Add optional theme background support
- commit
- 917da8c414e183118e68034e0e8c6f6b746f0132
- author
- patriceckhart · · 2026-05-30 17:01 UTC
- parent
- 16b95cb
- signature
- unsigned
- refs
- tag: v0.2.6
7 files · +89 -19
M docs/themes.md +3 -1
| @@ -115,6 +115,7 @@ All fields are optional. | |||
| 115 | 115 | "fg": 253, | |
| 116 | 116 | "muted": 244, | |
| 117 | 117 | "accent": 111, | |
| 118 | + | "background": "#0b1020", | |
| 118 | 119 | "user": 180, | |
| 119 | 120 | "user_bubble_bg": "#42454b", | |
| 120 | 121 | "user_bubble_fg": 248, | |
| @@ -180,6 +181,7 @@ Most color fields are xterm-256 indexes (`0`–`255`). | |||
| 180 | 181 | - `fg` — default foreground text. | |
| 181 | 182 | - `muted` — secondary text, dividers, gutters, inactive hints. | |
| 182 | 183 | - `accent` — prompt bar, bullets, links, headings, active markers. | |
| 184 | + | - `background` — optional full-row TUI background. If missing, zot uses the terminal's existing background. Experimental: terminal background colors can vary by emulator and scrollback behavior; for the most reliable result, change your terminal background color in your terminal settings instead. | |
| 183 | 185 | - `user` — user role label color; mostly compatibility. | |
| 184 | 186 | - `user_bubble_bg` — background behind user message rows. | |
| 185 | 187 | - `user_bubble_fg` — foreground inside user message rows. | |
| @@ -192,7 +194,7 @@ Most color fields are xterm-256 indexes (`0`–`255`). | |||
| 192 | 194 | - `selection_bg` — highlighted row background. | |
| 193 | 195 | - `selection_fg` — highlighted row foreground. | |
| 194 | 196 | ||
| 195 | - | `user_bubble_bg` supports richer terminal color forms: | |
| 197 | + | `background` and `user_bubble_bg` support richer terminal color forms: | |
| 196 | 198 | ||
| 197 | 199 | ```json | |
| 198 | 200 | 254 | |
| @@ -115,6 +115,7 @@ All fields are optional. | |||||
| 115 | "fg": 253, | 115 | "fg": 253, | ||
| 116 | "muted": 244, | 116 | "muted": 244, | ||
| 117 | "accent": 111, | 117 | "accent": 111, | ||
| 118 | + | "background": "#0b1020", | |||
| 118 | "user": 180, | 119 | "user": 180, | ||
| 119 | "user_bubble_bg": "#42454b", | 120 | "user_bubble_bg": "#42454b", | ||
| 120 | "user_bubble_fg": 248, | 121 | "user_bubble_fg": 248, | ||
| @@ -180,6 +181,7 @@ Most color fields are xterm-256 indexes (`0`–`255`). | |||||
| 180 | - `fg` — default foreground text. | 181 | - `fg` — default foreground text. | ||
| 181 | - `muted` — secondary text, dividers, gutters, inactive hints. | 182 | - `muted` — secondary text, dividers, gutters, inactive hints. | ||
| 182 | - `accent` — prompt bar, bullets, links, headings, active markers. | 183 | - `accent` — prompt bar, bullets, links, headings, active markers. | ||
| 184 | + | - `background` — optional full-row TUI background. If missing, zot uses the terminal's existing background. Experimental: terminal background colors can vary by emulator and scrollback behavior; for the most reliable result, change your terminal background color in your terminal settings instead. | |||
| 183 | - `user` — user role label color; mostly compatibility. | 185 | - `user` — user role label color; mostly compatibility. | ||
| 184 | - `user_bubble_bg` — background behind user message rows. | 186 | - `user_bubble_bg` — background behind user message rows. | ||
| 185 | - `user_bubble_fg` — foreground inside user message rows. | 187 | - `user_bubble_fg` — foreground inside user message rows. | ||
| @@ -192,7 +194,7 @@ Most color fields are xterm-256 indexes (`0`–`255`). | |||||
| 192 | - `selection_bg` — highlighted row background. | 194 | - `selection_bg` — highlighted row background. | ||
| 193 | - `selection_fg` — highlighted row foreground. | 195 | - `selection_fg` — highlighted row foreground. | ||
| 194 | 196 | ||||
| 195 | - | `user_bubble_bg` supports richer terminal color forms: | 197 | + | `background` and `user_bubble_bg` support richer terminal color forms: |
| 196 | 198 | ||||
| 197 | ```json | 199 | ```json | ||
| 198 | 254 | 200 | 254 | ||
R examples/theme/custom-spinner.json → examples/themes/custom-spinner.json +0 -0 renamed, 100% similar
renamed, 100% similar
R examples/theme/vercel.json → examples/themes/vercel.json +0 -0 renamed, 100% similar
renamed, 100% similar
M packages/agent/modes/interactive.go +4 -1
| @@ -415,6 +415,8 @@ const resumeTailExpandStep = 80 | |||
| 415 | 415 | ||
| 416 | 416 | // NewInteractive constructs an Interactive from cfg. | |
| 417 | 417 | func NewInteractive(cfg InteractiveConfig) *Interactive { | |
| 418 | + | renderer := tui.NewRenderer(cfg.Terminal) | |
| 419 | + | renderer.SetTheme(cfg.Theme) | |
| 418 | 420 | i := &Interactive{ | |
| 419 | 421 | cfg: cfg, | |
| 420 | 422 | view: &tui.View{ | |
| @@ -425,7 +427,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 425 | 427 | // speaker labels too, so the input gutter matches the rest | |
| 426 | 428 | // of the UI. | |
| 427 | 429 | ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)), | |
| 428 | - | rend: tui.NewRenderer(cfg.Terminal), | |
| 430 | + | rend: renderer, | |
| 429 | 431 | toolCalls: map[string]*tui.ToolCallView{}, | |
| 430 | 432 | dirty: make(chan struct{}, 8), | |
| 431 | 433 | dialog: newLoginDialog(), | |
| @@ -2709,6 +2711,7 @@ func (i *Interactive) applyThemeNow(name string) { | |||
| 2709 | 2711 | i.ed.Prompt = th.AccentBar(th.Accent) | |
| 2710 | 2712 | i.spin.Configure(th) | |
| 2711 | 2713 | if i.rend != nil { | |
| 2714 | + | i.rend.SetTheme(th) | |
| 2712 | 2715 | i.rend.Clear() | |
| 2713 | 2716 | } | |
| 2714 | 2717 | i.invalidate() | |
| @@ -415,6 +415,8 @@ const resumeTailExpandStep = 80 | |||||
| 415 | 415 | ||||
| 416 | // NewInteractive constructs an Interactive from cfg. | 416 | // NewInteractive constructs an Interactive from cfg. | ||
| 417 | func NewInteractive(cfg InteractiveConfig) *Interactive { | 417 | func NewInteractive(cfg InteractiveConfig) *Interactive { | ||
| 418 | + | renderer := tui.NewRenderer(cfg.Terminal) | |||
| 419 | + | renderer.SetTheme(cfg.Theme) | |||
| 418 | i := &Interactive{ | 420 | i := &Interactive{ | ||
| 419 | cfg: cfg, | 421 | cfg: cfg, | ||
| 420 | view: &tui.View{ | 422 | view: &tui.View{ | ||
| @@ -425,7 +427,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 425 | // speaker labels too, so the input gutter matches the rest | 427 | // speaker labels too, so the input gutter matches the rest | ||
| 426 | // of the UI. | 428 | // of the UI. | ||
| 427 | ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)), | 429 | ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)), | ||
| 428 | - | rend: tui.NewRenderer(cfg.Terminal), | 430 | + | rend: renderer, |
| 429 | toolCalls: map[string]*tui.ToolCallView{}, | 431 | toolCalls: map[string]*tui.ToolCallView{}, | ||
| 430 | dirty: make(chan struct{}, 8), | 432 | dirty: make(chan struct{}, 8), | ||
| 431 | dialog: newLoginDialog(), | 433 | dialog: newLoginDialog(), | ||
| @@ -2709,6 +2711,7 @@ func (i *Interactive) applyThemeNow(name string) { | |||||
| 2709 | i.ed.Prompt = th.AccentBar(th.Accent) | 2711 | i.ed.Prompt = th.AccentBar(th.Accent) | ||
| 2710 | i.spin.Configure(th) | 2712 | i.spin.Configure(th) | ||
| 2711 | if i.rend != nil { | 2713 | if i.rend != nil { | ||
| 2714 | + | i.rend.SetTheme(th) | |||
| 2712 | i.rend.Clear() | 2715 | i.rend.Clear() | ||
| 2713 | } | 2716 | } | ||
| 2714 | i.invalidate() | 2717 | i.invalidate() | ||
M packages/tui/render.go +62 -13
| @@ -70,6 +70,12 @@ type Renderer struct { | |||
| 70 | 70 | // limitation specific to VS Code's terminal that we have no | |
| 71 | 71 | // way to work around without breaking other terminals. | |
| 72 | 72 | keepScrollback bool | |
| 73 | + | ||
| 74 | + | // theme is optional renderer-level styling applied at the final | |
| 75 | + | // row-writing boundary. In particular, Theme.Background is painted | |
| 76 | + | // as a full-width row background without making every View renderer | |
| 77 | + | // know about terminal padding and reset semantics. | |
| 78 | + | theme Theme | |
| 73 | 79 | } | |
| 74 | 80 | ||
| 75 | 81 | // NewRenderer returns a renderer that writes to out. | |
| @@ -86,6 +92,13 @@ func NewRenderer(out io.Writer) *Renderer { | |||
| 86 | 92 | } | |
| 87 | 93 | } | |
| 88 | 94 | ||
| 95 | + | // SetTheme updates renderer-level terminal styling. Changing the | |
| 96 | + | // background affects every row, so cached frame state is invalidated. | |
| 97 | + | func (r *Renderer) SetTheme(th Theme) { | |
| 98 | + | r.theme = th | |
| 99 | + | r.Invalidate() | |
| 100 | + | } | |
| 101 | + | ||
| 89 | 102 | // Resize tells the renderer the current terminal size. | |
| 90 | 103 | // | |
| 91 | 104 | // On a real size change we also issue a clear-screen so the next Draw | |
| @@ -168,6 +181,23 @@ func containsImageEscape(s string) bool { | |||
| 168 | 181 | return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G") | |
| 169 | 182 | } | |
| 170 | 183 | ||
| 184 | + | // paintBackgroundRow applies the optional theme background to a single | |
| 185 | + | // already-truncated terminal row. It pads with spaces to cols so the | |
| 186 | + | // background reaches the right edge, and re-applies the background | |
| 187 | + | // after full SGR resets inside the row so local styling does not punch | |
| 188 | + | // transparent holes through the global tint. | |
| 189 | + | func paintBackgroundRow(line string, cols int, th Theme) string { | |
| 190 | + | bg := th.BackgroundStyle() | |
| 191 | + | if bg == "" || cols <= 0 || containsImageEscape(line) { | |
| 192 | + | return line | |
| 193 | + | } | |
| 194 | + | line = strings.ReplaceAll(line, reset, reset+bg) | |
| 195 | + | if w := visibleWidth(line); w < cols { | |
| 196 | + | line += strings.Repeat(" ", cols-w) | |
| 197 | + | } | |
| 198 | + | return bg + line + reset | |
| 199 | + | } | |
| 200 | + | ||
| 171 | 201 | // truncateToWidth clips s so its on-screen width doesn't exceed cols | |
| 172 | 202 | // cells, preserving ANSI CSI escape sequences (which don't consume | |
| 173 | 203 | // cells). Lines carrying an inline-image escape are returned as-is | |
| @@ -254,7 +284,12 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 254 | 284 | frame[i] = "" | |
| 255 | 285 | } | |
| 256 | 286 | for i, line := range visible { | |
| 257 | - | frame[top+i] = truncateToWidth(line, r.cols) | |
| 287 | + | frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme) | |
| 288 | + | } | |
| 289 | + | if r.theme.Background != nil { | |
| 290 | + | for i := 0; i < top; i++ { | |
| 291 | + | frame[i] = paintBackgroundRow("", r.cols, r.theme) | |
| 292 | + | } | |
| 258 | 293 | } | |
| 259 | 294 | ||
| 260 | 295 | var w strings.Builder | |
| @@ -297,19 +332,22 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 297 | 332 | // terminal doesn't reliably clear background colors on row | |
| 298 | 333 | // overwrites, leaving ghost highlights behind. | |
| 299 | 334 | hasSelection := false | |
| 300 | - | for _, l := range frame { | |
| 301 | - | if strings.Contains(l, "\x1b[48;5;") { | |
| 302 | - | hasSelection = true | |
| 303 | - | break | |
| 304 | - | } | |
| 305 | - | } | |
| 306 | - | if !hasSelection && r.prev != nil { | |
| 307 | - | for _, l := range r.prev { | |
| 308 | - | if strings.Contains(l, "\x1b[48;5;") { | |
| 335 | + | if r.theme.Background == nil { | |
| 336 | + | selectionBG := sgrBG(r.theme.SelectionBG) | |
| 337 | + | for _, l := range frame { | |
| 338 | + | if selectionBG != "" && strings.Contains(l, selectionBG) { | |
| 309 | 339 | hasSelection = true | |
| 310 | 340 | break | |
| 311 | 341 | } | |
| 312 | 342 | } | |
| 343 | + | if !hasSelection && r.prev != nil { | |
| 344 | + | for _, l := range r.prev { | |
| 345 | + | if selectionBG != "" && strings.Contains(l, selectionBG) { | |
| 346 | + | hasSelection = true | |
| 347 | + | break | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | } | |
| 313 | 351 | } | |
| 314 | 352 | ||
| 315 | 353 | full := r.prev == nil || len(r.prev) != r.rows | |
| @@ -355,11 +393,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 355 | 393 | } | |
| 356 | 394 | chatFrame := make([]string, len(chat)) | |
| 357 | 395 | for i, line := range chat { | |
| 358 | - | chatFrame[i] = truncateToWidth(line, r.cols) | |
| 396 | + | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme) | |
| 359 | 397 | } | |
| 360 | 398 | bottomFrame := make([]string, len(bottom)) | |
| 361 | 399 | for i, line := range bottom { | |
| 362 | - | bottomFrame[i] = truncateToWidth(line, r.cols) | |
| 400 | + | bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme) | |
| 363 | 401 | } | |
| 364 | 402 | ||
| 365 | 403 | // Always reserve one real row below the editor/status band. This is | |
| @@ -371,7 +409,18 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 371 | 409 | lines = append(lines, chatFrame...) | |
| 372 | 410 | lines = append(lines, bottomFrame...) | |
| 373 | 411 | for range bottomMarginRows { | |
| 374 | - | lines = append(lines, "") | |
| 412 | + | lines = append(lines, paintBackgroundRow("", r.cols, r.theme)) | |
| 413 | + | } | |
| 414 | + | // In main-screen flow mode zot normally emits only its logical | |
| 415 | + | // content rows and leaves the rest of the terminal viewport alone. | |
| 416 | + | // When a theme background is configured, fill that otherwise-idle | |
| 417 | + | // space with painted blank rows so the full window is tinted while | |
| 418 | + | // keeping the scrollback-oriented renderer model unchanged for the | |
| 419 | + | // default transparent case. | |
| 420 | + | if r.theme.Background != nil { | |
| 421 | + | for len(lines) < r.rows { | |
| 422 | + | lines = append(lines, paintBackgroundRow("", r.cols, r.theme)) | |
| 423 | + | } | |
| 375 | 424 | } | |
| 376 | 425 | if len(lines) == 0 { | |
| 377 | 426 | lines = []string{""} | |
| @@ -70,6 +70,12 @@ type Renderer struct { | |||||
| 70 | // limitation specific to VS Code's terminal that we have no | 70 | // limitation specific to VS Code's terminal that we have no | ||
| 71 | // way to work around without breaking other terminals. | 71 | // way to work around without breaking other terminals. | ||
| 72 | keepScrollback bool | 72 | keepScrollback bool | ||
| 73 | + | ||||
| 74 | + | // theme is optional renderer-level styling applied at the final | |||
| 75 | + | // row-writing boundary. In particular, Theme.Background is painted | |||
| 76 | + | // as a full-width row background without making every View renderer | |||
| 77 | + | // know about terminal padding and reset semantics. | |||
| 78 | + | theme Theme | |||
| 73 | } | 79 | } | ||
| 74 | 80 | ||||
| 75 | // NewRenderer returns a renderer that writes to out. | 81 | // NewRenderer returns a renderer that writes to out. | ||
| @@ -86,6 +92,13 @@ func NewRenderer(out io.Writer) *Renderer { | |||||
| 86 | } | 92 | } | ||
| 87 | } | 93 | } | ||
| 88 | 94 | ||||
| 95 | + | // SetTheme updates renderer-level terminal styling. Changing the | |||
| 96 | + | // background affects every row, so cached frame state is invalidated. | |||
| 97 | + | func (r *Renderer) SetTheme(th Theme) { | |||
| 98 | + | r.theme = th | |||
| 99 | + | r.Invalidate() | |||
| 100 | + | } | |||
| 101 | + | ||||
| 89 | // Resize tells the renderer the current terminal size. | 102 | // Resize tells the renderer the current terminal size. | ||
| 90 | // | 103 | // | ||
| 91 | // On a real size change we also issue a clear-screen so the next Draw | 104 | // On a real size change we also issue a clear-screen so the next Draw | ||
| @@ -168,6 +181,23 @@ func containsImageEscape(s string) bool { | |||||
| 168 | return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G") | 181 | return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G") | ||
| 169 | } | 182 | } | ||
| 170 | 183 | ||||
| 184 | + | // paintBackgroundRow applies the optional theme background to a single | |||
| 185 | + | // already-truncated terminal row. It pads with spaces to cols so the | |||
| 186 | + | // background reaches the right edge, and re-applies the background | |||
| 187 | + | // after full SGR resets inside the row so local styling does not punch | |||
| 188 | + | // transparent holes through the global tint. | |||
| 189 | + | func paintBackgroundRow(line string, cols int, th Theme) string { | |||
| 190 | + | bg := th.BackgroundStyle() | |||
| 191 | + | if bg == "" || cols <= 0 || containsImageEscape(line) { | |||
| 192 | + | return line | |||
| 193 | + | } | |||
| 194 | + | line = strings.ReplaceAll(line, reset, reset+bg) | |||
| 195 | + | if w := visibleWidth(line); w < cols { | |||
| 196 | + | line += strings.Repeat(" ", cols-w) | |||
| 197 | + | } | |||
| 198 | + | return bg + line + reset | |||
| 199 | + | } | |||
| 200 | + | ||||
| 171 | // truncateToWidth clips s so its on-screen width doesn't exceed cols | 201 | // truncateToWidth clips s so its on-screen width doesn't exceed cols | ||
| 172 | // cells, preserving ANSI CSI escape sequences (which don't consume | 202 | // cells, preserving ANSI CSI escape sequences (which don't consume | ||
| 173 | // cells). Lines carrying an inline-image escape are returned as-is | 203 | // cells). Lines carrying an inline-image escape are returned as-is | ||
| @@ -254,7 +284,12 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 254 | frame[i] = "" | 284 | frame[i] = "" | ||
| 255 | } | 285 | } | ||
| 256 | for i, line := range visible { | 286 | for i, line := range visible { | ||
| 257 | - | frame[top+i] = truncateToWidth(line, r.cols) | 287 | + | frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme) |
| 288 | + | } | |||
| 289 | + | if r.theme.Background != nil { | |||
| 290 | + | for i := 0; i < top; i++ { | |||
| 291 | + | frame[i] = paintBackgroundRow("", r.cols, r.theme) | |||
| 292 | + | } | |||
| 258 | } | 293 | } | ||
| 259 | 294 | ||||
| 260 | var w strings.Builder | 295 | var w strings.Builder | ||
| @@ -297,19 +332,22 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 297 | // terminal doesn't reliably clear background colors on row | 332 | // terminal doesn't reliably clear background colors on row | ||
| 298 | // overwrites, leaving ghost highlights behind. | 333 | // overwrites, leaving ghost highlights behind. | ||
| 299 | hasSelection := false | 334 | hasSelection := false | ||
| 300 | - | for _, l := range frame { | 335 | + | if r.theme.Background == nil { |
| 301 | - | if strings.Contains(l, "\x1b[48;5;") { | 336 | + | selectionBG := sgrBG(r.theme.SelectionBG) |
| 302 | - | hasSelection = true | 337 | + | for _, l := range frame { |
| 303 | - | break | 338 | + | if selectionBG != "" && strings.Contains(l, selectionBG) { |
| 304 | - | } | |||
| 305 | - | } | |||
| 306 | - | if !hasSelection && r.prev != nil { | |||
| 307 | - | for _, l := range r.prev { | |||
| 308 | - | if strings.Contains(l, "\x1b[48;5;") { | |||
| 309 | hasSelection = true | 339 | hasSelection = true | ||
| 310 | break | 340 | break | ||
| 311 | } | 341 | } | ||
| 312 | } | 342 | } | ||
| 343 | + | if !hasSelection && r.prev != nil { | |||
| 344 | + | for _, l := range r.prev { | |||
| 345 | + | if selectionBG != "" && strings.Contains(l, selectionBG) { | |||
| 346 | + | hasSelection = true | |||
| 347 | + | break | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 313 | } | 351 | } | ||
| 314 | 352 | ||||
| 315 | full := r.prev == nil || len(r.prev) != r.rows | 353 | full := r.prev == nil || len(r.prev) != r.rows | ||
| @@ -355,11 +393,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 355 | } | 393 | } | ||
| 356 | chatFrame := make([]string, len(chat)) | 394 | chatFrame := make([]string, len(chat)) | ||
| 357 | for i, line := range chat { | 395 | for i, line := range chat { | ||
| 358 | - | chatFrame[i] = truncateToWidth(line, r.cols) | 396 | + | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme) |
| 359 | } | 397 | } | ||
| 360 | bottomFrame := make([]string, len(bottom)) | 398 | bottomFrame := make([]string, len(bottom)) | ||
| 361 | for i, line := range bottom { | 399 | for i, line := range bottom { | ||
| 362 | - | bottomFrame[i] = truncateToWidth(line, r.cols) | 400 | + | bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme) |
| 363 | } | 401 | } | ||
| 364 | 402 | ||||
| 365 | // Always reserve one real row below the editor/status band. This is | 403 | // Always reserve one real row below the editor/status band. This is | ||
| @@ -371,7 +409,18 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 371 | lines = append(lines, chatFrame...) | 409 | lines = append(lines, chatFrame...) | ||
| 372 | lines = append(lines, bottomFrame...) | 410 | lines = append(lines, bottomFrame...) | ||
| 373 | for range bottomMarginRows { | 411 | for range bottomMarginRows { | ||
| 374 | - | lines = append(lines, "") | 412 | + | lines = append(lines, paintBackgroundRow("", r.cols, r.theme)) |
| 413 | + | } | |||
| 414 | + | // In main-screen flow mode zot normally emits only its logical | |||
| 415 | + | // content rows and leaves the rest of the terminal viewport alone. | |||
| 416 | + | // When a theme background is configured, fill that otherwise-idle | |||
| 417 | + | // space with painted blank rows so the full window is tinted while | |||
| 418 | + | // keeping the scrollback-oriented renderer model unchanged for the | |||
| 419 | + | // default transparent case. | |||
| 420 | + | if r.theme.Background != nil { | |||
| 421 | + | for len(lines) < r.rows { | |||
| 422 | + | lines = append(lines, paintBackgroundRow("", r.cols, r.theme)) | |||
| 423 | + | } | |||
| 375 | } | 424 | } | ||
| 376 | if len(lines) == 0 { | 425 | if len(lines) == 0 { | ||
| 377 | lines = []string{""} | 426 | lines = []string{""} | ||
M packages/tui/theme.go +15 -4
| @@ -39,10 +39,11 @@ type Theme struct { | |||
| 39 | 39 | FG int | |
| 40 | 40 | Muted int | |
| 41 | 41 | Accent int | |
| 42 | - | User int // label color for the user role | |
| 43 | - | UserBubbleBG TerminalColor // background tint behind user message rows | |
| 44 | - | UserBubbleFG int // foreground colour for user message rows | |
| 45 | - | Assistant int // label color for the zot role | |
| 42 | + | Background *TerminalColor // optional full-row TUI background; nil keeps terminal default | |
| 43 | + | User int // label color for the user role | |
| 44 | + | UserBubbleBG TerminalColor // background tint behind user message rows | |
| 45 | + | UserBubbleFG int // foreground colour for user message rows | |
| 46 | + | Assistant int // label color for the zot role | |
| 46 | 47 | Tool int | |
| 47 | 48 | ToolOut int | |
| 48 | 49 | Error int | |
| @@ -229,6 +230,16 @@ func (t Theme) SelectionStyle() string { | |||
| 229 | 230 | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) | |
| 230 | 231 | } | |
| 231 | 232 | ||
| 233 | + | // BackgroundStyle returns the SGR prefix for the optional full-row | |
| 234 | + | // TUI background. Empty means zot should leave the terminal's | |
| 235 | + | // configured background untouched. | |
| 236 | + | func (t Theme) BackgroundStyle() string { | |
| 237 | + | if t.Background == nil { | |
| 238 | + | return "" | |
| 239 | + | } | |
| 240 | + | return sgrBGColor(*t.Background) | |
| 241 | + | } | |
| 242 | + | ||
| 232 | 243 | // SelectionStyleFG returns the SGR prefix for selected-row text with | |
| 233 | 244 | // a custom foreground. Useful for preserving semantic marks inside a | |
| 234 | 245 | // highlighted row without hardcoding escape sequences outside theme.go. | |
| @@ -39,10 +39,11 @@ type Theme struct { | |||||
| 39 | FG int | 39 | FG int | ||
| 40 | Muted int | 40 | Muted int | ||
| 41 | Accent int | 41 | Accent int | ||
| 42 | - | User int // label color for the user role | 42 | + | Background *TerminalColor // optional full-row TUI background; nil keeps terminal default |
| 43 | - | UserBubbleBG TerminalColor // background tint behind user message rows | 43 | + | User int // label color for the user role |
| 44 | - | UserBubbleFG int // foreground colour for user message rows | 44 | + | UserBubbleBG TerminalColor // background tint behind user message rows |
| 45 | - | Assistant int // label color for the zot role | 45 | + | UserBubbleFG int // foreground colour for user message rows |
| 46 | + | Assistant int // label color for the zot role | |||
| 46 | Tool int | 47 | Tool int | ||
| 47 | ToolOut int | 48 | ToolOut int | ||
| 48 | Error int | 49 | Error int | ||
| @@ -229,6 +230,16 @@ func (t Theme) SelectionStyle() string { | |||||
| 229 | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) | 230 | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) | ||
| 230 | } | 231 | } | ||
| 231 | 232 | ||||
| 233 | + | // BackgroundStyle returns the SGR prefix for the optional full-row | |||
| 234 | + | // TUI background. Empty means zot should leave the terminal's | |||
| 235 | + | // configured background untouched. | |||
| 236 | + | func (t Theme) BackgroundStyle() string { | |||
| 237 | + | if t.Background == nil { | |||
| 238 | + | return "" | |||
| 239 | + | } | |||
| 240 | + | return sgrBGColor(*t.Background) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 232 | // SelectionStyleFG returns the SGR prefix for selected-row text with | 243 | // SelectionStyleFG returns the SGR prefix for selected-row text with | ||
| 233 | // a custom foreground. Useful for preserving semantic marks inside a | 244 | // a custom foreground. Useful for preserving semantic marks inside a | ||
| 234 | // highlighted row without hardcoding escape sequences outside theme.go. | 245 | // highlighted row without hardcoding escape sequences outside theme.go. | ||
M packages/tui/theme_loader.go +5 -0
| @@ -82,6 +82,7 @@ type ThemeOverrides struct { | |||
| 82 | 82 | FG *int `json:"fg,omitempty"` | |
| 83 | 83 | Muted *int `json:"muted,omitempty"` | |
| 84 | 84 | Accent *int `json:"accent,omitempty"` | |
| 85 | + | Background *TerminalColorValue `json:"background,omitempty"` | |
| 85 | 86 | User *int `json:"user,omitempty"` | |
| 86 | 87 | UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"` | |
| 87 | 88 | UserBubbleFG *int `json:"user_bubble_fg,omitempty"` | |
| @@ -351,6 +352,10 @@ func applyThemeOverrides(th Theme, o ThemeOverrides) Theme { | |||
| 351 | 352 | if o.Accent != nil { | |
| 352 | 353 | th.Accent = *o.Accent | |
| 353 | 354 | } | |
| 355 | + | if o.Background != nil { | |
| 356 | + | bg := o.Background.TerminalColor | |
| 357 | + | th.Background = &bg | |
| 358 | + | } | |
| 354 | 359 | if o.User != nil { | |
| 355 | 360 | th.User = *o.User | |
| 356 | 361 | } | |
| @@ -82,6 +82,7 @@ type ThemeOverrides struct { | |||||
| 82 | FG *int `json:"fg,omitempty"` | 82 | FG *int `json:"fg,omitempty"` | ||
| 83 | Muted *int `json:"muted,omitempty"` | 83 | Muted *int `json:"muted,omitempty"` | ||
| 84 | Accent *int `json:"accent,omitempty"` | 84 | Accent *int `json:"accent,omitempty"` | ||
| 85 | + | Background *TerminalColorValue `json:"background,omitempty"` | |||
| 85 | User *int `json:"user,omitempty"` | 86 | User *int `json:"user,omitempty"` | ||
| 86 | UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"` | 87 | UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"` | ||
| 87 | UserBubbleFG *int `json:"user_bubble_fg,omitempty"` | 88 | UserBubbleFG *int `json:"user_bubble_fg,omitempty"` | ||
| @@ -351,6 +352,10 @@ func applyThemeOverrides(th Theme, o ThemeOverrides) Theme { | |||||
| 351 | if o.Accent != nil { | 352 | if o.Accent != nil { | ||
| 352 | th.Accent = *o.Accent | 353 | th.Accent = *o.Accent | ||
| 353 | } | 354 | } | ||
| 355 | + | if o.Background != nil { | |||
| 356 | + | bg := o.Background.TerminalColor | |||
| 357 | + | th.Background = &bg | |||
| 358 | + | } | |||
| 354 | if o.User != nil { | 359 | if o.User != nil { | ||
| 355 | th.User = *o.User | 360 | th.User = *o.User | ||
| 356 | } | 361 | } | ||