detached 0e72ca7

Add compact transcript mode setting

commit
0e72ca708fb60c2649fa8703e2ddabe0a9a70162
author
patriceckhart · · 2026-06-29 18:34 UTC
parent
adfda69
signature
unsigned
11 files · +184 -32
M README.md +1 -0
@@ -301,6 +301,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
301301
302302 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
303303 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
304+- **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
304305 - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.
305306 - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).
306307 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
@@ -301,6 +301,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
301 301
302 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.302 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
303 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.303 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
304+- **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
304 - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.305 - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.
305 - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).306 - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).
306 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.307 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
M packages/agent/cli.go +1 -0
@@ -965,6 +965,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
965965 QuickModelShortcuts: quickModelShortcuts,
966966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
967967 RespectGitignore: initialCfg.RespectGitignore,
968+ CompactMode: initialCfg.CompactMode,
968969 ThemeName: initialCfg.Theme,
969970 FlatTools: initialCfg.FlatToolRender(),
970971 CompactUser: initialCfg.CompactUserInput(),
@@ -965,6 +965,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
965 QuickModelShortcuts: quickModelShortcuts,965 QuickModelShortcuts: quickModelShortcuts,
966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
967 RespectGitignore: initialCfg.RespectGitignore,967 RespectGitignore: initialCfg.RespectGitignore,
968+ CompactMode: initialCfg.CompactMode,
968 ThemeName: initialCfg.Theme,969 ThemeName: initialCfg.Theme,
969 FlatTools: initialCfg.FlatToolRender(),970 FlatTools: initialCfg.FlatToolRender(),
970 CompactUser: initialCfg.CompactUserInput(),971 CompactUser: initialCfg.CompactUserInput(),
M packages/agent/config.go +6 -0
@@ -69,6 +69,12 @@ type Config struct {
6969 // which is on; false shows ignored entries. Toggle from /settings.
7070 RespectGitignore *bool `json:"respect_gitignore,omitempty"`
7171
72+ // CompactMode renders the interactive transcript with less chrome:
73+ // tool calls use flat headers instead of bordered panels, and sent
74+ // user messages render without padded background bubbles. Off by
75+ // default; nil/missing means disabled. Toggle from /settings.
76+ CompactMode *bool `json:"compact_mode,omitempty"`
77+
7278 // Insecure skips TLS verification for custom inference endpoints.
7379 Insecure bool `json:"insecure,omitempty"`
7480
@@ -69,6 +69,12 @@ type Config struct {
69 // which is on; false shows ignored entries. Toggle from /settings.69 // which is on; false shows ignored entries. Toggle from /settings.
70 RespectGitignore *bool `json:"respect_gitignore,omitempty"`70 RespectGitignore *bool `json:"respect_gitignore,omitempty"`
71 71
72+ // CompactMode renders the interactive transcript with less chrome:
73+ // tool calls use flat headers instead of bordered panels, and sent
74+ // user messages render without padded background bubbles. Off by
75+ // default; nil/missing means disabled. Toggle from /settings.
76+ CompactMode *bool `json:"compact_mode,omitempty"`
77+
72 // Insecure skips TLS verification for custom inference endpoints.78 // Insecure skips TLS verification for custom inference endpoints.
73 Insecure bool `json:"insecure,omitempty"`79 Insecure bool `json:"insecure,omitempty"`
74 80
M packages/agent/modes/btw_dialog.go +24 -10
@@ -30,12 +30,13 @@ type btwTurn struct {
3030 // Cancellation: esc cancels an in-flight call when one is running,
3131 // otherwise closes the dialog.
3232 type btwDialog struct {
33- mu sync.Mutex
34- active bool
35- turns []btwTurn
36- editor *tui.Editor
37- loading bool
38- cancel context.CancelFunc
33+ mu sync.Mutex
34+ active bool
35+ turns []btwTurn
36+ editor *tui.Editor
37+ loading bool
38+ compactMode bool
39+ cancel context.CancelFunc
3940
4041 // spin drives the same braille animation + rotating funny-line
4142 // shown in the main status bar. Owned by the dialog so its clock
@@ -100,10 +101,11 @@ func (d *btwDialog) Loading() bool {
100101 // auto-submitted (so /btw <text> starts a conversation right away).
101102 // invalidate, if non-nil, is called after each state change so the
102103 // host redraw loop can pick up the update without polling.
103-func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, invalidate func()) {
104+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode bool, invalidate func()) {
104105 d.mu.Lock()
105106 d.active = true
106107 d.theme = th
108+ d.compactMode = compactMode
107109 d.turns = nil
108110 d.loading = false
109111 d.cancel = nil
@@ -319,7 +321,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
319321
320322 for _, t := range d.turns {
321323 out = append(out, "")
322- out = append(out, btwUserBubbleRows(th, t.User, width-2)...)
324+ out = append(out, btwUserBubbleRows(th, t.User, width-2, d.compactMode)...)
323325 if t.Assistant != "" {
324326 out = append(out, "")
325327 md := tui.RenderMarkdown(t.Assistant, th, width-4)
@@ -390,7 +392,7 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
390392 }
391393 for _, t := range d.turns {
392394 editorOffset++ // blank
393- editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2))
395+ editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2, d.compactMode))
394396 if t.Assistant != "" {
395397 editorOffset++ // blank
396398 editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n"))
@@ -413,7 +415,7 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
413415 // panel, left-edge ▌ bar, padding rows above and below). The frame
414416 // padding is the caller's job; bubbleWidth is the available row
415417 // width inside the frame.
416-func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string {
418+func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int, compactMode bool) []string {
417419 const leftGutter = 0
418420 const rightGutter = 2
419421 innerWidth := bubbleWidth - 2 - leftGutter - rightGutter // 2 = bar's two cells
@@ -425,6 +427,15 @@ func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string {
425427 inner := strings.Repeat(" ", leftGutter) + content
426428 return " " + bar + th.UserBubble(inner, bubbleWidth-2)
427429 }
430+ if compactMode {
431+ innerWidth = bubbleWidth - 2
432+ if innerWidth < 1 {
433+ innerWidth = 1
434+ }
435+ row = func(content string) string {
436+ return " " + th.FG256(th.Accent, "▌ ") + th.FG256(th.Muted, content)
437+ }
438+ }
428439 var bubble []string
429440 for _, l := range strings.Split(text, "\n") {
430441 for _, w := range tui.WrapANSILine(l, innerWidth) {
@@ -434,6 +445,9 @@ func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string {
434445 if len(bubble) == 0 {
435446 return nil
436447 }
448+ if compactMode {
449+ return bubble
450+ }
437451 out := make([]string, 0, len(bubble)+2)
438452 out = append(out, row(""))
439453 out = append(out, bubble...)
@@ -30,12 +30,13 @@ type btwTurn struct {
30 // Cancellation: esc cancels an in-flight call when one is running,30 // Cancellation: esc cancels an in-flight call when one is running,
31 // otherwise closes the dialog.31 // otherwise closes the dialog.
32 type btwDialog struct {32 type btwDialog struct {
33- mu sync.Mutex33+ mu sync.Mutex
34- active bool34+ active bool
35- turns []btwTurn35+ turns []btwTurn
36- editor *tui.Editor36+ editor *tui.Editor
37- loading bool37+ loading bool
38- cancel context.CancelFunc38+ compactMode bool
39+ cancel context.CancelFunc
39 40
40 // spin drives the same braille animation + rotating funny-line41 // spin drives the same braille animation + rotating funny-line
41 // shown in the main status bar. Owned by the dialog so its clock42 // shown in the main status bar. Owned by the dialog so its clock
@@ -100,10 +101,11 @@ func (d *btwDialog) Loading() bool {
100 // auto-submitted (so /btw <text> starts a conversation right away).101 // auto-submitted (so /btw <text> starts a conversation right away).
101 // invalidate, if non-nil, is called after each state change so the102 // invalidate, if non-nil, is called after each state change so the
102 // host redraw loop can pick up the update without polling.103 // host redraw loop can pick up the update without polling.
103-func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, invalidate func()) {104+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode bool, invalidate func()) {
104 d.mu.Lock()105 d.mu.Lock()
105 d.active = true106 d.active = true
106 d.theme = th107 d.theme = th
108+ d.compactMode = compactMode
107 d.turns = nil109 d.turns = nil
108 d.loading = false110 d.loading = false
109 d.cancel = nil111 d.cancel = nil
@@ -319,7 +321,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
319 321
320 for _, t := range d.turns {322 for _, t := range d.turns {
321 out = append(out, "")323 out = append(out, "")
322- out = append(out, btwUserBubbleRows(th, t.User, width-2)...)324+ out = append(out, btwUserBubbleRows(th, t.User, width-2, d.compactMode)...)
323 if t.Assistant != "" {325 if t.Assistant != "" {
324 out = append(out, "")326 out = append(out, "")
325 md := tui.RenderMarkdown(t.Assistant, th, width-4)327 md := tui.RenderMarkdown(t.Assistant, th, width-4)
@@ -390,7 +392,7 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
390 }392 }
391 for _, t := range d.turns {393 for _, t := range d.turns {
392 editorOffset++ // blank394 editorOffset++ // blank
393- editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2))395+ editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2, d.compactMode))
394 if t.Assistant != "" {396 if t.Assistant != "" {
395 editorOffset++ // blank397 editorOffset++ // blank
396 editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n"))398 editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n"))
@@ -413,7 +415,7 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
413 // panel, left-edge ▌ bar, padding rows above and below). The frame415 // panel, left-edge ▌ bar, padding rows above and below). The frame
414 // padding is the caller's job; bubbleWidth is the available row416 // padding is the caller's job; bubbleWidth is the available row
415 // width inside the frame.417 // width inside the frame.
416-func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string {418+func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int, compactMode bool) []string {
417 const leftGutter = 0419 const leftGutter = 0
418 const rightGutter = 2420 const rightGutter = 2
419 innerWidth := bubbleWidth - 2 - leftGutter - rightGutter // 2 = bar's two cells421 innerWidth := bubbleWidth - 2 - leftGutter - rightGutter // 2 = bar's two cells
@@ -425,6 +427,15 @@ func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string {
425 inner := strings.Repeat(" ", leftGutter) + content427 inner := strings.Repeat(" ", leftGutter) + content
426 return " " + bar + th.UserBubble(inner, bubbleWidth-2)428 return " " + bar + th.UserBubble(inner, bubbleWidth-2)
427 }429 }
430+ if compactMode {
431+ innerWidth = bubbleWidth - 2
432+ if innerWidth < 1 {
433+ innerWidth = 1
434+ }
435+ row = func(content string) string {
436+ return " " + th.FG256(th.Accent, "▌ ") + th.FG256(th.Muted, content)
437+ }
438+ }
428 var bubble []string439 var bubble []string
429 for _, l := range strings.Split(text, "\n") {440 for _, l := range strings.Split(text, "\n") {
430 for _, w := range tui.WrapANSILine(l, innerWidth) {441 for _, w := range tui.WrapANSILine(l, innerWidth) {
@@ -434,6 +445,9 @@ func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string {
434 if len(bubble) == 0 {445 if len(bubble) == 0 {
435 return nil446 return nil
436 }447 }
448+ if compactMode {
449+ return bubble
450+ }
437 out := make([]string, 0, len(bubble)+2)451 out := make([]string, 0, len(bubble)+2)
438 out = append(out, row(""))452 out = append(out, row(""))
439 out = append(out, bubble...)453 out = append(out, bubble...)
M packages/agent/modes/interactive.go +35 -1
@@ -71,6 +71,10 @@ type InteractiveConfig struct {
7171 // compact_input config / ZOT_COMPACT_INPUT env at startup.
7272 CompactUser bool
7373
74+ // CompactMode mirrors the persisted compact_mode flag at startup.
75+ // When true, it enables both flat tool calls and compact user turns.
76+ CompactMode *bool
77+
7478 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
7579 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
7680 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
@@ -265,6 +269,7 @@ type SettingsStore interface {
265269 SetAutoSwarm(enabled bool) error
266270 SetRecursiveFileSuggest(enabled bool) error
267271 SetRespectGitignore(enabled bool) error
272+ SetCompactMode(enabled bool) error
268273 SetReasoning(level string) error
269274 SetTheme(name string) error
270275 }
@@ -487,6 +492,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
487492 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
488493 FlatTools: cfg.FlatTools,
489494 CompactUser: cfg.CompactUser,
495+ CompactMode: cfg.CompactMode != nil && *cfg.CompactMode,
490496 },
491497 // Prompt is the standard half-block accent bar used by chat
492498 // speaker labels too, so the input gutter matches the rest
@@ -2818,6 +2824,7 @@ func (i *Interactive) openSettingsDialog() {
28182824
28192825 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
28202826 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
2827+ compactMode := i.compactModeEnabled()
28212828 quickItems := i.quickModelSettingItems()
28222829
28232830 reasoningOptions := []settingsOption{
@@ -2895,6 +2902,12 @@ func (i *Interactive) openSettingsDialog() {
28952902 desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
28962903 value: respectGitignore,
28972904 },
2905+ {
2906+ key: "compact_mode",
2907+ label: "compact transcript rendering",
2908+ desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
2909+ value: compactMode,
2910+ },
28982911 {
28992912 key: "reasoning",
29002913 label: "thinking level",
@@ -2966,6 +2979,10 @@ func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
29662979 }
29672980 }
29682981
2982+func (i *Interactive) compactModeEnabled() bool {
2983+ return i.cfg.CompactMode != nil && *i.cfg.CompactMode
2984+}
2985+
29692986 func quickModelShortcutSlot(k tui.Key) int {
29702987 if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
29712988 return 0
@@ -3184,6 +3201,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
31843201 i.statusOK = "hide gitignored files in @-picker " + onOff(value)
31853202 i.statusErr = ""
31863203 i.mu.Unlock()
3204+ case "compact_mode":
3205+ val := value
3206+ i.cfg.CompactMode = &val
3207+ if i.cfg.SettingsStore != nil {
3208+ if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
3209+ i.mu.Lock()
3210+ i.statusErr = "settings: " + err.Error()
3211+ i.mu.Unlock()
3212+ return
3213+ }
3214+ }
3215+ i.mu.Lock()
3216+ i.view.CompactMode = value
3217+ i.view.InvalidateRenderCache()
3218+ i.statusOK = "compact transcript rendering " + onOff(value)
3219+ i.statusErr = ""
3220+ i.mu.Unlock()
31873221 }
31883222 }
31893223
@@ -4052,7 +4086,7 @@ func (i *Interactive) openBtwDialog(args []string) {
40524086 return
40534087 }
40544088 seed := strings.TrimSpace(strings.Join(args, " "))
4055- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.invalidate)
4089+ i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.invalidate)
40564090 i.invalidate()
40574091 }
40584092
@@ -71,6 +71,10 @@ type InteractiveConfig struct {
71 // compact_input config / ZOT_COMPACT_INPUT env at startup.71 // compact_input config / ZOT_COMPACT_INPUT env at startup.
72 CompactUser bool72 CompactUser bool
73 73
74+ // CompactMode mirrors the persisted compact_mode flag at startup.
75+ // When true, it enables both flat tool calls and compact user turns.
76+ CompactMode *bool
77+
74 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The78 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
75 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal79 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
76 // forwards Command/Super keypresses, but Ctrl is the displayed chord.80 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
@@ -265,6 +269,7 @@ type SettingsStore interface {
265 SetAutoSwarm(enabled bool) error269 SetAutoSwarm(enabled bool) error
266 SetRecursiveFileSuggest(enabled bool) error270 SetRecursiveFileSuggest(enabled bool) error
267 SetRespectGitignore(enabled bool) error271 SetRespectGitignore(enabled bool) error
272+ SetCompactMode(enabled bool) error
268 SetReasoning(level string) error273 SetReasoning(level string) error
269 SetTheme(name string) error274 SetTheme(name string) error
270 }275 }
@@ -487,6 +492,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
487 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),492 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
488 FlatTools: cfg.FlatTools,493 FlatTools: cfg.FlatTools,
489 CompactUser: cfg.CompactUser,494 CompactUser: cfg.CompactUser,
495+ CompactMode: cfg.CompactMode != nil && *cfg.CompactMode,
490 },496 },
491 // Prompt is the standard half-block accent bar used by chat497 // Prompt is the standard half-block accent bar used by chat
492 // speaker labels too, so the input gutter matches the rest498 // speaker labels too, so the input gutter matches the rest
@@ -2818,6 +2824,7 @@ func (i *Interactive) openSettingsDialog() {
2818 2824
2819 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest2825 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
2820 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore2826 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
2827+ compactMode := i.compactModeEnabled()
2821 quickItems := i.quickModelSettingItems()2828 quickItems := i.quickModelSettingItems()
2822 2829
2823 reasoningOptions := []settingsOption{2830 reasoningOptions := []settingsOption{
@@ -2895,6 +2902,12 @@ func (i *Interactive) openSettingsDialog() {
2895 desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",2902 desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
2896 value: respectGitignore,2903 value: respectGitignore,
2897 },2904 },
2905+ {
2906+ key: "compact_mode",
2907+ label: "compact transcript rendering",
2908+ desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
2909+ value: compactMode,
2910+ },
2898 {2911 {
2899 key: "reasoning",2912 key: "reasoning",
2900 label: "thinking level",2913 label: "thinking level",
@@ -2966,6 +2979,10 @@ func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
2966 }2979 }
2967 }2980 }
2968 2981
2982+func (i *Interactive) compactModeEnabled() bool {
2983+ return i.cfg.CompactMode != nil && *i.cfg.CompactMode
2984+}
2985+
2969 func quickModelShortcutSlot(k tui.Key) int {2986 func quickModelShortcutSlot(k tui.Key) int {
2970 if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {2987 if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
2971 return 02988 return 0
@@ -3184,6 +3201,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
3184 i.statusOK = "hide gitignored files in @-picker " + onOff(value)3201 i.statusOK = "hide gitignored files in @-picker " + onOff(value)
3185 i.statusErr = ""3202 i.statusErr = ""
3186 i.mu.Unlock()3203 i.mu.Unlock()
3204+ case "compact_mode":
3205+ val := value
3206+ i.cfg.CompactMode = &val
3207+ if i.cfg.SettingsStore != nil {
3208+ if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
3209+ i.mu.Lock()
3210+ i.statusErr = "settings: " + err.Error()
3211+ i.mu.Unlock()
3212+ return
3213+ }
3214+ }
3215+ i.mu.Lock()
3216+ i.view.CompactMode = value
3217+ i.view.InvalidateRenderCache()
3218+ i.statusOK = "compact transcript rendering " + onOff(value)
3219+ i.statusErr = ""
3220+ i.mu.Unlock()
3187 }3221 }
3188 }3222 }
3189 3223
@@ -4052,7 +4086,7 @@ func (i *Interactive) openBtwDialog(args []string) {
4052 return4086 return
4053 }4087 }
4054 seed := strings.TrimSpace(strings.Join(args, " "))4088 seed := strings.TrimSpace(strings.Join(args, " "))
4055- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.invalidate)4089+ i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.invalidate)
4056 i.invalidate()4090 i.invalidate()
4057 }4091 }
4058 4092
M packages/agent/modes/swarm_dialog.go +13 -8
@@ -32,10 +32,11 @@ import (
3232 // ↑/↓/PgUp/PgDn/Home/End scroll
3333 // esc / q return to the list
3434 type swarmDialog struct {
35- active bool
36- snapshot func() []swarm.AgentSnapshot
37- stop func(id string) error
38- remove func(id string) error
35+ active bool
36+ compactMode bool
37+ snapshot func() []swarm.AgentSnapshot
38+ stop func(id string) error
39+ remove func(id string) error
3940 // spawn accepts an optional model + provider override (empty
4041 // strings mean "let the child resolve its own default"). The cli
4142 // adapter forwards these to swarm.Swarm.SpawnReq.
@@ -119,6 +120,10 @@ func newSwarmDialog() *swarmDialog { return &swarmDialog{} }
119120 // run on whatever the user last picked via /model. Pass empty
120121 // strings to clear the override (the child then resolves its own
121122 // default the same way a bare `zot` invocation does).
123+func (d *swarmDialog) SetCompactMode(enabled bool) {
124+ d.compactMode = enabled
125+}
126+
122127 func (d *swarmDialog) SetCurrentModel(model, providerID string) {
123128 d.pendingModel = model
124129 d.pendingProvider = providerID
@@ -274,7 +279,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse
274279 // Full body, no scroll window — the host's outer scrollback
275280 // owns overflow now. Cursor row math just counts every
276281 // rendered line.
277- body := renderSwarmTranscriptBlocks(raw, tui.Theme{}, width)
282+ body := renderSwarmTranscriptBlocks(raw, tui.Theme{}, width, d.compactMode)
278283 row += len(body)
279284 }
280285
@@ -1107,7 +1112,7 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {
11071112 // That gives the user the chat UX they expect (transcript grows
11081113 // with the window, arrow keys belong to the editor, no "↑ N
11091114 // more above" cliff).
1110- body := renderSwarmTranscriptBlocks(raw, th, width)
1115+ body := renderSwarmTranscriptBlocks(raw, th, width, d.compactMode)
11111116
11121117 out := header
11131118 out = append(out, body...)
@@ -1218,7 +1223,7 @@ func agentIsBusy(a *swarm.AgentSnapshot) bool {
12181223 // whole block together — essential for markdown, which needs the
12191224 // full message body in one piece to detect fenced code spans,
12201225 // bullet lists, and so on.
1221-func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int) []string {
1226+func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compactMode bool) []string {
12221227 type blockKind int
12231228 const (
12241229 kindAssistant blockKind = iota
@@ -1289,7 +1294,7 @@ func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int) []stri
12891294 switch b.kind {
12901295 case kindUser:
12911296 text := strings.Join(b.body, "\n")
1292- out = append(out, btwUserBubbleRows(th, text, bubbleWidth)...)
1297+ out = append(out, btwUserBubbleRows(th, text, bubbleWidth, compactMode)...)
12931298 case kindAssistant:
12941299 text := strings.Join(b.body, "\n")
12951300 md := tui.RenderMarkdown(strings.TrimLeft(text, "\n"), th, innerMD)
@@ -32,10 +32,11 @@ import (
32 // ↑/↓/PgUp/PgDn/Home/End scroll32 // ↑/↓/PgUp/PgDn/Home/End scroll
33 // esc / q return to the list33 // esc / q return to the list
34 type swarmDialog struct {34 type swarmDialog struct {
35- active bool35+ active bool
36- snapshot func() []swarm.AgentSnapshot36+ compactMode bool
37- stop func(id string) error37+ snapshot func() []swarm.AgentSnapshot
38- remove func(id string) error38+ stop func(id string) error
39+ remove func(id string) error
39 // spawn accepts an optional model + provider override (empty40 // spawn accepts an optional model + provider override (empty
40 // strings mean "let the child resolve its own default"). The cli41 // strings mean "let the child resolve its own default"). The cli
41 // adapter forwards these to swarm.Swarm.SpawnReq.42 // adapter forwards these to swarm.Swarm.SpawnReq.
@@ -119,6 +120,10 @@ func newSwarmDialog() *swarmDialog { return &swarmDialog{} }
119 // run on whatever the user last picked via /model. Pass empty120 // run on whatever the user last picked via /model. Pass empty
120 // strings to clear the override (the child then resolves its own121 // strings to clear the override (the child then resolves its own
121 // default the same way a bare `zot` invocation does).122 // default the same way a bare `zot` invocation does).
123+func (d *swarmDialog) SetCompactMode(enabled bool) {
124+ d.compactMode = enabled
125+}
126+
122 func (d *swarmDialog) SetCurrentModel(model, providerID string) {127 func (d *swarmDialog) SetCurrentModel(model, providerID string) {
123 d.pendingModel = model128 d.pendingModel = model
124 d.pendingProvider = providerID129 d.pendingProvider = providerID
@@ -274,7 +279,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse
274 // Full body, no scroll window — the host's outer scrollback279 // Full body, no scroll window — the host's outer scrollback
275 // owns overflow now. Cursor row math just counts every280 // owns overflow now. Cursor row math just counts every
276 // rendered line.281 // rendered line.
277- body := renderSwarmTranscriptBlocks(raw, tui.Theme{}, width)282+ body := renderSwarmTranscriptBlocks(raw, tui.Theme{}, width, d.compactMode)
278 row += len(body)283 row += len(body)
279 }284 }
280 285
@@ -1107,7 +1112,7 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {
1107 // That gives the user the chat UX they expect (transcript grows1112 // That gives the user the chat UX they expect (transcript grows
1108 // with the window, arrow keys belong to the editor, no "↑ N1113 // with the window, arrow keys belong to the editor, no "↑ N
1109 // more above" cliff).1114 // more above" cliff).
1110- body := renderSwarmTranscriptBlocks(raw, th, width)1115+ body := renderSwarmTranscriptBlocks(raw, th, width, d.compactMode)
1111 1116
1112 out := header1117 out := header
1113 out = append(out, body...)1118 out = append(out, body...)
@@ -1218,7 +1223,7 @@ func agentIsBusy(a *swarm.AgentSnapshot) bool {
1218 // whole block together — essential for markdown, which needs the1223 // whole block together — essential for markdown, which needs the
1219 // full message body in one piece to detect fenced code spans,1224 // full message body in one piece to detect fenced code spans,
1220 // bullet lists, and so on.1225 // bullet lists, and so on.
1221-func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int) []string {1226+func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compactMode bool) []string {
1222 type blockKind int1227 type blockKind int
1223 const (1228 const (
1224 kindAssistant blockKind = iota1229 kindAssistant blockKind = iota
@@ -1289,7 +1294,7 @@ func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int) []stri
1289 switch b.kind {1294 switch b.kind {
1290 case kindUser:1295 case kindUser:
1291 text := strings.Join(b.body, "\n")1296 text := strings.Join(b.body, "\n")
1292- out = append(out, btwUserBubbleRows(th, text, bubbleWidth)...)1297+ out = append(out, btwUserBubbleRows(th, text, bubbleWidth, compactMode)...)
1293 case kindAssistant:1298 case kindAssistant:
1294 text := strings.Join(b.body, "\n")1299 text := strings.Join(b.body, "\n")
1295 md := tui.RenderMarkdown(strings.TrimLeft(text, "\n"), th, innerMD)1300 md := tui.RenderMarkdown(strings.TrimLeft(text, "\n"), th, innerMD)
M packages/agent/modes/swarm_dialog_test.go +3 -3
@@ -753,7 +753,7 @@ func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) {
753753 "stderr: warning thing",
754754 "error: boom",
755755 }
756- out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)
756+ out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false)
757757 joined := strings.Join(out, "\n")
758758
759759 // The user echo should appear as a bubble row, which contains
@@ -790,7 +790,7 @@ func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) {
790790 "- **bold** item",
791791 "- plain item",
792792 }
793- out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)
793+ out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false)
794794 joined := strings.Join(out, "\n")
795795
796796 // ANSI bold on/off. Markdown rendering uses CSI 1 / 22.
@@ -821,7 +821,7 @@ func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T)
821821 "```",
822822 "Done.",
823823 }
824- out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)
824+ out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false)
825825 joined := strings.Join(out, "\n")
826826
827827 // The literal fence markers should be consumed by the markdown
@@ -753,7 +753,7 @@ func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) {
753 "stderr: warning thing",753 "stderr: warning thing",
754 "error: boom",754 "error: boom",
755 }755 }
756- out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)756+ out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false)
757 joined := strings.Join(out, "\n")757 joined := strings.Join(out, "\n")
758 758
759 // The user echo should appear as a bubble row, which contains759 // The user echo should appear as a bubble row, which contains
@@ -790,7 +790,7 @@ func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) {
790 "- **bold** item",790 "- **bold** item",
791 "- plain item",791 "- plain item",
792 }792 }
793- out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)793+ out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false)
794 joined := strings.Join(out, "\n")794 joined := strings.Join(out, "\n")
795 795
796 // ANSI bold on/off. Markdown rendering uses CSI 1 / 22.796 // ANSI bold on/off. Markdown rendering uses CSI 1 / 22.
@@ -821,7 +821,7 @@ func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T)
821 "```",821 "```",
822 "Done.",822 "Done.",
823 }823 }
824- out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)824+ out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false)
825 joined := strings.Join(out, "\n")825 joined := strings.Join(out, "\n")
826 826
827 // The literal fence markers should be consumed by the markdown827 // The literal fence markers should be consumed by the markdown
M packages/agent/modes/swarm_slash.go +1 -0
@@ -69,6 +69,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
6969 // wants a different model for the next swarm agent, they pick it
7070 // via /model first (globally), or, while inside the spawn
7171 // editor, by typing /model on its own line to pop the picker.
72+ i.swarmDialog.SetCompactMode(i.compactModeEnabled())
7273 i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
7374 if i.cfg.LoggedInProviders != nil {
7475 i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
@@ -69,6 +69,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
69 // wants a different model for the next swarm agent, they pick it69 // wants a different model for the next swarm agent, they pick it
70 // via /model first (globally), or, while inside the spawn70 // via /model first (globally), or, while inside the spawn
71 // editor, by typing /model on its own line to pop the picker.71 // editor, by typing /model on its own line to pop the picker.
72+ i.swarmDialog.SetCompactMode(i.compactModeEnabled())
72 i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)73 i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
73 if i.cfg.LoggedInProviders != nil {74 if i.cfg.LoggedInProviders != nil {
74 i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())75 i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
M packages/agent/settings_store.go +9 -0
@@ -65,6 +65,15 @@ func (configSettingsStore) SetRespectGitignore(enabled bool) error {
6565 return SaveConfig(cfg)
6666 }
6767
68+func (configSettingsStore) SetCompactMode(enabled bool) error {
69+ cfg, err := LoadConfig()
70+ if err != nil {
71+ return err
72+ }
73+ cfg.CompactMode = &enabled
74+ return SaveConfig(cfg)
75+}
76+
6877 func (configSettingsStore) SetReasoning(level string) error {
6978 cfg, err := LoadConfig()
7079 if err != nil {
@@ -65,6 +65,15 @@ func (configSettingsStore) SetRespectGitignore(enabled bool) error {
65 return SaveConfig(cfg)65 return SaveConfig(cfg)
66 }66 }
67 67
68+func (configSettingsStore) SetCompactMode(enabled bool) error {
69+ cfg, err := LoadConfig()
70+ if err != nil {
71+ return err
72+ }
73+ cfg.CompactMode = &enabled
74+ return SaveConfig(cfg)
75+}
76+
68 func (configSettingsStore) SetReasoning(level string) error {77 func (configSettingsStore) SetReasoning(level string) error {
69 cfg, err := LoadConfig()78 cfg, err := LoadConfig()
70 if err != nil {79 if err != nil {
M packages/tui/view.go +19 -10
@@ -144,6 +144,12 @@ type View struct {
144144 // padded, background-tinted bubble.
145145 CompactUser bool
146146
147+ // CompactMode reduces visual chrome in the transcript. Tool calls
148+ // render with a flat header and no bordered panel, and sent user
149+ // messages render without padded background bubbles. False keeps
150+ // the current spacious rendering.
151+ CompactMode bool
152+
147153 // ExpandAll forces every long tool result to render in full.
148154 // Toggled from the tui by ctrl+o. When false, results longer than
149155 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
@@ -647,7 +653,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
647653 bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))
648654 return bar + padded
649655 }
650- if v.CompactUser {
656+ if v.CompactUser || v.CompactMode {
651657 // Compact: no tinted bubble background and no padding rows.
652658 // Each wrapped row is a quiet "▌ text" gutter line, matching
653659 // the flat tool-call header so user turns still segment the
@@ -657,6 +663,9 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
657663 innerWidth = 1
658664 }
659665 row = func(content string) string {
666+ if v.CompactMode {
667+ content = v.Theme.FG256(v.Theme.Muted, content)
668+ }
660669 return v.Theme.FG256(v.Theme.Accent, "▌ ") + content
661670 }
662671 }
@@ -675,7 +684,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
675684 }
676685 }
677686 if len(bubble) > 0 {
678- if v.CompactUser {
687+ if v.CompactUser || v.CompactMode {
679688 // No tinted padding rows in compact mode; the inter-message
680689 // blank from Build() already gives the turn breathing room.
681690 lines = append(lines, bubble...)
@@ -737,7 +746,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
737746 // from the matching ToolCallBlock so multiple calls
738747 // in one assistant message render as N adjacent boxes
739748 // instead of stacking unclosed top edges.
740- if v.FlatTools {
749+ if v.FlatTools || v.CompactMode {
741750 lines = append(lines, flatToolHeader(v.Theme, label, width))
742751 if tr.IsError {
743752 lines = append(lines, flatToolBody(v.Theme, v.Theme.FG256(color, " error")))
@@ -816,7 +825,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
816825 }
817826 v.liveBodyHigh[tc.ID] = high
818827 }
819- if v.FlatTools {
828+ if v.FlatTools || v.CompactMode {
820829 for len(body) < high {
821830 body = append(body, "")
822831 }
@@ -840,7 +849,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
840849 // directly closed by the bottom. Avoids a blank interior row
841850 // for no-output tools.
842851 if tc.Result == "" {
843- if v.FlatTools {
852+ if v.FlatTools || v.CompactMode {
844853 lines = append(lines, flatToolHeader(v.Theme, label, width))
845854 return lines
846855 }
@@ -858,7 +867,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
858867 if tc.Error {
859868 color = v.Theme.Error
860869 }
861- if v.FlatTools {
870+ if v.FlatTools || v.CompactMode {
862871 lines = append(lines, flatToolHeader(v.Theme, label, width))
863872 body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)
864873 for _, l := range v.collapseToolBody(body, false) {
@@ -926,7 +935,7 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
926935
927936 func (v *View) renderLiveBashCommand(command string, width int) []string {
928937 inner := toolBoxBodyRenderWidth(width)
929- if v.FlatTools {
938+ if v.FlatTools || v.CompactMode {
930939 inner = flatToolBodyRenderWidth(width)
931940 }
932941 prompt := v.Theme.FG256(v.Theme.Muted, "$ ")
@@ -962,7 +971,7 @@ func (v *View) renderLiveBashCommand(command string, width int) []string {
962971 func (v *View) wrapLiveBody(body []string, width int) []string {
963972 body = v.collapseToolBody(body, false)
964973 out := make([]string, 0, len(body))
965- if v.FlatTools {
974+ if v.FlatTools || v.CompactMode {
966975 for _, l := range body {
967976 _, stripped := parseImageFootprint(l)
968977 out = append(out, flatToolBody(v.Theme, stripped))
@@ -1080,7 +1089,7 @@ func flatToolHeader(th Theme, label string, width int) string {
10801089 label = oneLineToolLabel(label)
10811090 name, rest := splitToolLabel(label)
10821091 margin := strings.Repeat(" ", toolBoxOuterMargin)
1083- gutter := th.FG256(th.Accent, "▌") + " "
1092+ gutter := th.FG256(th.Assistant, "▌") + " "
10841093 // Budget the visible text to the content width so a very long
10851094 // argument summary doesn't run off the right edge; truncate with
10861095 // an ellipsis like the box header does.
@@ -1332,7 +1341,7 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
13321341 switch bb := b.(type) {
13331342 case provider.TextBlock:
13341343 bodyWidth := toolBoxBodyRenderWidth(width)
1335- if v.FlatTools {
1344+ if v.FlatTools || v.CompactMode {
13361345 bodyWidth = flatToolBodyRenderWidth(width)
13371346 }
13381347 body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)
@@ -144,6 +144,12 @@ type View struct {
144 // padded, background-tinted bubble.144 // padded, background-tinted bubble.
145 CompactUser bool145 CompactUser bool
146 146
147+ // CompactMode reduces visual chrome in the transcript. Tool calls
148+ // render with a flat header and no bordered panel, and sent user
149+ // messages render without padded background bubbles. False keeps
150+ // the current spacious rendering.
151+ CompactMode bool
152+
147 // ExpandAll forces every long tool result to render in full.153 // ExpandAll forces every long tool result to render in full.
148 // Toggled from the tui by ctrl+o. When false, results longer than154 // Toggled from the tui by ctrl+o. When false, results longer than
149 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a155 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
@@ -647,7 +653,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
647 bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))653 bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))
648 return bar + padded654 return bar + padded
649 }655 }
650- if v.CompactUser {656+ if v.CompactUser || v.CompactMode {
651 // Compact: no tinted bubble background and no padding rows.657 // Compact: no tinted bubble background and no padding rows.
652 // Each wrapped row is a quiet "▌ text" gutter line, matching658 // Each wrapped row is a quiet "▌ text" gutter line, matching
653 // the flat tool-call header so user turns still segment the659 // the flat tool-call header so user turns still segment the
@@ -657,6 +663,9 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
657 innerWidth = 1663 innerWidth = 1
658 }664 }
659 row = func(content string) string {665 row = func(content string) string {
666+ if v.CompactMode {
667+ content = v.Theme.FG256(v.Theme.Muted, content)
668+ }
660 return v.Theme.FG256(v.Theme.Accent, "▌ ") + content669 return v.Theme.FG256(v.Theme.Accent, "▌ ") + content
661 }670 }
662 }671 }
@@ -675,7 +684,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
675 }684 }
676 }685 }
677 if len(bubble) > 0 {686 if len(bubble) > 0 {
678- if v.CompactUser {687+ if v.CompactUser || v.CompactMode {
679 // No tinted padding rows in compact mode; the inter-message688 // No tinted padding rows in compact mode; the inter-message
680 // blank from Build() already gives the turn breathing room.689 // blank from Build() already gives the turn breathing room.
681 lines = append(lines, bubble...)690 lines = append(lines, bubble...)
@@ -737,7 +746,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
737 // from the matching ToolCallBlock so multiple calls746 // from the matching ToolCallBlock so multiple calls
738 // in one assistant message render as N adjacent boxes747 // in one assistant message render as N adjacent boxes
739 // instead of stacking unclosed top edges.748 // instead of stacking unclosed top edges.
740- if v.FlatTools {749+ if v.FlatTools || v.CompactMode {
741 lines = append(lines, flatToolHeader(v.Theme, label, width))750 lines = append(lines, flatToolHeader(v.Theme, label, width))
742 if tr.IsError {751 if tr.IsError {
743 lines = append(lines, flatToolBody(v.Theme, v.Theme.FG256(color, " error")))752 lines = append(lines, flatToolBody(v.Theme, v.Theme.FG256(color, " error")))
@@ -816,7 +825,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
816 }825 }
817 v.liveBodyHigh[tc.ID] = high826 v.liveBodyHigh[tc.ID] = high
818 }827 }
819- if v.FlatTools {828+ if v.FlatTools || v.CompactMode {
820 for len(body) < high {829 for len(body) < high {
821 body = append(body, "")830 body = append(body, "")
822 }831 }
@@ -840,7 +849,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
840 // directly closed by the bottom. Avoids a blank interior row849 // directly closed by the bottom. Avoids a blank interior row
841 // for no-output tools.850 // for no-output tools.
842 if tc.Result == "" {851 if tc.Result == "" {
843- if v.FlatTools {852+ if v.FlatTools || v.CompactMode {
844 lines = append(lines, flatToolHeader(v.Theme, label, width))853 lines = append(lines, flatToolHeader(v.Theme, label, width))
845 return lines854 return lines
846 }855 }
@@ -858,7 +867,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
858 if tc.Error {867 if tc.Error {
859 color = v.Theme.Error868 color = v.Theme.Error
860 }869 }
861- if v.FlatTools {870+ if v.FlatTools || v.CompactMode {
862 lines = append(lines, flatToolHeader(v.Theme, label, width))871 lines = append(lines, flatToolHeader(v.Theme, label, width))
863 body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)872 body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)
864 for _, l := range v.collapseToolBody(body, false) {873 for _, l := range v.collapseToolBody(body, false) {
@@ -926,7 +935,7 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
926 935
927 func (v *View) renderLiveBashCommand(command string, width int) []string {936 func (v *View) renderLiveBashCommand(command string, width int) []string {
928 inner := toolBoxBodyRenderWidth(width)937 inner := toolBoxBodyRenderWidth(width)
929- if v.FlatTools {938+ if v.FlatTools || v.CompactMode {
930 inner = flatToolBodyRenderWidth(width)939 inner = flatToolBodyRenderWidth(width)
931 }940 }
932 prompt := v.Theme.FG256(v.Theme.Muted, "$ ")941 prompt := v.Theme.FG256(v.Theme.Muted, "$ ")
@@ -962,7 +971,7 @@ func (v *View) renderLiveBashCommand(command string, width int) []string {
962 func (v *View) wrapLiveBody(body []string, width int) []string {971 func (v *View) wrapLiveBody(body []string, width int) []string {
963 body = v.collapseToolBody(body, false)972 body = v.collapseToolBody(body, false)
964 out := make([]string, 0, len(body))973 out := make([]string, 0, len(body))
965- if v.FlatTools {974+ if v.FlatTools || v.CompactMode {
966 for _, l := range body {975 for _, l := range body {
967 _, stripped := parseImageFootprint(l)976 _, stripped := parseImageFootprint(l)
968 out = append(out, flatToolBody(v.Theme, stripped))977 out = append(out, flatToolBody(v.Theme, stripped))
@@ -1080,7 +1089,7 @@ func flatToolHeader(th Theme, label string, width int) string {
1080 label = oneLineToolLabel(label)1089 label = oneLineToolLabel(label)
1081 name, rest := splitToolLabel(label)1090 name, rest := splitToolLabel(label)
1082 margin := strings.Repeat(" ", toolBoxOuterMargin)1091 margin := strings.Repeat(" ", toolBoxOuterMargin)
1083- gutter := th.FG256(th.Accent, "▌") + " "1092+ gutter := th.FG256(th.Assistant, "▌") + " "
1084 // Budget the visible text to the content width so a very long1093 // Budget the visible text to the content width so a very long
1085 // argument summary doesn't run off the right edge; truncate with1094 // argument summary doesn't run off the right edge; truncate with
1086 // an ellipsis like the box header does.1095 // an ellipsis like the box header does.
@@ -1332,7 +1341,7 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
1332 switch bb := b.(type) {1341 switch bb := b.(type) {
1333 case provider.TextBlock:1342 case provider.TextBlock:
1334 bodyWidth := toolBoxBodyRenderWidth(width)1343 bodyWidth := toolBoxBodyRenderWidth(width)
1335- if v.FlatTools {1344+ if v.FlatTools || v.CompactMode {
1336 bodyWidth = flatToolBodyRenderWidth(width)1345 bodyWidth = flatToolBodyRenderWidth(width)
1337 }1346 }
1338 body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)1347 body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)
A packages/tui/view_compact_mode_test.go +72 -0
@@ -0,0 +1,72 @@
1+package tui
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/provider"
9+)
10+
11+func compactModeUserMsg(text string) provider.Message {
12+ return provider.Message{
13+ Role: provider.RoleUser,
14+ Content: []provider.Content{provider.TextBlock{Text: text}},
15+ }
16+}
17+
18+func countGutterRows(plain string) int {
19+ n := 0
20+ for _, line := range strings.Split(plain, "\n") {
21+ if strings.Contains(line, "▌") {
22+ n++
23+ }
24+ }
25+ return n
26+}
27+
28+func TestCompactModeDropsUserBubblePadding(t *testing.T) {
29+ spacious := View{Theme: Dark, Messages: []provider.Message{compactModeUserMsg("hello")}}
30+ spaciousPlain := stripANSI(strings.Join(spacious.Build(80), "\n"))
31+ if got := countGutterRows(spaciousPlain); got != 3 {
32+ t.Fatalf("default user bubble should be 3 gutter rows, got %d:\n%s", got, spaciousPlain)
33+ }
34+
35+ compact := View{Theme: Dark, CompactMode: true, Messages: []provider.Message{compactModeUserMsg("hello")}}
36+ compactPlain := stripANSI(strings.Join(compact.Build(80), "\n"))
37+ if got := countGutterRows(compactPlain); got != 1 {
38+ t.Fatalf("compact mode user message should be 1 gutter row, got %d:\n%s", got, compactPlain)
39+ }
40+ if !strings.Contains(compactPlain, "▌ hello") {
41+ t.Fatalf("compact mode lost user text or gutter:\n%s", compactPlain)
42+ }
43+}
44+
45+func TestCompactModeDropsToolBoxBorders(t *testing.T) {
46+ args := json.RawMessage(`{"command":"echo hi"}`)
47+ compact := View{
48+ Theme: Dark,
49+ CompactMode: true,
50+ ToolCalls: []ToolCallView{
51+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "hi\n"},
52+ },
53+ }
54+ compactPlain := stripANSI(strings.Join(compact.Build(80), "\n"))
55+ if strings.ContainsAny(compactPlain, "┌└│┐┘") {
56+ t.Fatalf("compact mode tool render still contains box glyphs:\n%s", compactPlain)
57+ }
58+ if !strings.Contains(compactPlain, "bash") || !strings.Contains(compactPlain, "hi") {
59+ t.Fatalf("compact mode tool render lost header or output:\n%s", compactPlain)
60+ }
61+
62+ spacious := View{
63+ Theme: Dark,
64+ ToolCalls: []ToolCallView{
65+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "hi\n"},
66+ },
67+ }
68+ spaciousPlain := stripANSI(strings.Join(spacious.Build(80), "\n"))
69+ if !strings.ContainsAny(spaciousPlain, "┌└│┐┘") {
70+ t.Fatalf("default tool render should contain box glyphs:\n%s", spaciousPlain)
71+ }
72+}
@@ -0,0 +1,72 @@
1+package tui
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/provider"
9+)
10+
11+func compactModeUserMsg(text string) provider.Message {
12+ return provider.Message{
13+ Role: provider.RoleUser,
14+ Content: []provider.Content{provider.TextBlock{Text: text}},
15+ }
16+}
17+
18+func countGutterRows(plain string) int {
19+ n := 0
20+ for _, line := range strings.Split(plain, "\n") {
21+ if strings.Contains(line, "▌") {
22+ n++
23+ }
24+ }
25+ return n
26+}
27+
28+func TestCompactModeDropsUserBubblePadding(t *testing.T) {
29+ spacious := View{Theme: Dark, Messages: []provider.Message{compactModeUserMsg("hello")}}
30+ spaciousPlain := stripANSI(strings.Join(spacious.Build(80), "\n"))
31+ if got := countGutterRows(spaciousPlain); got != 3 {
32+ t.Fatalf("default user bubble should be 3 gutter rows, got %d:\n%s", got, spaciousPlain)
33+ }
34+
35+ compact := View{Theme: Dark, CompactMode: true, Messages: []provider.Message{compactModeUserMsg("hello")}}
36+ compactPlain := stripANSI(strings.Join(compact.Build(80), "\n"))
37+ if got := countGutterRows(compactPlain); got != 1 {
38+ t.Fatalf("compact mode user message should be 1 gutter row, got %d:\n%s", got, compactPlain)
39+ }
40+ if !strings.Contains(compactPlain, "▌ hello") {
41+ t.Fatalf("compact mode lost user text or gutter:\n%s", compactPlain)
42+ }
43+}
44+
45+func TestCompactModeDropsToolBoxBorders(t *testing.T) {
46+ args := json.RawMessage(`{"command":"echo hi"}`)
47+ compact := View{
48+ Theme: Dark,
49+ CompactMode: true,
50+ ToolCalls: []ToolCallView{
51+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "hi\n"},
52+ },
53+ }
54+ compactPlain := stripANSI(strings.Join(compact.Build(80), "\n"))
55+ if strings.ContainsAny(compactPlain, "┌└│┐┘") {
56+ t.Fatalf("compact mode tool render still contains box glyphs:\n%s", compactPlain)
57+ }
58+ if !strings.Contains(compactPlain, "bash") || !strings.Contains(compactPlain, "hi") {
59+ t.Fatalf("compact mode tool render lost header or output:\n%s", compactPlain)
60+ }
61+
62+ spacious := View{
63+ Theme: Dark,
64+ ToolCalls: []ToolCallView{
65+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "hi\n"},
66+ },
67+ }
68+ spaciousPlain := stripANSI(strings.Join(spacious.Build(80), "\n"))
69+ if !strings.ContainsAny(spaciousPlain, "┌└│┐┘") {
70+ t.Fatalf("default tool render should contain box glyphs:\n%s", spaciousPlain)
71+ }
72+}

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