detached 9484dbc

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 Unverified89ACE636A8263BA0 The 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"
587587
588588 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.
589589
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+
590611 ## Queued messages
591612
592613 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 messages611 ## 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 {
967967 RespectGitignore: initialCfg.RespectGitignore,
968968 ThemeName: initialCfg.Theme,
969969 FlatTools: initialCfg.FlatToolRender(),
970+ CompactUser: initialCfg.CompactUserInput(),
970971 ExtensionThemes: extMgr.ThemeOptions,
971972 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
972973 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 {
3636 // set ("1"/"true" forces flat, "0"/"false" forces box).
3737 ToolRender string `json:"tool_render,omitempty"`
3838
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+
3945 // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by
4046 // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super.
4147 QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"`
@@ -121,6 +127,24 @@ func (c Config) FlatToolRender() bool {
121127 return strings.EqualFold(strings.TrimSpace(c.ToolRender), "flat")
122128 }
123129
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+
124148 // AuthPath returns the path to auth.json.
125149 func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }
126150
@@ -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 by45 // 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 {
6666 // resolved tool_render config / ZOT_FLAT_TOOLS env at startup.
6767 FlatTools bool
6868
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+
6974 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
7075 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
7176 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
@@ -478,9 +483,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
478483 i := &Interactive{
479484 cfg: cfg,
480485 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,
484490 },
485491 // Prompt is the standard half-block accent bar used by chat
486492 // 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 bool67 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. The74 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
70 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal75 // 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 chat491 // Prompt is the standard half-block accent bar used by chat
486 // speaker labels too, so the input gutter matches the rest492 // speaker labels too, so the input gutter matches the rest
M packages/tui/view.go +28 -3
@@ -138,6 +138,12 @@ type View struct {
138138 // False (the default) keeps the bordered box.
139139 FlatTools bool
140140
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+
141147 // ExpandAll forces every long tool result to render in full.
142148 // Toggled from the tui by ctrl+o. When false, results longer than
143149 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
@@ -641,6 +647,19 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
641647 bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))
642648 return bar + padded
643649 }
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+ }
644663 var bubble []string
645664 for _, c := range m.Content {
646665 switch b := c.(type) {
@@ -656,9 +675,15 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
656675 }
657676 }
658677 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+ }
662687 }
663688 case provider.RoleAssistant:
664689 // 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 bool139 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 than148 // Toggled from the tui by ctrl+o. When false, results longer than
143 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a149 // 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 + padded648 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 []string663 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 a689 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close