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.
- commit
- 9484dbcb8a1f3ee57ae10891028622b7f973416f
- author
- kekkker · · 2026-06-26 08:33 UTC
- parent
- cf4ee29
- signature
- GPG Unverified
89ACE636A8263BA0The key is unknown
6 files · +164 -6
M README.md +21 -0
| @@ -587,6 +587,27 @@ ZOT_FLAT_TOOLS=0 zot # boxes, even if config.json says "flat" | |||
| 587 | 587 | ||
| 588 | 588 | 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. | |
| 589 | 589 | ||
| 590 | + | ## Compact input | |
| 591 | + | ||
| 592 | + | 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. | |
| 593 | + | ||
| 594 | + | ```json | |
| 595 | + | { | |
| 596 | + | "compact_input": true | |
| 597 | + | } | |
| 598 | + | ``` | |
| 599 | + | ||
| 600 | + | | Value | Effect | | |
| 601 | + | |---|---| | |
| 602 | + | | unset / `false` (default) | Padded, background-tinted user bubble. | | |
| 603 | + | | `true` | One quiet gutter line per wrapped row. | | |
| 604 | + | ||
| 605 | + | 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): | |
| 606 | + | ||
| 607 | + | ```sh | |
| 608 | + | ZOT_COMPACT_INPUT=1 zot # compact, just this run | |
| 609 | + | ``` | |
| 610 | + | ||
| 590 | 611 | ## Queued messages | |
| 591 | 612 | ||
| 592 | 613 | 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. | |
| @@ -587,6 +587,27 @@ ZOT_FLAT_TOOLS=0 zot # boxes, even if config.json says "flat" | |||||
| 587 | 587 | ||||
| 588 | 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. | 588 | 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. | ||
| 589 | 589 | ||||
| 590 | + | ## Compact input | |||
| 591 | + | ||||
| 592 | + | 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. | |||
| 593 | + | ||||
| 594 | + | ```json | |||
| 595 | + | { | |||
| 596 | + | "compact_input": true | |||
| 597 | + | } | |||
| 598 | + | ``` | |||
| 599 | + | ||||
| 600 | + | | Value | Effect | | |||
| 601 | + | |---|---| | |||
| 602 | + | | unset / `false` (default) | Padded, background-tinted user bubble. | | |||
| 603 | + | | `true` | One quiet gutter line per wrapped row. | | |||
| 604 | + | ||||
| 605 | + | 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): | |||
| 606 | + | ||||
| 607 | + | ```sh | |||
| 608 | + | ZOT_COMPACT_INPUT=1 zot # compact, just this run | |||
| 609 | + | ``` | |||
| 610 | + | ||||
| 590 | ## Queued messages | 611 | ## Queued messages | ||
| 591 | 612 | ||||
| 592 | 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. | 613 | 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. | ||
M packages/agent/cli.go +1 -0
| @@ -967,6 +967,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 967 | 967 | RespectGitignore: initialCfg.RespectGitignore, | |
| 968 | 968 | ThemeName: initialCfg.Theme, | |
| 969 | 969 | FlatTools: initialCfg.FlatToolRender(), | |
| 970 | + | CompactUser: initialCfg.CompactUserInput(), | |
| 970 | 971 | ExtensionThemes: extMgr.ThemeOptions, | |
| 971 | 972 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | |
| 972 | 973 | SettingsStore: configSettingsStore{}, | |
| @@ -967,6 +967,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 967 | RespectGitignore: initialCfg.RespectGitignore, | 967 | RespectGitignore: initialCfg.RespectGitignore, | ||
| 968 | ThemeName: initialCfg.Theme, | 968 | ThemeName: initialCfg.Theme, | ||
| 969 | FlatTools: initialCfg.FlatToolRender(), | 969 | FlatTools: initialCfg.FlatToolRender(), | ||
| 970 | + | CompactUser: initialCfg.CompactUserInput(), | |||
| 970 | ExtensionThemes: extMgr.ThemeOptions, | 971 | ExtensionThemes: extMgr.ThemeOptions, | ||
| 971 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | 972 | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | ||
| 972 | SettingsStore: configSettingsStore{}, | 973 | SettingsStore: configSettingsStore{}, | ||
M packages/agent/config.go +24 -0
| @@ -36,6 +36,12 @@ type Config struct { | |||
| 36 | 36 | // set ("1"/"true" forces flat, "0"/"false" forces box). | |
| 37 | 37 | ToolRender string `json:"tool_render,omitempty"` | |
| 38 | 38 | ||
| 39 | + | // CompactInput renders sent user messages as a single quiet gutter | |
| 40 | + | // line instead of a padded, background-tinted bubble. nil/false | |
| 41 | + | // (the default) keeps the bubble. The ZOT_COMPACT_INPUT env var | |
| 42 | + | // overrides this when set. | |
| 43 | + | CompactInput *bool `json:"compact_input,omitempty"` | |
| 44 | + | ||
| 39 | 45 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by | |
| 40 | 46 | // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. | |
| 41 | 47 | QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` | |
| @@ -121,6 +127,24 @@ func (c Config) FlatToolRender() bool { | |||
| 121 | 127 | return strings.EqualFold(strings.TrimSpace(c.ToolRender), "flat") | |
| 122 | 128 | } | |
| 123 | 129 | ||
| 130 | + | // CompactUserInput reports whether sent user messages should render as | |
| 131 | + | // a single quiet gutter line instead of a padded, background-tinted | |
| 132 | + | // bubble. The ZOT_COMPACT_INPUT env var takes precedence over the | |
| 133 | + | // config when set: "1"/"true"/"yes"/"on" force compact, "0"/"false"/ | |
| 134 | + | // "no"/"off" force the bubble. Otherwise the config's compact_input | |
| 135 | + | // is consulted (nil/false means the bubble). | |
| 136 | + | func (c Config) CompactUserInput() bool { | |
| 137 | + | if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZOT_COMPACT_INPUT"))); v != "" { | |
| 138 | + | switch v { | |
| 139 | + | case "1", "true", "yes", "on", "compact": | |
| 140 | + | return true | |
| 141 | + | case "0", "false", "no", "off", "bubble": | |
| 142 | + | return false | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | return c.CompactInput != nil && *c.CompactInput | |
| 146 | + | } | |
| 147 | + | ||
| 124 | 148 | // AuthPath returns the path to auth.json. | |
| 125 | 149 | func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") } | |
| 126 | 150 | ||
| @@ -36,6 +36,12 @@ type Config struct { | |||||
| 36 | // set ("1"/"true" forces flat, "0"/"false" forces box). | 36 | // set ("1"/"true" forces flat, "0"/"false" forces box). | ||
| 37 | ToolRender string `json:"tool_render,omitempty"` | 37 | ToolRender string `json:"tool_render,omitempty"` | ||
| 38 | 38 | ||||
| 39 | + | // CompactInput renders sent user messages as a single quiet gutter | |||
| 40 | + | // line instead of a padded, background-tinted bubble. nil/false | |||
| 41 | + | // (the default) keeps the bubble. The ZOT_COMPACT_INPUT env var | |||
| 42 | + | // overrides this when set. | |||
| 43 | + | CompactInput *bool `json:"compact_input,omitempty"` | |||
| 44 | + | ||||
| 39 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by | 45 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by | ||
| 40 | // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. | 46 | // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. | ||
| 41 | QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` | 47 | QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` | ||
| @@ -121,6 +127,24 @@ func (c Config) FlatToolRender() bool { | |||||
| 121 | return strings.EqualFold(strings.TrimSpace(c.ToolRender), "flat") | 127 | return strings.EqualFold(strings.TrimSpace(c.ToolRender), "flat") | ||
| 122 | } | 128 | } | ||
| 123 | 129 | ||||
| 130 | + | // CompactUserInput reports whether sent user messages should render as | |||
| 131 | + | // a single quiet gutter line instead of a padded, background-tinted | |||
| 132 | + | // bubble. The ZOT_COMPACT_INPUT env var takes precedence over the | |||
| 133 | + | // config when set: "1"/"true"/"yes"/"on" force compact, "0"/"false"/ | |||
| 134 | + | // "no"/"off" force the bubble. Otherwise the config's compact_input | |||
| 135 | + | // is consulted (nil/false means the bubble). | |||
| 136 | + | func (c Config) CompactUserInput() bool { | |||
| 137 | + | if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZOT_COMPACT_INPUT"))); v != "" { | |||
| 138 | + | switch v { | |||
| 139 | + | case "1", "true", "yes", "on", "compact": | |||
| 140 | + | return true | |||
| 141 | + | case "0", "false", "no", "off", "bubble": | |||
| 142 | + | return false | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | return c.CompactInput != nil && *c.CompactInput | |||
| 146 | + | } | |||
| 147 | + | ||||
| 124 | // AuthPath returns the path to auth.json. | 148 | // AuthPath returns the path to auth.json. | ||
| 125 | func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") } | 149 | func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") } | ||
| 126 | 150 | ||||
M packages/agent/modes/interactive.go +9 -3
| @@ -66,6 +66,11 @@ type InteractiveConfig struct { | |||
| 66 | 66 | // resolved tool_render config / ZOT_FLAT_TOOLS env at startup. | |
| 67 | 67 | FlatTools bool | |
| 68 | 68 | ||
| 69 | + | // CompactUser renders sent user messages as a single quiet gutter | |
| 70 | + | // line instead of a padded, tinted bubble. Mirrors the resolved | |
| 71 | + | // compact_input config / ZOT_COMPACT_INPUT env at startup. | |
| 72 | + | CompactUser bool | |
| 73 | + | ||
| 69 | 74 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The | |
| 70 | 75 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | |
| 71 | 76 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | |
| @@ -478,9 +483,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 478 | 483 | i := &Interactive{ | |
| 479 | 484 | cfg: cfg, | |
| 480 | 485 | view: &tui.View{ | |
| 481 | - | Theme: cfg.Theme, | |
| 482 | - | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | |
| 483 | - | FlatTools: cfg.FlatTools, | |
| 486 | + | Theme: cfg.Theme, | |
| 487 | + | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | |
| 488 | + | FlatTools: cfg.FlatTools, | |
| 489 | + | CompactUser: cfg.CompactUser, | |
| 484 | 490 | }, | |
| 485 | 491 | // Prompt is the standard half-block accent bar used by chat | |
| 486 | 492 | // speaker labels too, so the input gutter matches the rest | |
| @@ -66,6 +66,11 @@ type InteractiveConfig struct { | |||||
| 66 | // resolved tool_render config / ZOT_FLAT_TOOLS env at startup. | 66 | // resolved tool_render config / ZOT_FLAT_TOOLS env at startup. | ||
| 67 | FlatTools bool | 67 | FlatTools bool | ||
| 68 | 68 | ||||
| 69 | + | // CompactUser renders sent user messages as a single quiet gutter | |||
| 70 | + | // line instead of a padded, tinted bubble. Mirrors the resolved | |||
| 71 | + | // compact_input config / ZOT_COMPACT_INPUT env at startup. | |||
| 72 | + | CompactUser bool | |||
| 73 | + | ||||
| 69 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The | 74 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The | ||
| 70 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | 75 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | ||
| 71 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | 76 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | ||
| @@ -478,9 +483,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 478 | i := &Interactive{ | 483 | i := &Interactive{ | ||
| 479 | cfg: cfg, | 484 | cfg: cfg, | ||
| 480 | view: &tui.View{ | 485 | view: &tui.View{ | ||
| 481 | - | Theme: cfg.Theme, | 486 | + | Theme: cfg.Theme, |
| 482 | - | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | 487 | + | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), |
| 483 | - | FlatTools: cfg.FlatTools, | 488 | + | FlatTools: cfg.FlatTools, |
| 489 | + | CompactUser: cfg.CompactUser, | |||
| 484 | }, | 490 | }, | ||
| 485 | // Prompt is the standard half-block accent bar used by chat | 491 | // Prompt is the standard half-block accent bar used by chat | ||
| 486 | // speaker labels too, so the input gutter matches the rest | 492 | // speaker labels too, so the input gutter matches the rest | ||
M packages/tui/view.go +28 -3
| @@ -138,6 +138,12 @@ type View struct { | |||
| 138 | 138 | // False (the default) keeps the bordered box. | |
| 139 | 139 | FlatTools bool | |
| 140 | 140 | ||
| 141 | + | // CompactUser renders sent user messages as a single quiet gutter | |
| 142 | + | // line per wrapped row instead of a tinted bubble with a blank | |
| 143 | + | // padding row above and below. False (the default) keeps the | |
| 144 | + | // padded, background-tinted bubble. | |
| 145 | + | CompactUser bool | |
| 146 | + | ||
| 141 | 147 | // ExpandAll forces every long tool result to render in full. | |
| 142 | 148 | // Toggled from the tui by ctrl+o. When false, results longer than | |
| 143 | 149 | // ToolCollapseLines collapse to ToolCollapsePreview lines plus a | |
| @@ -641,6 +647,19 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 641 | 647 | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | |
| 642 | 648 | return bar + padded | |
| 643 | 649 | } | |
| 650 | + | if v.CompactUser { | |
| 651 | + | // Compact: no tinted bubble background and no padding rows. | |
| 652 | + | // Each wrapped row is a quiet "▌ text" gutter line, matching | |
| 653 | + | // the flat tool-call header so user turns still segment the | |
| 654 | + | // chat without the loud panel. | |
| 655 | + | innerWidth = width - 2 | |
| 656 | + | if innerWidth < 1 { | |
| 657 | + | innerWidth = 1 | |
| 658 | + | } | |
| 659 | + | row = func(content string) string { | |
| 660 | + | return v.Theme.FG256(v.Theme.Accent, "▌ ") + content | |
| 661 | + | } | |
| 662 | + | } | |
| 644 | 663 | var bubble []string | |
| 645 | 664 | for _, c := range m.Content { | |
| 646 | 665 | switch b := c.(type) { | |
| @@ -656,9 +675,15 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 656 | 675 | } | |
| 657 | 676 | } | |
| 658 | 677 | if len(bubble) > 0 { | |
| 659 | - | lines = append(lines, row("")) | |
| 660 | - | lines = append(lines, bubble...) | |
| 661 | - | lines = append(lines, row("")) | |
| 678 | + | if v.CompactUser { | |
| 679 | + | // No tinted padding rows in compact mode; the inter-message | |
| 680 | + | // blank from Build() already gives the turn breathing room. | |
| 681 | + | lines = append(lines, bubble...) | |
| 682 | + | } else { | |
| 683 | + | lines = append(lines, row("")) | |
| 684 | + | lines = append(lines, bubble...) | |
| 685 | + | lines = append(lines, row("")) | |
| 686 | + | } | |
| 662 | 687 | } | |
| 663 | 688 | case provider.RoleAssistant: | |
| 664 | 689 | // Assistant rows: no speaker label either. Prose still gets a | |
| @@ -138,6 +138,12 @@ type View struct { | |||||
| 138 | // False (the default) keeps the bordered box. | 138 | // False (the default) keeps the bordered box. | ||
| 139 | FlatTools bool | 139 | FlatTools bool | ||
| 140 | 140 | ||||
| 141 | + | // CompactUser renders sent user messages as a single quiet gutter | |||
| 142 | + | // line per wrapped row instead of a tinted bubble with a blank | |||
| 143 | + | // padding row above and below. False (the default) keeps the | |||
| 144 | + | // padded, background-tinted bubble. | |||
| 145 | + | CompactUser bool | |||
| 146 | + | ||||
| 141 | // ExpandAll forces every long tool result to render in full. | 147 | // ExpandAll forces every long tool result to render in full. | ||
| 142 | // Toggled from the tui by ctrl+o. When false, results longer than | 148 | // Toggled from the tui by ctrl+o. When false, results longer than | ||
| 143 | // ToolCollapseLines collapse to ToolCollapsePreview lines plus a | 149 | // ToolCollapseLines collapse to ToolCollapsePreview lines plus a | ||
| @@ -641,6 +647,19 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 641 | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | 647 | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | ||
| 642 | return bar + padded | 648 | return bar + padded | ||
| 643 | } | 649 | } | ||
| 650 | + | if v.CompactUser { | |||
| 651 | + | // Compact: no tinted bubble background and no padding rows. | |||
| 652 | + | // Each wrapped row is a quiet "▌ text" gutter line, matching | |||
| 653 | + | // the flat tool-call header so user turns still segment the | |||
| 654 | + | // chat without the loud panel. | |||
| 655 | + | innerWidth = width - 2 | |||
| 656 | + | if innerWidth < 1 { | |||
| 657 | + | innerWidth = 1 | |||
| 658 | + | } | |||
| 659 | + | row = func(content string) string { | |||
| 660 | + | return v.Theme.FG256(v.Theme.Accent, "▌ ") + content | |||
| 661 | + | } | |||
| 662 | + | } | |||
| 644 | var bubble []string | 663 | var bubble []string | ||
| 645 | for _, c := range m.Content { | 664 | for _, c := range m.Content { | ||
| 646 | switch b := c.(type) { | 665 | switch b := c.(type) { | ||
| @@ -656,9 +675,15 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 656 | } | 675 | } | ||
| 657 | } | 676 | } | ||
| 658 | if len(bubble) > 0 { | 677 | if len(bubble) > 0 { | ||
| 659 | - | lines = append(lines, row("")) | 678 | + | if v.CompactUser { |
| 660 | - | lines = append(lines, bubble...) | 679 | + | // No tinted padding rows in compact mode; the inter-message |
| 661 | - | lines = append(lines, row("")) | 680 | + | // blank from Build() already gives the turn breathing room. |
| 681 | + | lines = append(lines, bubble...) | |||
| 682 | + | } else { | |||
| 683 | + | lines = append(lines, row("")) | |||
| 684 | + | lines = append(lines, bubble...) | |||
| 685 | + | lines = append(lines, row("")) | |||
| 686 | + | } | |||
| 662 | } | 687 | } | ||
| 663 | case provider.RoleAssistant: | 688 | case provider.RoleAssistant: | ||
| 664 | // Assistant rows: no speaker label either. Prose still gets a | 689 | // Assistant rows: no speaker label either. Prose still gets a | ||
A packages/tui/view_compact_user_test.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func userMsg(text string) provider.Message { | |
| 11 | + | return provider.Message{ | |
| 12 | + | Role: provider.RoleUser, | |
| 13 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 14 | + | } | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // userBubbleRows counts the rows that carry the user gutter bar "▌". | |
| 18 | + | func userBubbleRows(plain string) int { | |
| 19 | + | n := 0 | |
| 20 | + | for _, l := range strings.Split(plain, "\n") { | |
| 21 | + | if strings.Contains(l, "▌") { | |
| 22 | + | n++ | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | return n | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // A one-line prompt is a 3-row bubble by default (blank pad, text, | |
| 29 | + | // blank pad); compact mode collapses it to a single text row. | |
| 30 | + | func TestCompactUserDropsPaddingRows(t *testing.T) { | |
| 31 | + | bubble := View{Theme: Dark, Messages: []provider.Message{userMsg("hello")}} | |
| 32 | + | bubblePlain := stripANSI(strings.Join(bubble.Build(80), "\n")) | |
| 33 | + | if got := userBubbleRows(bubblePlain); got != 3 { | |
| 34 | + | t.Fatalf("default bubble should be 3 gutter rows (pad+text+pad), got %d:\n%s", got, bubblePlain) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | compact := View{Theme: Dark, CompactUser: true, Messages: []provider.Message{userMsg("hello")}} | |
| 38 | + | compactPlain := stripANSI(strings.Join(compact.Build(80), "\n")) | |
| 39 | + | if got := userBubbleRows(compactPlain); got != 1 { | |
| 40 | + | t.Fatalf("compact user should be a single gutter row, got %d:\n%s", got, compactPlain) | |
| 41 | + | } | |
| 42 | + | if !strings.Contains(compactPlain, "▌ hello") { | |
| 43 | + | t.Fatalf("compact user lost the gutter or text:\n%s", compactPlain) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Compact mode must never paint the bubble background tint. The tint | |
| 48 | + | // is an SGR background sequence (ESC[48;...m); the compact path uses | |
| 49 | + | // only a foreground-colored gutter, so no 48; should appear on the | |
| 50 | + | // user rows. | |
| 51 | + | func TestCompactUserHasNoBackgroundTint(t *testing.T) { | |
| 52 | + | compact := View{Theme: Dark, CompactUser: true, Messages: []provider.Message{userMsg("hi there")}} | |
| 53 | + | raw := strings.Join(compact.Build(80), "\n") | |
| 54 | + | if strings.Contains(raw, "[48;") { | |
| 55 | + | t.Fatalf("compact user should not paint a background tint:\n%q", raw) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // The default bubble does tint, so this is a meaningful assertion. | |
| 59 | + | bubble := View{Theme: Dark, Messages: []provider.Message{userMsg("hi there")}} | |
| 60 | + | if rawB := strings.Join(bubble.Build(80), "\n"); !strings.Contains(rawB, "[48;") { | |
| 61 | + | t.Fatalf("default bubble was expected to paint a background tint:\n%q", rawB) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Multi-line prompts keep every wrapped row in compact mode, just | |
| 66 | + | // without the surrounding padding rows. | |
| 67 | + | func TestCompactUserKeepsAllWrappedRows(t *testing.T) { | |
| 68 | + | long := strings.Repeat("word ", 60) // forces several wrapped rows | |
| 69 | + | compact := View{Theme: Dark, CompactUser: true, Messages: []provider.Message{userMsg(long)}} | |
| 70 | + | plain := stripANSI(strings.Join(compact.Build(40), "\n")) | |
| 71 | + | rows := userBubbleRows(plain) | |
| 72 | + | if rows < 3 { | |
| 73 | + | t.Fatalf("expected multiple wrapped gutter rows, got %d:\n%s", rows, plain) | |
| 74 | + | } | |
| 75 | + | // No empty gutter rows (that would be leftover padding). | |
| 76 | + | for _, l := range strings.Split(plain, "\n") { | |
| 77 | + | if strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(l), "▌")) == "" && strings.Contains(l, "▌") { | |
| 78 | + | t.Fatalf("compact user emitted an empty padding row:\n%s", plain) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| @@ -0,0 +1,81 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func userMsg(text string) provider.Message { | |||
| 11 | + | return provider.Message{ | |||
| 12 | + | Role: provider.RoleUser, | |||
| 13 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |||
| 14 | + | } | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // userBubbleRows counts the rows that carry the user gutter bar "▌". | |||
| 18 | + | func userBubbleRows(plain string) int { | |||
| 19 | + | n := 0 | |||
| 20 | + | for _, l := range strings.Split(plain, "\n") { | |||
| 21 | + | if strings.Contains(l, "▌") { | |||
| 22 | + | n++ | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | return n | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // A one-line prompt is a 3-row bubble by default (blank pad, text, | |||
| 29 | + | // blank pad); compact mode collapses it to a single text row. | |||
| 30 | + | func TestCompactUserDropsPaddingRows(t *testing.T) { | |||
| 31 | + | bubble := View{Theme: Dark, Messages: []provider.Message{userMsg("hello")}} | |||
| 32 | + | bubblePlain := stripANSI(strings.Join(bubble.Build(80), "\n")) | |||
| 33 | + | if got := userBubbleRows(bubblePlain); got != 3 { | |||
| 34 | + | t.Fatalf("default bubble should be 3 gutter rows (pad+text+pad), got %d:\n%s", got, bubblePlain) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | compact := View{Theme: Dark, CompactUser: true, Messages: []provider.Message{userMsg("hello")}} | |||
| 38 | + | compactPlain := stripANSI(strings.Join(compact.Build(80), "\n")) | |||
| 39 | + | if got := userBubbleRows(compactPlain); got != 1 { | |||
| 40 | + | t.Fatalf("compact user should be a single gutter row, got %d:\n%s", got, compactPlain) | |||
| 41 | + | } | |||
| 42 | + | if !strings.Contains(compactPlain, "▌ hello") { | |||
| 43 | + | t.Fatalf("compact user lost the gutter or text:\n%s", compactPlain) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // Compact mode must never paint the bubble background tint. The tint | |||
| 48 | + | // is an SGR background sequence (ESC[48;...m); the compact path uses | |||
| 49 | + | // only a foreground-colored gutter, so no 48; should appear on the | |||
| 50 | + | // user rows. | |||
| 51 | + | func TestCompactUserHasNoBackgroundTint(t *testing.T) { | |||
| 52 | + | compact := View{Theme: Dark, CompactUser: true, Messages: []provider.Message{userMsg("hi there")}} | |||
| 53 | + | raw := strings.Join(compact.Build(80), "\n") | |||
| 54 | + | if strings.Contains(raw, "[48;") { | |||
| 55 | + | t.Fatalf("compact user should not paint a background tint:\n%q", raw) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // The default bubble does tint, so this is a meaningful assertion. | |||
| 59 | + | bubble := View{Theme: Dark, Messages: []provider.Message{userMsg("hi there")}} | |||
| 60 | + | if rawB := strings.Join(bubble.Build(80), "\n"); !strings.Contains(rawB, "[48;") { | |||
| 61 | + | t.Fatalf("default bubble was expected to paint a background tint:\n%q", rawB) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // Multi-line prompts keep every wrapped row in compact mode, just | |||
| 66 | + | // without the surrounding padding rows. | |||
| 67 | + | func TestCompactUserKeepsAllWrappedRows(t *testing.T) { | |||
| 68 | + | long := strings.Repeat("word ", 60) // forces several wrapped rows | |||
| 69 | + | compact := View{Theme: Dark, CompactUser: true, Messages: []provider.Message{userMsg(long)}} | |||
| 70 | + | plain := stripANSI(strings.Join(compact.Build(40), "\n")) | |||
| 71 | + | rows := userBubbleRows(plain) | |||
| 72 | + | if rows < 3 { | |||
| 73 | + | t.Fatalf("expected multiple wrapped gutter rows, got %d:\n%s", rows, plain) | |||
| 74 | + | } | |||
| 75 | + | // No empty gutter rows (that would be leftover padding). | |||
| 76 | + | for _, l := range strings.Split(plain, "\n") { | |||
| 77 | + | if strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(l), "▌")) == "" && strings.Contains(l, "▌") { | |||
| 78 | + | t.Fatalf("compact user emitted an empty padding row:\n%s", plain) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | } | |||