detached 00ca863

Merge pull request #33 from bnema/feat/theme-shades-gray

feat(tui): promote semantic theme colors to truecolor
commit
00ca8634726c6b8fd71a9c1a2338de5a2ba59d20
author
bnema · · 2026-08-05 15:19 UTC
committer
GitHub · · 2026-08-05 15:19 UTC
merge of
bfb904a + baba7f3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
49 files · +719 -624
M README.md +1 -1
@@ -419,7 +419,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
419419 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
420420 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
421421 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
422-- **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Inherited mode uses terminal colors with truecolor or best-effort 256-color output; 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).
422+- **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override 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).
423423 - **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.
424424
425425 ### `/skills`
@@ -419,7 +419,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
419 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.419 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
420 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).420 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
421 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.421 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
422-- **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Inherited mode uses terminal colors with truecolor or best-effort 256-color output; 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).422+- **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override 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).
423 - **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.423 - **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.
424 424
425 ### `/skills`425 ### `/skills`
M docs/themes.md +32 -14
@@ -34,6 +34,20 @@ The built-in choices are:
3434 The inherited terminal snapshot is captured at startup and reused when the
3535 setting is changed live; restart zot to pick up terminal palette changes.
3636
37+All theme color roles use the same terminal-color model. JSON themes may use
38+xterm-256 indexes, ANSI SGR colors, or RGB values for any role. On terminals
39+that advertise truecolor, zot emits RGB values directly and expands indexed
40+values through the xterm palette; otherwise RGB values fall back to the
41+nearest xterm-256 color while indexed values remain indexed. ANSI values keep
42+their SGR semantics, with standard foreground/background ranges normalized to
43+the role where necessary.
44+
45+## Theme proposals
46+
47+- **shades of gray (truecolor)** — a neutral grayscale palette for the UI and
48+ syntax highlighting, with an xterm-256 fallback when direct color is
49+ unavailable.
50+
3751 Theme files bundled with extensions are discovered in-place from loaded
3852 extension directories:
3953
@@ -191,11 +205,22 @@ You may also put overrides directly at the top level or directly under
191205
192206 ## Color fields
193207
194-Most color fields are xterm-256 indexes (`0`–`255`) for JSON themes.
195-The built-in inherited theme resolves semantic accents through the terminal's
196-ANSI palette, fades muted/selected surfaces toward the terminal background,
197-and emits truecolor or best-effort 256-color sequences according to terminal
198-capability.
208+Every color field accepts the following JSON forms:
209+
210+```json
211+254
212+"#42454b"
213+{ "mode": "256", "index": 254 }
214+{ "mode": "ansi", "index": 100 }
215+{ "mode": "rgb", "r": 66, "g": 69, "b": 75 }
216+```
217+
218+Numeric values are xterm-256 indexes. ANSI values use SGR codes, with
219+standard foreground/background ranges normalized to the role where necessary.
220+RGB values are emitted directly when truecolor is advertised and quantized to
221+xterm-256 otherwise. The built-in inherited theme additionally resolves
222+semantic accents through the terminal's ANSI palette and derives muted and
223+selected surfaces from the terminal foreground/background.
199224
200225 - `fg` — default foreground text.
201226 - `muted` — secondary text, dividers, gutters, inactive hints.
@@ -214,15 +239,8 @@ capability.
214239 - `selection_bg` — highlighted row background.
215240 - `selection_fg` — highlighted row foreground.
216241
217-`background` and `user_bubble_bg` support richer terminal color forms:
218-
219-```json
220-254
221-"#42454b"
222-{ "mode": "256", "index": 254 }
223-{ "mode": "ansi", "index": 100 }
224-{ "mode": "rgb", "r": 66, "g": 69, "b": 75 }
225-```
242+The same forms apply to `background`, `user_bubble_bg`, and every other
243+semantic color field listed above.
226244
227245 ## Spinner fields
228246
@@ -34,6 +34,20 @@ The built-in choices are:
34 The inherited terminal snapshot is captured at startup and reused when the34 The inherited terminal snapshot is captured at startup and reused when the
35 setting is changed live; restart zot to pick up terminal palette changes.35 setting is changed live; restart zot to pick up terminal palette changes.
36 36
37+All theme color roles use the same terminal-color model. JSON themes may use
38+xterm-256 indexes, ANSI SGR colors, or RGB values for any role. On terminals
39+that advertise truecolor, zot emits RGB values directly and expands indexed
40+values through the xterm palette; otherwise RGB values fall back to the
41+nearest xterm-256 color while indexed values remain indexed. ANSI values keep
42+their SGR semantics, with standard foreground/background ranges normalized to
43+the role where necessary.
44+
45+## Theme proposals
46+
47+- **shades of gray (truecolor)** — a neutral grayscale palette for the UI and
48+ syntax highlighting, with an xterm-256 fallback when direct color is
49+ unavailable.
50+
37 Theme files bundled with extensions are discovered in-place from loaded51 Theme files bundled with extensions are discovered in-place from loaded
38 extension directories:52 extension directories:
39 53
@@ -191,11 +205,22 @@ You may also put overrides directly at the top level or directly under
191 205
192 ## Color fields206 ## Color fields
193 207
194-Most color fields are xterm-256 indexes (`0`–`255`) for JSON themes.208+Every color field accepts the following JSON forms:
195-The built-in inherited theme resolves semantic accents through the terminal's209+
196-ANSI palette, fades muted/selected surfaces toward the terminal background,210+```json
197-and emits truecolor or best-effort 256-color sequences according to terminal211+254
198-capability.212+"#42454b"
213+{ "mode": "256", "index": 254 }
214+{ "mode": "ansi", "index": 100 }
215+{ "mode": "rgb", "r": 66, "g": 69, "b": 75 }
216+```
217+
218+Numeric values are xterm-256 indexes. ANSI values use SGR codes, with
219+standard foreground/background ranges normalized to the role where necessary.
220+RGB values are emitted directly when truecolor is advertised and quantized to
221+xterm-256 otherwise. The built-in inherited theme additionally resolves
222+semantic accents through the terminal's ANSI palette and derives muted and
223+selected surfaces from the terminal foreground/background.
199 224
200 - `fg` — default foreground text.225 - `fg` — default foreground text.
201 - `muted` — secondary text, dividers, gutters, inactive hints.226 - `muted` — secondary text, dividers, gutters, inactive hints.
@@ -214,15 +239,8 @@ capability.
214 - `selection_bg` — highlighted row background.239 - `selection_bg` — highlighted row background.
215 - `selection_fg` — highlighted row foreground.240 - `selection_fg` — highlighted row foreground.
216 241
217-`background` and `user_bubble_bg` support richer terminal color forms:242+The same forms apply to `background`, `user_bubble_bg`, and every other
218-243+semantic color field listed above.
219-```json
220-254
221-"#42454b"
222-{ "mode": "256", "index": 254 }
223-{ "mode": "ansi", "index": 100 }
224-{ "mode": "rgb", "r": 66, "g": 69, "b": 75 }
225-```
226 244
227 ## Spinner fields245 ## Spinner fields
228 246
M packages/agent/args.go +5 -2
@@ -370,11 +370,14 @@ func PrintHelp(version string) {
370370 th := tui.Dark
371371 fd := int(os.Stderr.Fd())
372372 useColor := term.IsTerminal(fd)
373- style := func(c int, s string) string {
373+ if useColor && tui.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM")) {
374+ th.Terminal.TrueColor = true
375+ }
376+ style := func(c tui.TerminalColor, s string) string {
374377 if !useColor {
375378 return s
376379 }
377- return th.FG256(c, s)
380+ return th.FGColor(c, s)
378381 }
379382 assistant := func(s string) string { return style(th.Assistant, s) }
380383 muted := func(s string) string { return style(th.Muted, s) }
@@ -370,11 +370,14 @@ func PrintHelp(version string) {
370 th := tui.Dark370 th := tui.Dark
371 fd := int(os.Stderr.Fd())371 fd := int(os.Stderr.Fd())
372 useColor := term.IsTerminal(fd)372 useColor := term.IsTerminal(fd)
373- style := func(c int, s string) string {373+ if useColor && tui.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM")) {
374+ th.Terminal.TrueColor = true
375+ }
376+ style := func(c tui.TerminalColor, s string) string {
374 if !useColor {377 if !useColor {
375 return s378 return s
376 }379 }
377- return th.FG256(c, s)380+ return th.FGColor(c, s)
378 }381 }
379 assistant := func(s string) string { return style(th.Assistant, s) }382 assistant := func(s string) string { return style(th.Assistant, s) }
380 muted := func(s string) string { return style(th.Muted, s) }383 muted := func(s string) string { return style(th.Muted, s) }
M packages/agent/modes/btw_dialog.go +9 -9
@@ -383,7 +383,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
383383 out = append(out, frameHeaderColor(th, "btw - side chat (esc closes; nothing is added to the main thread)", width, th.Accent))
384384
385385 if len(d.turns) == 0 && !d.loading {
386- out = append(out, " "+th.FG256(th.Muted, "ask anything; replies stay private to this side chat."))
386+ out = append(out, " "+th.FGColor(th.Muted, "ask anything; replies stay private to this side chat."))
387387 }
388388
389389 for _, t := range d.turns {
@@ -402,7 +402,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
402402 out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...)
403403 }
404404 if t.Err != "" {
405- out = append(out, wrapDialogTextRows(th.FG256(th.Error, "✖ "+t.Err), width)...)
405+ out = append(out, wrapDialogTextRows(th.FGColor(th.Error, "✖ "+t.Err), width)...)
406406 }
407407 }
408408
@@ -412,11 +412,11 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
412412 // rotating funny-line, elapsed seconds, then a muted hint
413413 // that esc cancels.
414414 prefix := fmt.Sprintf("%s %s - %s",
415- th.FG256(th.Assistant, d.spin.Frame()),
416- th.FG256(th.Assistant, d.spin.Message()),
417- th.FG256(th.Muted, d.spin.Elapsed().String()),
415+ th.FGColor(th.Assistant, d.spin.Frame()),
416+ th.FGColor(th.Assistant, d.spin.Message()),
417+ th.FGColor(th.Muted, d.spin.Elapsed().String()),
418418 )
419- out = append(out, " "+prefix+" "+th.FG256(th.Muted, "(esc cancels)"))
419+ out = append(out, " "+prefix+" "+th.FGColor(th.Muted, "(esc cancels)"))
420420 }
421421
422422 out = append(out, "")
@@ -477,7 +477,7 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
477477 editorOffset += len(renderDialogMarkdownRows(t.Assistant, d.theme, width))
478478 }
479479 if t.Err != "" {
480- editorOffset += len(wrapDialogTextRows(d.theme.FG256(d.theme.Error, "✖ "+t.Err), width))
480+ editorOffset += len(wrapDialogTextRows(d.theme.FGColor(d.theme.Error, "✖ "+t.Err), width))
481481 }
482482 }
483483 if d.loading {
@@ -505,7 +505,7 @@ func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int, compactMode b
505505 if innerWidth < 1 {
506506 innerWidth = 1
507507 }
508- bar := th.BG(th.UserBubbleBG, th.FG256(th.Accent, "▌ "))
508+ bar := th.BG(th.UserBubbleBG, th.FGColor(th.Accent, "▌ "))
509509 row := func(content string) string {
510510 inner := strings.Repeat(" ", leftGutter) + content
511511 return " " + bar + th.UserBubble(inner, bubbleWidth-2)
@@ -516,7 +516,7 @@ func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int, compactMode b
516516 innerWidth = 1
517517 }
518518 row = func(content string) string {
519- return " " + th.FG256(th.Accent, "▌ ") + th.FG256(th.Muted, content)
519+ return " " + th.FGColor(th.Accent, "▌ ") + th.FGColor(th.Muted, content)
520520 }
521521 }
522522 var bubble []string
@@ -383,7 +383,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
383 out = append(out, frameHeaderColor(th, "btw - side chat (esc closes; nothing is added to the main thread)", width, th.Accent))383 out = append(out, frameHeaderColor(th, "btw - side chat (esc closes; nothing is added to the main thread)", width, th.Accent))
384 384
385 if len(d.turns) == 0 && !d.loading {385 if len(d.turns) == 0 && !d.loading {
386- out = append(out, " "+th.FG256(th.Muted, "ask anything; replies stay private to this side chat."))386+ out = append(out, " "+th.FGColor(th.Muted, "ask anything; replies stay private to this side chat."))
387 }387 }
388 388
389 for _, t := range d.turns {389 for _, t := range d.turns {
@@ -402,7 +402,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
402 out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...)402 out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...)
403 }403 }
404 if t.Err != "" {404 if t.Err != "" {
405- out = append(out, wrapDialogTextRows(th.FG256(th.Error, "✖ "+t.Err), width)...)405+ out = append(out, wrapDialogTextRows(th.FGColor(th.Error, "✖ "+t.Err), width)...)
406 }406 }
407 }407 }
408 408
@@ -412,11 +412,11 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
412 // rotating funny-line, elapsed seconds, then a muted hint412 // rotating funny-line, elapsed seconds, then a muted hint
413 // that esc cancels.413 // that esc cancels.
414 prefix := fmt.Sprintf("%s %s - %s",414 prefix := fmt.Sprintf("%s %s - %s",
415- th.FG256(th.Assistant, d.spin.Frame()),415+ th.FGColor(th.Assistant, d.spin.Frame()),
416- th.FG256(th.Assistant, d.spin.Message()),416+ th.FGColor(th.Assistant, d.spin.Message()),
417- th.FG256(th.Muted, d.spin.Elapsed().String()),417+ th.FGColor(th.Muted, d.spin.Elapsed().String()),
418 )418 )
419- out = append(out, " "+prefix+" "+th.FG256(th.Muted, "(esc cancels)"))419+ out = append(out, " "+prefix+" "+th.FGColor(th.Muted, "(esc cancels)"))
420 }420 }
421 421
422 out = append(out, "")422 out = append(out, "")
@@ -477,7 +477,7 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
477 editorOffset += len(renderDialogMarkdownRows(t.Assistant, d.theme, width))477 editorOffset += len(renderDialogMarkdownRows(t.Assistant, d.theme, width))
478 }478 }
479 if t.Err != "" {479 if t.Err != "" {
480- editorOffset += len(wrapDialogTextRows(d.theme.FG256(d.theme.Error, "✖ "+t.Err), width))480+ editorOffset += len(wrapDialogTextRows(d.theme.FGColor(d.theme.Error, "✖ "+t.Err), width))
481 }481 }
482 }482 }
483 if d.loading {483 if d.loading {
@@ -505,7 +505,7 @@ func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int, compactMode b
505 if innerWidth < 1 {505 if innerWidth < 1 {
506 innerWidth = 1506 innerWidth = 1
507 }507 }
508- bar := th.BG(th.UserBubbleBG, th.FG256(th.Accent, "▌ "))508+ bar := th.BG(th.UserBubbleBG, th.FGColor(th.Accent, "▌ "))
509 row := func(content string) string {509 row := func(content string) string {
510 inner := strings.Repeat(" ", leftGutter) + content510 inner := strings.Repeat(" ", leftGutter) + content
511 return " " + bar + th.UserBubble(inner, bubbleWidth-2)511 return " " + bar + th.UserBubble(inner, bubbleWidth-2)
@@ -516,7 +516,7 @@ func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int, compactMode b
516 innerWidth = 1516 innerWidth = 1
517 }517 }
518 row = func(content string) string {518 row = func(content string) string {
519- return " " + th.FG256(th.Accent, "▌ ") + th.FG256(th.Muted, content)519+ return " " + th.FGColor(th.Accent, "▌ ") + th.FGColor(th.Muted, content)
520 }520 }
521 }521 }
522 var bubble []string522 var bubble []string
M packages/agent/modes/changelog_dialog.go +3 -3
@@ -79,7 +79,7 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string {
7979 title := fmt.Sprintf("zot %s \u2014 release notes (any key to dismiss)", d.version)
8080 out := []string{frameHeaderColor(th, title, width, th.Accent)}
8181 if d.url != "" {
82- out = append(out, " "+th.FG256(th.Muted, d.url))
82+ out = append(out, " "+th.FGColor(th.Muted, d.url))
8383 out = append(out, "")
8484 }
8585
@@ -88,7 +88,7 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string {
8888 if strings.HasPrefix(l, "\x00H:") {
8989 // Heading: render in accent color, bold.
9090 heading := strings.TrimPrefix(l, "\x00H:")
91- styled := th.FG256(th.Accent, tui.Bold(heading))
91+ styled := th.FGColor(th.Accent, tui.Bold(heading))
9292 bodyLines = append(bodyLines, wrapDialogTextRows(styled, width)...)
9393 } else {
9494 // Regular line: render and wrap through the shared dialog
@@ -110,7 +110,7 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string {
110110 }
111111 out = append(out, bodyLines[d.scroll:end]...)
112112 if end < len(bodyLines) {
113- out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))
113+ out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))
114114 }
115115 out = append(out, frameRuleColor(th, width, th.Accent))
116116 return out
@@ -79,7 +79,7 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string {
79 title := fmt.Sprintf("zot %s \u2014 release notes (any key to dismiss)", d.version)79 title := fmt.Sprintf("zot %s \u2014 release notes (any key to dismiss)", d.version)
80 out := []string{frameHeaderColor(th, title, width, th.Accent)}80 out := []string{frameHeaderColor(th, title, width, th.Accent)}
81 if d.url != "" {81 if d.url != "" {
82- out = append(out, " "+th.FG256(th.Muted, d.url))82+ out = append(out, " "+th.FGColor(th.Muted, d.url))
83 out = append(out, "")83 out = append(out, "")
84 }84 }
85 85
@@ -88,7 +88,7 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string {
88 if strings.HasPrefix(l, "\x00H:") {88 if strings.HasPrefix(l, "\x00H:") {
89 // Heading: render in accent color, bold.89 // Heading: render in accent color, bold.
90 heading := strings.TrimPrefix(l, "\x00H:")90 heading := strings.TrimPrefix(l, "\x00H:")
91- styled := th.FG256(th.Accent, tui.Bold(heading))91+ styled := th.FGColor(th.Accent, tui.Bold(heading))
92 bodyLines = append(bodyLines, wrapDialogTextRows(styled, width)...)92 bodyLines = append(bodyLines, wrapDialogTextRows(styled, width)...)
93 } else {93 } else {
94 // Regular line: render and wrap through the shared dialog94 // Regular line: render and wrap through the shared dialog
@@ -110,7 +110,7 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string {
110 }110 }
111 out = append(out, bodyLines[d.scroll:end]...)111 out = append(out, bodyLines[d.scroll:end]...)
112 if end < len(bodyLines) {112 if end < len(bodyLines) {
113- out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))113+ out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))
114 }114 }
115 out = append(out, frameRuleColor(th, width, th.Accent))115 out = append(out, frameRuleColor(th, width, th.Accent))
116 return out116 return out
M packages/agent/modes/confirm_dialog.go +4 -4
@@ -251,9 +251,9 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string {
251251 }
252252 lines = append(lines, frameHeader(th, title, width))
253253
254- toolLine := " " + th.FG256(th.Tool, req.toolName)
254+ toolLine := " " + th.FGColor(th.Tool, req.toolName)
255255 if req.preview != "" {
256- toolLine += " " + th.FG256(th.Muted, req.preview)
256+ toolLine += " " + th.FGColor(th.Muted, req.preview)
257257 }
258258 lines = append(lines, toolLine)
259259 lines = append(lines, "")
@@ -261,7 +261,7 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string {
261261 if !d.focused {
262262 hint = "confirmation paused (finish or esc the command to return):"
263263 }
264- lines = append(lines, th.FG256(th.Muted, hint))
264+ lines = append(lines, th.FGColor(th.Muted, hint))
265265
266266 for i, opt := range confirmOptions {
267267 label := opt.label
@@ -279,7 +279,7 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string {
279279 if i == cursor {
280280 lines = append(lines, th.PadHighlight(plain, width))
281281 } else {
282- lines = append(lines, th.FG256(th.Muted, plain))
282+ lines = append(lines, th.FGColor(th.Muted, plain))
283283 }
284284 }
285285 lines = append(lines, frameRule(th, width))
@@ -251,9 +251,9 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string {
251 }251 }
252 lines = append(lines, frameHeader(th, title, width))252 lines = append(lines, frameHeader(th, title, width))
253 253
254- toolLine := " " + th.FG256(th.Tool, req.toolName)254+ toolLine := " " + th.FGColor(th.Tool, req.toolName)
255 if req.preview != "" {255 if req.preview != "" {
256- toolLine += " " + th.FG256(th.Muted, req.preview)256+ toolLine += " " + th.FGColor(th.Muted, req.preview)
257 }257 }
258 lines = append(lines, toolLine)258 lines = append(lines, toolLine)
259 lines = append(lines, "")259 lines = append(lines, "")
@@ -261,7 +261,7 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string {
261 if !d.focused {261 if !d.focused {
262 hint = "confirmation paused (finish or esc the command to return):"262 hint = "confirmation paused (finish or esc the command to return):"
263 }263 }
264- lines = append(lines, th.FG256(th.Muted, hint))264+ lines = append(lines, th.FGColor(th.Muted, hint))
265 265
266 for i, opt := range confirmOptions {266 for i, opt := range confirmOptions {
267 label := opt.label267 label := opt.label
@@ -279,7 +279,7 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string {
279 if i == cursor {279 if i == cursor {
280 lines = append(lines, th.PadHighlight(plain, width))280 lines = append(lines, th.PadHighlight(plain, width))
281 } else {281 } else {
282- lines = append(lines, th.FG256(th.Muted, plain))282+ lines = append(lines, th.FGColor(th.Muted, plain))
283 }283 }
284 }284 }
285 lines = append(lines, frameRule(th, width))285 lines = append(lines, frameRule(th, width))
M packages/agent/modes/dialog_frame.go +10 -10
@@ -16,13 +16,13 @@ import (
1616 func frameHeader(th tui.Theme, title string, width int) string {
1717 label := "── " + title + " "
1818 if width <= 0 {
19- return th.FG256(th.Muted, label)
19+ return th.FGColor(th.Muted, label)
2020 }
2121 padLen := width - runewidth.StringWidth(label)
2222 if padLen < 0 {
2323 padLen = 0
2424 }
25- return th.FG256(th.Muted, label+strings.Repeat("─", padLen))
25+ return th.FGColor(th.Muted, label+strings.Repeat("─", padLen))
2626 }
2727
2828 // frameRule returns a full-width horizontal rule in the muted color.
@@ -30,7 +30,7 @@ func frameRule(th tui.Theme, width int) string {
3030 if width <= 0 {
3131 width = 1
3232 }
33- return th.FG256(th.Muted, strings.Repeat("─", width))
33+ return th.FGColor(th.Muted, strings.Repeat("─", width))
3434 }
3535
3636 // padDialogFrame inserts breathing room between the shared dialog frame
@@ -71,24 +71,24 @@ func isFrameRuleLine(line string) bool {
7171 }
7272
7373 // frameHeaderColor is like frameHeader but renders in a caller-supplied
74-// 256-color code. Used by the update-available banner which wants a
75-// yellow accent on the rules and title.
76-func frameHeaderColor(th tui.Theme, title string, width, color int) string {
74+// theme color. Used by the update-available banner which wants a yellow
75+// accent on the rules and title.
76+func frameHeaderColor(th tui.Theme, title string, width int, color tui.TerminalColor) string {
7777 label := "── " + title + " "
7878 if width <= 0 {
79- return th.FG256(color, label)
79+ return th.FGColor(color, label)
8080 }
8181 padLen := width - runewidth.StringWidth(label)
8282 if padLen < 0 {
8383 padLen = 0
8484 }
85- return th.FG256(color, label+strings.Repeat("─", padLen))
85+ return th.FGColor(color, label+strings.Repeat("─", padLen))
8686 }
8787
8888 // frameRuleColor is like frameRule in an explicit color.
89-func frameRuleColor(th tui.Theme, width, color int) string {
89+func frameRuleColor(th tui.Theme, width int, color tui.TerminalColor) string {
9090 if width <= 0 {
9191 width = 1
9292 }
93- return th.FG256(color, strings.Repeat("─", width))
93+ return th.FGColor(color, strings.Repeat("─", width))
9494 }
@@ -16,13 +16,13 @@ import (
16 func frameHeader(th tui.Theme, title string, width int) string {16 func frameHeader(th tui.Theme, title string, width int) string {
17 label := "── " + title + " "17 label := "── " + title + " "
18 if width <= 0 {18 if width <= 0 {
19- return th.FG256(th.Muted, label)19+ return th.FGColor(th.Muted, label)
20 }20 }
21 padLen := width - runewidth.StringWidth(label)21 padLen := width - runewidth.StringWidth(label)
22 if padLen < 0 {22 if padLen < 0 {
23 padLen = 023 padLen = 0
24 }24 }
25- return th.FG256(th.Muted, label+strings.Repeat("─", padLen))25+ return th.FGColor(th.Muted, label+strings.Repeat("─", padLen))
26 }26 }
27 27
28 // frameRule returns a full-width horizontal rule in the muted color.28 // frameRule returns a full-width horizontal rule in the muted color.
@@ -30,7 +30,7 @@ func frameRule(th tui.Theme, width int) string {
30 if width <= 0 {30 if width <= 0 {
31 width = 131 width = 1
32 }32 }
33- return th.FG256(th.Muted, strings.Repeat("─", width))33+ return th.FGColor(th.Muted, strings.Repeat("─", width))
34 }34 }
35 35
36 // padDialogFrame inserts breathing room between the shared dialog frame36 // padDialogFrame inserts breathing room between the shared dialog frame
@@ -71,24 +71,24 @@ func isFrameRuleLine(line string) bool {
71 }71 }
72 72
73 // frameHeaderColor is like frameHeader but renders in a caller-supplied73 // frameHeaderColor is like frameHeader but renders in a caller-supplied
74-// 256-color code. Used by the update-available banner which wants a74+// theme color. Used by the update-available banner which wants a yellow
75-// yellow accent on the rules and title.75+// accent on the rules and title.
76-func frameHeaderColor(th tui.Theme, title string, width, color int) string {76+func frameHeaderColor(th tui.Theme, title string, width int, color tui.TerminalColor) string {
77 label := "── " + title + " "77 label := "── " + title + " "
78 if width <= 0 {78 if width <= 0 {
79- return th.FG256(color, label)79+ return th.FGColor(color, label)
80 }80 }
81 padLen := width - runewidth.StringWidth(label)81 padLen := width - runewidth.StringWidth(label)
82 if padLen < 0 {82 if padLen < 0 {
83 padLen = 083 padLen = 0
84 }84 }
85- return th.FG256(color, label+strings.Repeat("─", padLen))85+ return th.FGColor(color, label+strings.Repeat("─", padLen))
86 }86 }
87 87
88 // frameRuleColor is like frameRule in an explicit color.88 // frameRuleColor is like frameRule in an explicit color.
89-func frameRuleColor(th tui.Theme, width, color int) string {89+func frameRuleColor(th tui.Theme, width int, color tui.TerminalColor) string {
90 if width <= 0 {90 if width <= 0 {
91 width = 191 width = 1
92 }92 }
93- return th.FG256(color, strings.Repeat("─", width))93+ return th.FGColor(color, strings.Repeat("─", width))
94 }94 }
M packages/agent/modes/ext_notes_test.go +4 -1
@@ -11,7 +11,10 @@ import (
1111
1212 func newNotesTestInteractive() *Interactive {
1313 i := &Interactive{dirty: make(chan struct{}, 1)}
14- i.cfg.Theme = tui.Theme{Muted: 8, Warning: 3, Error: 1, Tool: 2, Accent: 4}
14+ i.cfg.Theme = tui.Theme{
15+ Muted: tui.Color256(8), Warning: tui.Color256(3), Error: tui.Color256(1),
16+ Tool: tui.Color256(2), Accent: tui.Color256(4),
17+ }
1518 return i
1619 }
1720
@@ -11,7 +11,10 @@ import (
11 11
12 func newNotesTestInteractive() *Interactive {12 func newNotesTestInteractive() *Interactive {
13 i := &Interactive{dirty: make(chan struct{}, 1)}13 i := &Interactive{dirty: make(chan struct{}, 1)}
14- i.cfg.Theme = tui.Theme{Muted: 8, Warning: 3, Error: 1, Tool: 2, Accent: 4}14+ i.cfg.Theme = tui.Theme{
15+ Muted: tui.Color256(8), Warning: tui.Color256(3), Error: tui.Color256(1),
16+ Tool: tui.Color256(2), Accent: tui.Color256(4),
17+ }
15 return i18 return i
16 }19 }
17 20
M packages/agent/modes/ext_panel_dialog.go +3 -3
@@ -74,7 +74,7 @@ func (d *extPanelDialog) Render(th tui.Theme, width int) []string {
7474 }
7575 if strings.TrimSpace(d.footer) != "" {
7676 out = append(out, "")
77- out = append(out, th.FG256(th.Muted, d.footer))
77+ out = append(out, th.FGColor(th.Muted, d.footer))
7878 }
7979 out = append(out, frameRuleColor(th, width, th.Accent))
8080 return out
@@ -92,7 +92,7 @@ func styleExtPanelLine(th tui.Theme, raw string, plain string, width int, select
9292 if raw != plain {
9393 return raw + "\x1b[0m"
9494 }
95- styled := th.FG256(th.Muted, plain)
96- styled = strings.ReplaceAll(styled, "✓", th.FG256(th.Tool, "✓"))
95+ styled := th.FGColor(th.Muted, plain)
96+ styled = strings.ReplaceAll(styled, "✓", th.FGColor(th.Tool, "✓"))
9797 return styled
9898 }
@@ -74,7 +74,7 @@ func (d *extPanelDialog) Render(th tui.Theme, width int) []string {
74 }74 }
75 if strings.TrimSpace(d.footer) != "" {75 if strings.TrimSpace(d.footer) != "" {
76 out = append(out, "")76 out = append(out, "")
77- out = append(out, th.FG256(th.Muted, d.footer))77+ out = append(out, th.FGColor(th.Muted, d.footer))
78 }78 }
79 out = append(out, frameRuleColor(th, width, th.Accent))79 out = append(out, frameRuleColor(th, width, th.Accent))
80 return out80 return out
@@ -92,7 +92,7 @@ func styleExtPanelLine(th tui.Theme, raw string, plain string, width int, select
92 if raw != plain {92 if raw != plain {
93 return raw + "\x1b[0m"93 return raw + "\x1b[0m"
94 }94 }
95- styled := th.FG256(th.Muted, plain)95+ styled := th.FGColor(th.Muted, plain)
96- styled = strings.ReplaceAll(styled, "✓", th.FG256(th.Tool, "✓"))96+ styled = strings.ReplaceAll(styled, "✓", th.FGColor(th.Tool, "✓"))
97 return styled97 return styled
98 }98 }
M packages/agent/modes/file_suggest.go +3 -3
@@ -480,7 +480,7 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
480480
481481 // Show breadcrumb when browsing a subdirectory.
482482 if s.browseRel != "" {
483- crumb := th.FG256(th.Accent, " "+s.browseRel+"/")
483+ crumb := th.FGColor(th.Accent, " "+s.browseRel+"/")
484484 out = append(out, crumb)
485485 }
486486
@@ -494,7 +494,7 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
494494 if idx == s.cursor {
495495 out = append(out, th.PadHighlight(plain, width))
496496 } else {
497- out = append(out, th.FG256(th.Muted, plain))
497+ out = append(out, th.FGColor(th.Muted, plain))
498498 }
499499 }
500500
@@ -508,7 +508,7 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
508508 default:
509509 hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
510510 }
511- out = append(out, th.FG256(th.Muted, hint))
511+ out = append(out, th.FGColor(th.Muted, hint))
512512 out = append(out, "")
513513 return out
514514 }
@@ -480,7 +480,7 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
480 480
481 // Show breadcrumb when browsing a subdirectory.481 // Show breadcrumb when browsing a subdirectory.
482 if s.browseRel != "" {482 if s.browseRel != "" {
483- crumb := th.FG256(th.Accent, " "+s.browseRel+"/")483+ crumb := th.FGColor(th.Accent, " "+s.browseRel+"/")
484 out = append(out, crumb)484 out = append(out, crumb)
485 }485 }
486 486
@@ -494,7 +494,7 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
494 if idx == s.cursor {494 if idx == s.cursor {
495 out = append(out, th.PadHighlight(plain, width))495 out = append(out, th.PadHighlight(plain, width))
496 } else {496 } else {
497- out = append(out, th.FG256(th.Muted, plain))497+ out = append(out, th.FGColor(th.Muted, plain))
498 }498 }
499 }499 }
500 500
@@ -508,7 +508,7 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
508 default:508 default:
509 hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"509 hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
510 }510 }
511- out = append(out, th.FG256(th.Muted, hint))511+ out = append(out, th.FGColor(th.Muted, hint))
512 out = append(out, "")512 out = append(out, "")
513 return out513 return out
514 }514 }
M packages/agent/modes/help.go +4 -4
@@ -82,16 +82,16 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string {
8282 continue
8383 }
8484 out = append(out, fmt.Sprintf(" %s %s",
85- th.FG256(th.Accent, pad(c.Name)),
86- th.FG256(th.Muted, c.Desc)))
85+ th.FGColor(th.Accent, pad(c.Name)),
86+ th.FGColor(th.Muted, c.Desc)))
8787 }
8888
8989 // keys section
9090 out = append(out, "", tui.Bold("keys:"))
9191 for _, k := range helpKeyRows {
9292 out = append(out, fmt.Sprintf(" %s %s",
93- th.FG256(th.Accent, pad(k[0])),
94- th.FG256(th.Muted, k[1])))
93+ th.FGColor(th.Accent, pad(k[0])),
94+ th.FGColor(th.Muted, k[1])))
9595 }
9696
9797 out = append(out, "", frameRule(th, width), "")
@@ -82,16 +82,16 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string {
82 continue82 continue
83 }83 }
84 out = append(out, fmt.Sprintf(" %s %s",84 out = append(out, fmt.Sprintf(" %s %s",
85- th.FG256(th.Accent, pad(c.Name)),85+ th.FGColor(th.Accent, pad(c.Name)),
86- th.FG256(th.Muted, c.Desc)))86+ th.FGColor(th.Muted, c.Desc)))
87 }87 }
88 88
89 // keys section89 // keys section
90 out = append(out, "", tui.Bold("keys:"))90 out = append(out, "", tui.Bold("keys:"))
91 for _, k := range helpKeyRows {91 for _, k := range helpKeyRows {
92 out = append(out, fmt.Sprintf(" %s %s",92 out = append(out, fmt.Sprintf(" %s %s",
93- th.FG256(th.Accent, pad(k[0])),93+ th.FGColor(th.Accent, pad(k[0])),
94- th.FG256(th.Muted, k[1])))94+ th.FGColor(th.Muted, k[1])))
95 }95 }
96 96
97 out = append(out, "", frameRule(th, width), "")97 out = append(out, "", frameRule(th, width), "")
M packages/agent/modes/interactive.go +17 -17
@@ -1274,7 +1274,7 @@ func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActi
12741274 }
12751275 limit := maxNarrowExtensionChromeRows
12761276 trimmed := append([]string(nil), lines[:limit-1]...)
1277- trimmed = append(trimmed, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
1277+ trimmed = append(trimmed, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
12781278 return trimmed
12791279 }
12801280
@@ -1310,7 +1310,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
13101310 if widget.Title != "" {
13111311 label += " " + widget.Title
13121312 }
1313- if !appendWidgetRow(i.cfg.Theme.FG256(i.cfg.Theme.Accent, truncateLine(label, cols))) {
1313+ if !appendWidgetRow(i.cfg.Theme.FGColor(i.cfg.Theme.Accent, truncateLine(label, cols))) {
13141314 stopWidgets = true
13151315 break
13161316 }
@@ -1326,7 +1326,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
13261326 }
13271327 }
13281328 if widgetTruncated {
1329- out = append(out, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
1329+ out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
13301330 }
13311331
13321332 statusRows := 0
@@ -1348,7 +1348,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
13481348 color = i.cfg.Theme.Tool
13491349 }
13501350 label := " [" + extName + "] " + status.Text
1351- out = append(out, i.cfg.Theme.FG256(color, truncateLine(label, cols)))
1351+ out = append(out, i.cfg.Theme.FGColor(color, truncateLine(label, cols)))
13521352 statusRows++
13531353 }
13541354 if statusTruncated {
@@ -1356,7 +1356,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
13561356 }
13571357 }
13581358 if statusTruncated {
1359- out = append(out, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols)))
1359+ out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols)))
13601360 }
13611361 return out
13621362 }
@@ -1467,7 +1467,7 @@ func (i *Interactive) buildChatLocked(cols int) []string {
14671467 if cols > 4 && len(line) > cols {
14681468 line = line[:cols-3] + "..."
14691469 }
1470- chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
1470+ chat = append(chat, i.cfg.Theme.FGColor(i.cfg.Theme.Tool, line), "")
14711471 }
14721472
14731473 // Live shell-escape output (!command / entry.pre) streams into the
@@ -1772,10 +1772,10 @@ func (i *Interactive) redraw() {
17721772 var busyPrefix string
17731773 if i.busy {
17741774 busyPrefix = fmt.Sprintf("%s %s %s %s",
1775- i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
1776- i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
1777- i.cfg.Theme.FG256(i.cfg.Theme.Muted, "-"),
1778- i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
1775+ i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
1776+ i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Message()),
1777+ i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
1778+ i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
17791779 )
17801780 }
17811781
@@ -1843,16 +1843,16 @@ func (i *Interactive) redraw() {
18431843 if len(queued) > 0 {
18441844 queue = append(queue, "")
18451845 for _, q := range queued {
1846- label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ")
1846+ label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
18471847 text := truncateLine(q, mainCols-17)
1848- queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
1848+ queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
18491849 }
18501850 // Hint row, rendered in the same muted tone as the model
18511851 // info on the status bar so it reads as ambient metadata
18521852 // rather than a chip. Tells the user how to recover the
18531853 // most recent queued message back into the editor.
18541854 hint := " Press " + slideBackChordHint() + " to slide back into input"
1855- queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
1855+ queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
18561856 }
18571857
18581858 extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
@@ -2060,7 +2060,7 @@ func (i *Interactive) redraw() {
20602060 } else {
20612061 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
20622062 }
2063- note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
2063+ note := i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)
20642064 visibleChat = append([]string{note}, visibleChat...)
20652065 if len(visibleChat) > chatRows {
20662066 visibleChat = visibleChat[:chatRows]
@@ -3442,11 +3442,11 @@ func (i *Interactive) appendExtensionNote(extName, msg, level string) {
34423442 case "success":
34433443 color = i.cfg.Theme.Tool
34443444 }
3445- prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
3445+ prefix := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, "["+extName+"] ")
34463446 for _, line := range strings.Split(msg, "\n") {
34473447 i.statusOK = "" // clear any stale ok
34483448 i.statusErr = ""
3449- i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
3449+ i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FGColor(color, line))
34503450 }
34513451 // Extension slash commands complete asynchronously. If their result is
34523452 // displayed while confirmation is waiting, keep Esc assigned to the note
@@ -6988,7 +6988,7 @@ func stripAutoCompactNotes(notes []string) []string {
69886988 // inline auto-compact heads-up. Lives in extNotes so it survives
69896989 // the busy-spinner overwrite of the status row.
69906990 func autoCompactNoteLine(th tui.Theme, msg string) string {
6991- return " " + th.FG256(th.Warning, "⚠ "+msg)
6991+ return " " + th.FGColor(th.Warning, "⚠ "+msg)
69926992 }
69936993
69946994 // isContextOverflowError matches provider responses that reject the
@@ -1274,7 +1274,7 @@ func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActi
1274 }1274 }
1275 limit := maxNarrowExtensionChromeRows1275 limit := maxNarrowExtensionChromeRows
1276 trimmed := append([]string(nil), lines[:limit-1]...)1276 trimmed := append([]string(nil), lines[:limit-1]...)
1277- trimmed = append(trimmed, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))1277+ trimmed = append(trimmed, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
1278 return trimmed1278 return trimmed
1279 }1279 }
1280 1280
@@ -1310,7 +1310,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
1310 if widget.Title != "" {1310 if widget.Title != "" {
1311 label += " " + widget.Title1311 label += " " + widget.Title
1312 }1312 }
1313- if !appendWidgetRow(i.cfg.Theme.FG256(i.cfg.Theme.Accent, truncateLine(label, cols))) {1313+ if !appendWidgetRow(i.cfg.Theme.FGColor(i.cfg.Theme.Accent, truncateLine(label, cols))) {
1314 stopWidgets = true1314 stopWidgets = true
1315 break1315 break
1316 }1316 }
@@ -1326,7 +1326,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
1326 }1326 }
1327 }1327 }
1328 if widgetTruncated {1328 if widgetTruncated {
1329- out = append(out, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))1329+ out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
1330 }1330 }
1331 1331
1332 statusRows := 01332 statusRows := 0
@@ -1348,7 +1348,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
1348 color = i.cfg.Theme.Tool1348 color = i.cfg.Theme.Tool
1349 }1349 }
1350 label := " [" + extName + "] " + status.Text1350 label := " [" + extName + "] " + status.Text
1351- out = append(out, i.cfg.Theme.FG256(color, truncateLine(label, cols)))1351+ out = append(out, i.cfg.Theme.FGColor(color, truncateLine(label, cols)))
1352 statusRows++1352 statusRows++
1353 }1353 }
1354 if statusTruncated {1354 if statusTruncated {
@@ -1356,7 +1356,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
1356 }1356 }
1357 }1357 }
1358 if statusTruncated {1358 if statusTruncated {
1359- out = append(out, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols)))1359+ out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols)))
1360 }1360 }
1361 return out1361 return out
1362 }1362 }
@@ -1467,7 +1467,7 @@ func (i *Interactive) buildChatLocked(cols int) []string {
1467 if cols > 4 && len(line) > cols {1467 if cols > 4 && len(line) > cols {
1468 line = line[:cols-3] + "..."1468 line = line[:cols-3] + "..."
1469 }1469 }
1470- chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")1470+ chat = append(chat, i.cfg.Theme.FGColor(i.cfg.Theme.Tool, line), "")
1471 }1471 }
1472 1472
1473 // Live shell-escape output (!command / entry.pre) streams into the1473 // Live shell-escape output (!command / entry.pre) streams into the
@@ -1772,10 +1772,10 @@ func (i *Interactive) redraw() {
1772 var busyPrefix string1772 var busyPrefix string
1773 if i.busy {1773 if i.busy {
1774 busyPrefix = fmt.Sprintf("%s %s %s %s",1774 busyPrefix = fmt.Sprintf("%s %s %s %s",
1775- i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),1775+ i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
1776- i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),1776+ i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Message()),
1777- i.cfg.Theme.FG256(i.cfg.Theme.Muted, "-"),1777+ i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
1778- i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),1778+ i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
1779 )1779 )
1780 }1780 }
1781 1781
@@ -1843,16 +1843,16 @@ func (i *Interactive) redraw() {
1843 if len(queued) > 0 {1843 if len(queued) > 0 {
1844 queue = append(queue, "")1844 queue = append(queue, "")
1845 for _, q := range queued {1845 for _, q := range queued {
1846- label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ")1846+ label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
1847 text := truncateLine(q, mainCols-17)1847 text := truncateLine(q, mainCols-17)
1848- queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))1848+ queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
1849 }1849 }
1850 // Hint row, rendered in the same muted tone as the model1850 // Hint row, rendered in the same muted tone as the model
1851 // info on the status bar so it reads as ambient metadata1851 // info on the status bar so it reads as ambient metadata
1852 // rather than a chip. Tells the user how to recover the1852 // rather than a chip. Tells the user how to recover the
1853 // most recent queued message back into the editor.1853 // most recent queued message back into the editor.
1854 hint := " Press " + slideBackChordHint() + " to slide back into input"1854 hint := " Press " + slideBackChordHint() + " to slide back into input"
1855- queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))1855+ queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
1856 }1856 }
1857 1857
1858 extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)1858 extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
@@ -2060,7 +2060,7 @@ func (i *Interactive) redraw() {
2060 } else {2060 } else {
2061 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)2061 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
2062 }2062 }
2063- note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)2063+ note := i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)
2064 visibleChat = append([]string{note}, visibleChat...)2064 visibleChat = append([]string{note}, visibleChat...)
2065 if len(visibleChat) > chatRows {2065 if len(visibleChat) > chatRows {
2066 visibleChat = visibleChat[:chatRows]2066 visibleChat = visibleChat[:chatRows]
@@ -3442,11 +3442,11 @@ func (i *Interactive) appendExtensionNote(extName, msg, level string) {
3442 case "success":3442 case "success":
3443 color = i.cfg.Theme.Tool3443 color = i.cfg.Theme.Tool
3444 }3444 }
3445- prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")3445+ prefix := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, "["+extName+"] ")
3446 for _, line := range strings.Split(msg, "\n") {3446 for _, line := range strings.Split(msg, "\n") {
3447 i.statusOK = "" // clear any stale ok3447 i.statusOK = "" // clear any stale ok
3448 i.statusErr = ""3448 i.statusErr = ""
3449- i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))3449+ i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FGColor(color, line))
3450 }3450 }
3451 // Extension slash commands complete asynchronously. If their result is3451 // Extension slash commands complete asynchronously. If their result is
3452 // displayed while confirmation is waiting, keep Esc assigned to the note3452 // displayed while confirmation is waiting, keep Esc assigned to the note
@@ -6988,7 +6988,7 @@ func stripAutoCompactNotes(notes []string) []string {
6988 // inline auto-compact heads-up. Lives in extNotes so it survives6988 // inline auto-compact heads-up. Lives in extNotes so it survives
6989 // the busy-spinner overwrite of the status row.6989 // the busy-spinner overwrite of the status row.
6990 func autoCompactNoteLine(th tui.Theme, msg string) string {6990 func autoCompactNoteLine(th tui.Theme, msg string) string {
6991- return " " + th.FG256(th.Warning, "⚠ "+msg)6991+ return " " + th.FGColor(th.Warning, "⚠ "+msg)
6992 }6992 }
6993 6993
6994 // isContextOverflowError matches provider responses that reject the6994 // isContextOverflowError matches provider responses that reject the
M packages/agent/modes/jump_dialog.go +7 -7
@@ -104,8 +104,8 @@ func (d *jumpDialog) Render(th tui.Theme, width int) []string {
104104 var lines []string
105105 lines = append(lines, frameHeader(th, "jump to turn", width))
106106 if len(d.all) == 0 {
107- lines = append(lines, th.FG256(th.Muted, "no turns in this session yet"))
108- lines = append(lines, th.FG256(th.Muted, "press esc to close"))
107+ lines = append(lines, th.FGColor(th.Muted, "no turns in this session yet"))
108+ lines = append(lines, th.FGColor(th.Muted, "press esc to close"))
109109 lines = append(lines, frameRule(th, width))
110110 return lines
111111 }
@@ -115,10 +115,10 @@ func (d *jumpDialog) Render(th tui.Theme, width int) []string {
115115 if d.filter != "" {
116116 hint = fmt.Sprintf("filter: %q - %d match - ", d.filter, len(d.visible)) + hint
117117 }
118- lines = append(lines, th.FG256(th.Muted, hint))
118+ lines = append(lines, th.FGColor(th.Muted, hint))
119119
120120 if len(d.visible) == 0 {
121- lines = append(lines, th.FG256(th.Muted, " (nothing matches; backspace to widen)"))
121+ lines = append(lines, th.FGColor(th.Muted, " (nothing matches; backspace to widen)"))
122122 lines = append(lines, frameRule(th, width))
123123 return lines
124124 }
@@ -140,7 +140,7 @@ func (d *jumpDialog) Render(th tui.Theme, width int) []string {
140140 }
141141 }
142142 if start > 0 {
143- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" \u2191 %d more above", start)))
143+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" \u2191 %d more above", start)))
144144 }
145145 for i := start; i < end; i++ {
146146 t := d.visible[i]
@@ -148,11 +148,11 @@ func (d *jumpDialog) Render(th tui.Theme, width int) []string {
148148 if i == d.cursor {
149149 lines = append(lines, th.PadHighlight(plain, width))
150150 } else {
151- lines = append(lines, th.FG256(th.Muted, plain))
151+ lines = append(lines, th.FGColor(th.Muted, plain))
152152 }
153153 }
154154 if end < len(d.visible) {
155- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" \u2193 %d more below", len(d.visible)-end)))
155+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" \u2193 %d more below", len(d.visible)-end)))
156156 }
157157
158158 lines = append(lines, frameRule(th, width))
@@ -104,8 +104,8 @@ func (d *jumpDialog) Render(th tui.Theme, width int) []string {
104 var lines []string104 var lines []string
105 lines = append(lines, frameHeader(th, "jump to turn", width))105 lines = append(lines, frameHeader(th, "jump to turn", width))
106 if len(d.all) == 0 {106 if len(d.all) == 0 {
107- lines = append(lines, th.FG256(th.Muted, "no turns in this session yet"))107+ lines = append(lines, th.FGColor(th.Muted, "no turns in this session yet"))
108- lines = append(lines, th.FG256(th.Muted, "press esc to close"))108+ lines = append(lines, th.FGColor(th.Muted, "press esc to close"))
109 lines = append(lines, frameRule(th, width))109 lines = append(lines, frameRule(th, width))
110 return lines110 return lines
111 }111 }
@@ -115,10 +115,10 @@ func (d *jumpDialog) Render(th tui.Theme, width int) []string {
115 if d.filter != "" {115 if d.filter != "" {
116 hint = fmt.Sprintf("filter: %q - %d match - ", d.filter, len(d.visible)) + hint116 hint = fmt.Sprintf("filter: %q - %d match - ", d.filter, len(d.visible)) + hint
117 }117 }
118- lines = append(lines, th.FG256(th.Muted, hint))118+ lines = append(lines, th.FGColor(th.Muted, hint))
119 119
120 if len(d.visible) == 0 {120 if len(d.visible) == 0 {
121- lines = append(lines, th.FG256(th.Muted, " (nothing matches; backspace to widen)"))121+ lines = append(lines, th.FGColor(th.Muted, " (nothing matches; backspace to widen)"))
122 lines = append(lines, frameRule(th, width))122 lines = append(lines, frameRule(th, width))
123 return lines123 return lines
124 }124 }
@@ -140,7 +140,7 @@ func (d *jumpDialog) Render(th tui.Theme, width int) []string {
140 }140 }
141 }141 }
142 if start > 0 {142 if start > 0 {
143- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" \u2191 %d more above", start)))143+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" \u2191 %d more above", start)))
144 }144 }
145 for i := start; i < end; i++ {145 for i := start; i < end; i++ {
146 t := d.visible[i]146 t := d.visible[i]
@@ -148,11 +148,11 @@ func (d *jumpDialog) Render(th tui.Theme, width int) []string {
148 if i == d.cursor {148 if i == d.cursor {
149 lines = append(lines, th.PadHighlight(plain, width))149 lines = append(lines, th.PadHighlight(plain, width))
150 } else {150 } else {
151- lines = append(lines, th.FG256(th.Muted, plain))151+ lines = append(lines, th.FGColor(th.Muted, plain))
152 }152 }
153 }153 }
154 if end < len(d.visible) {154 if end < len(d.visible) {
155- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" \u2193 %d more below", len(d.visible)-end)))155+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" \u2193 %d more below", len(d.visible)-end)))
156 }156 }
157 157
158 lines = append(lines, frameRule(th, width))158 lines = append(lines, frameRule(th, width))
M packages/agent/modes/llama_dialog.go +19 -19
@@ -497,15 +497,15 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string {
497497 }
498498 lines := []string{frameHeader(th, "llama.cpp models", width)}
499499 if d.client != nil {
500- lines = append(lines, th.FG256(th.Muted, d.client.ServerURL))
500+ lines = append(lines, th.FGColor(th.Muted, d.client.ServerURL))
501501 }
502502 if d.message != "" {
503- lines = append(lines, th.FG256(th.Muted, d.message))
503+ lines = append(lines, th.FGColor(th.Muted, d.message))
504504 }
505505
506506 switch d.step {
507507 case llamaLoading:
508- lines = append(lines, th.FG256(th.Muted, " working..."))
508+ lines = append(lines, th.FGColor(th.Muted, " working..."))
509509 case llamaModels:
510510 const visible = 12
511511 start, end := dialogWindow(d.cursor, len(d.models)+1, visible)
@@ -523,10 +523,10 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string {
523523 if index == d.cursor {
524524 lines = append(lines, th.PadHighlight(line, width))
525525 } else {
526- lines = append(lines, th.FG256(th.Muted, line))
526+ lines = append(lines, th.FGColor(th.Muted, line))
527527 }
528528 }
529- lines = append(lines, th.FG256(th.Muted, "enter loads/unloads, d removes cached model, esc closes"))
529+ lines = append(lines, th.FGColor(th.Muted, "enter loads/unloads, d removes cached model, esc closes"))
530530 case llamaConfirmUnload:
531531 lines = append(lines, "", " Unload "+d.selected+"?")
532532 for index, label := range []string{"Yes", "No"} {
@@ -534,7 +534,7 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string {
534534 if index == d.cursor {
535535 lines = append(lines, th.PadHighlight(line, width))
536536 } else {
537- lines = append(lines, th.FG256(th.Muted, line))
537+ lines = append(lines, th.FGColor(th.Muted, line))
538538 }
539539 }
540540 case llamaConfirmRemove:
@@ -544,11 +544,11 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string {
544544 if index == d.cursor {
545545 lines = append(lines, th.PadHighlight(line, width))
546546 } else {
547- lines = append(lines, th.FG256(th.Muted, line))
547+ lines = append(lines, th.FGColor(th.Muted, line))
548548 }
549549 }
550550 case llamaSearch:
551- lines = append(lines, "", th.FG256(th.Muted, " Model name or owner/repository"), th.FG256(th.FG, " "+d.query), "")
551+ lines = append(lines, "", th.FGColor(th.Muted, " Model name or owner/repository"), th.FGColor(th.FG, " "+d.query), "")
552552 start, end := dialogWindow(d.cursor, len(d.results), 10)
553553 for index := start; index < end; index++ {
554554 result := d.results[index]
@@ -556,20 +556,20 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string {
556556 if index == d.cursor {
557557 lines = append(lines, th.PadHighlight(line, width))
558558 } else {
559- lines = append(lines, th.FG256(th.Muted, line))
559+ lines = append(lines, th.FGColor(th.Muted, line))
560560 }
561561 }
562- lines = append(lines, th.FG256(th.Muted, "enter selects, esc goes back"))
562+ lines = append(lines, th.FGColor(th.Muted, "enter selects, esc goes back"))
563563 case llamaModelDetails:
564564 lines = append(lines, "", " "+d.details.ID)
565565 if d.details.Gated != "" {
566- lines = append(lines, th.FG256(th.Error, " access approval and HF_TOKEN may be required"))
566+ lines = append(lines, th.FGColor(th.Error, " access approval and HF_TOKEN may be required"))
567567 }
568- lines = append(lines, th.FG256(th.Muted, " enter downloads, esc goes back"))
568+ lines = append(lines, th.FGColor(th.Muted, " enter downloads, esc goes back"))
569569 case llamaQuantizations:
570570 lines = append(lines, "", " Select quantization for "+d.details.ID)
571571 if d.details.Gated != "" {
572- lines = append(lines, th.FG256(th.Error, " gated model: approve access on huggingface.co and configure HF_TOKEN on the router"))
572+ lines = append(lines, th.FGColor(th.Error, " gated model: approve access on huggingface.co and configure HF_TOKEN on the router"))
573573 }
574574 start, end := dialogWindow(d.cursor, len(d.details.Quantizations), 10)
575575 for index := start; index < end; index++ {
@@ -585,24 +585,24 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string {
585585 if index == d.cursor {
586586 lines = append(lines, th.PadHighlight(line, width))
587587 } else {
588- lines = append(lines, th.FG256(th.Muted, line))
588+ lines = append(lines, th.FGColor(th.Muted, line))
589589 }
590590 }
591591 case llamaProgress:
592- lines = append(lines, "", " "+d.selected, th.FG256(th.Muted, " "+d.progress.Message))
592+ lines = append(lines, "", " "+d.selected, th.FGColor(th.Muted, " "+d.progress.Message))
593593 if d.progress.HasRatio {
594594 const cells = 32
595595 filled := int(d.progress.Ratio*float64(cells) + .5)
596596 filled = max(0, min(cells, filled))
597597 bar := strings.Repeat("#", filled) + strings.Repeat("-", cells-filled)
598- lines = append(lines, th.FG256(th.Accent, fmt.Sprintf(" %s %d%%", bar, int(d.progress.Ratio*100+.5))))
598+ lines = append(lines, th.FGColor(th.Accent, fmt.Sprintf(" %s %d%%", bar, int(d.progress.Ratio*100+.5))))
599599 }
600600 if d.progress.Detail != "" {
601- lines = append(lines, th.FG256(th.Muted, " "+d.progress.Detail))
601+ lines = append(lines, th.FGColor(th.Muted, " "+d.progress.Detail))
602602 }
603- lines = append(lines, th.FG256(th.Muted, " esc stops"))
603+ lines = append(lines, th.FGColor(th.Muted, " esc stops"))
604604 case llamaError:
605- lines = append(lines, th.FG256(th.Error, " "+d.message), th.FG256(th.Muted, " esc closes"))
605+ lines = append(lines, th.FGColor(th.Error, " "+d.message), th.FGColor(th.Muted, " esc closes"))
606606 }
607607 return append(lines, frameRule(th, width))
608608 }
@@ -497,15 +497,15 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string {
497 }497 }
498 lines := []string{frameHeader(th, "llama.cpp models", width)}498 lines := []string{frameHeader(th, "llama.cpp models", width)}
499 if d.client != nil {499 if d.client != nil {
500- lines = append(lines, th.FG256(th.Muted, d.client.ServerURL))500+ lines = append(lines, th.FGColor(th.Muted, d.client.ServerURL))
501 }501 }
502 if d.message != "" {502 if d.message != "" {
503- lines = append(lines, th.FG256(th.Muted, d.message))503+ lines = append(lines, th.FGColor(th.Muted, d.message))
504 }504 }
505 505
506 switch d.step {506 switch d.step {
507 case llamaLoading:507 case llamaLoading:
508- lines = append(lines, th.FG256(th.Muted, " working..."))508+ lines = append(lines, th.FGColor(th.Muted, " working..."))
509 case llamaModels:509 case llamaModels:
510 const visible = 12510 const visible = 12
511 start, end := dialogWindow(d.cursor, len(d.models)+1, visible)511 start, end := dialogWindow(d.cursor, len(d.models)+1, visible)
@@ -523,10 +523,10 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string {
523 if index == d.cursor {523 if index == d.cursor {
524 lines = append(lines, th.PadHighlight(line, width))524 lines = append(lines, th.PadHighlight(line, width))
525 } else {525 } else {
526- lines = append(lines, th.FG256(th.Muted, line))526+ lines = append(lines, th.FGColor(th.Muted, line))
527 }527 }
528 }528 }
529- lines = append(lines, th.FG256(th.Muted, "enter loads/unloads, d removes cached model, esc closes"))529+ lines = append(lines, th.FGColor(th.Muted, "enter loads/unloads, d removes cached model, esc closes"))
530 case llamaConfirmUnload:530 case llamaConfirmUnload:
531 lines = append(lines, "", " Unload "+d.selected+"?")531 lines = append(lines, "", " Unload "+d.selected+"?")
532 for index, label := range []string{"Yes", "No"} {532 for index, label := range []string{"Yes", "No"} {
@@ -534,7 +534,7 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string {
534 if index == d.cursor {534 if index == d.cursor {
535 lines = append(lines, th.PadHighlight(line, width))535 lines = append(lines, th.PadHighlight(line, width))
536 } else {536 } else {
537- lines = append(lines, th.FG256(th.Muted, line))537+ lines = append(lines, th.FGColor(th.Muted, line))
538 }538 }
539 }539 }
540 case llamaConfirmRemove:540 case llamaConfirmRemove:
@@ -544,11 +544,11 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string {
544 if index == d.cursor {544 if index == d.cursor {
545 lines = append(lines, th.PadHighlight(line, width))545 lines = append(lines, th.PadHighlight(line, width))
546 } else {546 } else {
547- lines = append(lines, th.FG256(th.Muted, line))547+ lines = append(lines, th.FGColor(th.Muted, line))
548 }548 }
549 }549 }
550 case llamaSearch:550 case llamaSearch:
551- lines = append(lines, "", th.FG256(th.Muted, " Model name or owner/repository"), th.FG256(th.FG, " "+d.query), "")551+ lines = append(lines, "", th.FGColor(th.Muted, " Model name or owner/repository"), th.FGColor(th.FG, " "+d.query), "")
552 start, end := dialogWindow(d.cursor, len(d.results), 10)552 start, end := dialogWindow(d.cursor, len(d.results), 10)
553 for index := start; index < end; index++ {553 for index := start; index < end; index++ {
554 result := d.results[index]554 result := d.results[index]
@@ -556,20 +556,20 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string {
556 if index == d.cursor {556 if index == d.cursor {
557 lines = append(lines, th.PadHighlight(line, width))557 lines = append(lines, th.PadHighlight(line, width))
558 } else {558 } else {
559- lines = append(lines, th.FG256(th.Muted, line))559+ lines = append(lines, th.FGColor(th.Muted, line))
560 }560 }
561 }561 }
562- lines = append(lines, th.FG256(th.Muted, "enter selects, esc goes back"))562+ lines = append(lines, th.FGColor(th.Muted, "enter selects, esc goes back"))
563 case llamaModelDetails:563 case llamaModelDetails:
564 lines = append(lines, "", " "+d.details.ID)564 lines = append(lines, "", " "+d.details.ID)
565 if d.details.Gated != "" {565 if d.details.Gated != "" {
566- lines = append(lines, th.FG256(th.Error, " access approval and HF_TOKEN may be required"))566+ lines = append(lines, th.FGColor(th.Error, " access approval and HF_TOKEN may be required"))
567 }567 }
568- lines = append(lines, th.FG256(th.Muted, " enter downloads, esc goes back"))568+ lines = append(lines, th.FGColor(th.Muted, " enter downloads, esc goes back"))
569 case llamaQuantizations:569 case llamaQuantizations:
570 lines = append(lines, "", " Select quantization for "+d.details.ID)570 lines = append(lines, "", " Select quantization for "+d.details.ID)
571 if d.details.Gated != "" {571 if d.details.Gated != "" {
572- lines = append(lines, th.FG256(th.Error, " gated model: approve access on huggingface.co and configure HF_TOKEN on the router"))572+ lines = append(lines, th.FGColor(th.Error, " gated model: approve access on huggingface.co and configure HF_TOKEN on the router"))
573 }573 }
574 start, end := dialogWindow(d.cursor, len(d.details.Quantizations), 10)574 start, end := dialogWindow(d.cursor, len(d.details.Quantizations), 10)
575 for index := start; index < end; index++ {575 for index := start; index < end; index++ {
@@ -585,24 +585,24 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string {
585 if index == d.cursor {585 if index == d.cursor {
586 lines = append(lines, th.PadHighlight(line, width))586 lines = append(lines, th.PadHighlight(line, width))
587 } else {587 } else {
588- lines = append(lines, th.FG256(th.Muted, line))588+ lines = append(lines, th.FGColor(th.Muted, line))
589 }589 }
590 }590 }
591 case llamaProgress:591 case llamaProgress:
592- lines = append(lines, "", " "+d.selected, th.FG256(th.Muted, " "+d.progress.Message))592+ lines = append(lines, "", " "+d.selected, th.FGColor(th.Muted, " "+d.progress.Message))
593 if d.progress.HasRatio {593 if d.progress.HasRatio {
594 const cells = 32594 const cells = 32
595 filled := int(d.progress.Ratio*float64(cells) + .5)595 filled := int(d.progress.Ratio*float64(cells) + .5)
596 filled = max(0, min(cells, filled))596 filled = max(0, min(cells, filled))
597 bar := strings.Repeat("#", filled) + strings.Repeat("-", cells-filled)597 bar := strings.Repeat("#", filled) + strings.Repeat("-", cells-filled)
598- lines = append(lines, th.FG256(th.Accent, fmt.Sprintf(" %s %d%%", bar, int(d.progress.Ratio*100+.5))))598+ lines = append(lines, th.FGColor(th.Accent, fmt.Sprintf(" %s %d%%", bar, int(d.progress.Ratio*100+.5))))
599 }599 }
600 if d.progress.Detail != "" {600 if d.progress.Detail != "" {
601- lines = append(lines, th.FG256(th.Muted, " "+d.progress.Detail))601+ lines = append(lines, th.FGColor(th.Muted, " "+d.progress.Detail))
602 }602 }
603- lines = append(lines, th.FG256(th.Muted, " esc stops"))603+ lines = append(lines, th.FGColor(th.Muted, " esc stops"))
604 case llamaError:604 case llamaError:
605- lines = append(lines, th.FG256(th.Error, " "+d.message), th.FG256(th.Muted, " esc closes"))605+ lines = append(lines, th.FGColor(th.Error, " "+d.message), th.FGColor(th.Muted, " esc closes"))
606 }606 }
607 return append(lines, frameRule(th, width))607 return append(lines, frameRule(th, width))
608 }608 }
M packages/agent/modes/login_dialog.go +30 -30
@@ -135,13 +135,13 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
135135 }
136136 lines = append(lines, frameHeader(th, "login", width))
137137 lines = append(lines, d.renderStatusLines(th)...)
138- lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):"))
138+ lines = append(lines, th.FGColor(th.Muted, "choose login method (↑/↓, enter, esc to cancel):"))
139139 for i, o := range opts {
140140 plain := " " + o
141141 if i == d.cursor {
142142 lines = append(lines, th.PadHighlight(plain, width))
143143 } else {
144- lines = append(lines, th.FG256(th.Muted, plain))
144+ lines = append(lines, th.FGColor(th.Muted, plain))
145145 }
146146 }
147147 lines = append(lines, frameRule(th, width))
@@ -156,7 +156,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
156156 hint = fmt.Sprintf("filter: %s (%d matches)", d.providerQuery, len(opts))
157157 }
158158 }
159- lines = append(lines, th.FG256(th.Muted, hint))
159+ lines = append(lines, th.FGColor(th.Muted, hint))
160160 start, end := d.providerPage(len(opts))
161161 for i := start; i < end; i++ {
162162 o := opts[i]
@@ -170,81 +170,81 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
170170 if i == d.cursor {
171171 lines = append(lines, th.PadHighlight(plain, width))
172172 } else {
173- lines = append(lines, th.FG256(th.Muted, label)+th.FG256(th.Accent, tag))
173+ lines = append(lines, th.FGColor(th.Muted, label)+th.FGColor(th.Accent, tag))
174174 }
175175 }
176176 if len(opts) == 0 {
177- lines = append(lines, th.FG256(th.Muted, " no providers match "+fmt.Sprintf("%q", d.providerQuery)))
177+ lines = append(lines, th.FGColor(th.Muted, " no providers match "+fmt.Sprintf("%q", d.providerQuery)))
178178 } else if len(opts) > loginProviderPageSize {
179- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
179+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
180180 }
181181 lines = append(lines, frameRule(th, width))
182182 case loginStepLlamaURL:
183183 lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width))
184- lines = append(lines, th.FG256(th.Muted, "llama.cpp router URL:"))
184+ lines = append(lines, th.FGColor(th.Muted, "llama.cpp router URL:"))
185185 if d.llamaEd == nil {
186186 d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent))
187187 }
188188 edLines, _, _ := d.llamaEd.Render(width - 2)
189189 lines = append(lines, edLines...)
190190 if d.message != "" {
191- lines = append(lines, th.FG256(th.Error, d.message))
191+ lines = append(lines, th.FGColor(th.Error, d.message))
192192 }
193- lines = append(lines, "", th.FG256(th.Muted, "enter continues - esc cancels"), frameRule(th, width))
193+ lines = append(lines, "", th.FGColor(th.Muted, "enter continues - esc cancels"), frameRule(th, width))
194194 case loginStepLlamaKey:
195195 lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width))
196- lines = append(lines, th.FG256(th.Muted, "router: "+d.llamaURL), "", th.FG256(th.Muted, "API key (optional):"))
196+ lines = append(lines, th.FGColor(th.Muted, "router: "+d.llamaURL), "", th.FGColor(th.Muted, "API key (optional):"))
197197 if d.llamaEd == nil {
198198 d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent))
199199 }
200200 d.llamaEd.Mask = true
201201 edLines, _, _ := d.llamaEd.Render(width - 2)
202202 lines = append(lines, edLines...)
203- lines = append(lines, "", th.FG256(th.Muted, "enter saves - esc cancels"), frameRule(th, width))
203+ lines = append(lines, "", th.FGColor(th.Muted, "enter saves - esc cancels"), frameRule(th, width))
204204 case loginStepWaiting:
205205 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))
206- lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:"))
206+ lines = append(lines, th.FGColor(th.Muted, "open this URL in a browser:"))
207207 wrapW := width - 2
208208 if wrapW < 20 {
209209 wrapW = 20
210210 }
211211 for _, seg := range tui.WrapANSILine(d.url, wrapW) {
212- lines = append(lines, th.FG256(th.Accent, seg))
212+ lines = append(lines, th.FGColor(th.Accent, seg))
213213 }
214214 lines = append(lines, "")
215215 if d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" {
216- lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))
216+ lines = append(lines, th.FGColor(th.Muted, "complete sign-in in the browser - esc cancels"))
217217 lines = append(lines, frameRule(th, width))
218218 break
219219 }
220- lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
220+ lines = append(lines, th.FGColor(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
221221 if d.codeEd == nil {
222222 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
223223 }
224224 edLines, _, _ := d.codeEd.Render(width - 2)
225225 lines = append(lines, edLines...)
226226 lines = append(lines, "")
227- lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels - waiting for browser callback in background"))
227+ lines = append(lines, th.FGColor(th.Muted, "enter submits - esc cancels - waiting for browser callback in background"))
228228 lines = append(lines, frameRule(th, width))
229229 case loginStepPasteCode:
230230 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider)+" - paste code", width))
231- lines = append(lines, th.FG256(th.Muted, "open this URL in any browser:"))
231+ lines = append(lines, th.FGColor(th.Muted, "open this URL in any browser:"))
232232 wrapW := width - 2
233233 if wrapW < 20 {
234234 wrapW = 20
235235 }
236236 for _, seg := range tui.WrapANSILine(d.url, wrapW) {
237- lines = append(lines, th.FG256(th.Accent, seg))
237+ lines = append(lines, th.FGColor(th.Accent, seg))
238238 }
239239 lines = append(lines, "")
240- lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
240+ lines = append(lines, th.FGColor(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
241241 if d.codeEd == nil {
242242 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
243243 }
244244 edLines, _, _ := d.codeEd.Render(width - 2)
245245 lines = append(lines, edLines...)
246246 lines = append(lines, "")
247- lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels"))
247+ lines = append(lines, th.FGColor(th.Muted, "enter submits - esc cancels"))
248248 lines = append(lines, frameRule(th, width))
249249 case loginStepInfo:
250250 title := d.infoTitle
@@ -254,18 +254,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
254254 lines = append(lines, frameHeader(th, title, width))
255255 lines = append(lines, d.infoLines...)
256256 lines = append(lines, "")
257- lines = append(lines, th.FG256(th.Muted, "press any key to close"))
257+ lines = append(lines, th.FGColor(th.Muted, "press any key to close"))
258258 lines = append(lines, frameRule(th, width))
259259 case loginStepDone:
260260 title := "login - failed"
261- body := th.FG256(th.Error, d.message)
261+ body := th.FGColor(th.Error, d.message)
262262 if d.success {
263263 title = "login - success"
264- body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", providerLabel(d.provider), d.method))
264+ body = th.FGColor(th.Tool, fmt.Sprintf("logged in to %s via %s", providerLabel(d.provider), d.method))
265265 }
266266 lines = append(lines, frameHeader(th, title, width))
267267 lines = append(lines, body)
268- lines = append(lines, th.FG256(th.Muted, "press any key to close"))
268+ lines = append(lines, th.FGColor(th.Muted, "press any key to close"))
269269 lines = append(lines, frameRule(th, width))
270270 }
271271 return lines
@@ -384,14 +384,14 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
384384 var mark, body string
385385 switch method {
386386 case "apikey":
387- mark = th.FG256(th.Tool, "✓")
388- body = th.FG256(th.Muted, label+": api key")
387+ mark = th.FGColor(th.Tool, "✓")
388+ body = th.FGColor(th.Muted, label+": api key")
389389 case "oauth":
390- mark = th.FG256(th.Tool, "✓")
391- body = th.FG256(th.Muted, label+": subscription")
390+ mark = th.FGColor(th.Tool, "✓")
391+ body = th.FGColor(th.Muted, label+": subscription")
392392 default:
393- mark = th.FG256(th.Muted, "–")
394- body = th.FG256(th.Muted, label+": not logged in")
393+ mark = th.FGColor(th.Muted, "–")
394+ body = th.FGColor(th.Muted, label+": not logged in")
395395 }
396396 return " " + mark + " " + body
397397 }
@@ -135,13 +135,13 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
135 }135 }
136 lines = append(lines, frameHeader(th, "login", width))136 lines = append(lines, frameHeader(th, "login", width))
137 lines = append(lines, d.renderStatusLines(th)...)137 lines = append(lines, d.renderStatusLines(th)...)
138- lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):"))138+ lines = append(lines, th.FGColor(th.Muted, "choose login method (↑/↓, enter, esc to cancel):"))
139 for i, o := range opts {139 for i, o := range opts {
140 plain := " " + o140 plain := " " + o
141 if i == d.cursor {141 if i == d.cursor {
142 lines = append(lines, th.PadHighlight(plain, width))142 lines = append(lines, th.PadHighlight(plain, width))
143 } else {143 } else {
144- lines = append(lines, th.FG256(th.Muted, plain))144+ lines = append(lines, th.FGColor(th.Muted, plain))
145 }145 }
146 }146 }
147 lines = append(lines, frameRule(th, width))147 lines = append(lines, frameRule(th, width))
@@ -156,7 +156,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
156 hint = fmt.Sprintf("filter: %s (%d matches)", d.providerQuery, len(opts))156 hint = fmt.Sprintf("filter: %s (%d matches)", d.providerQuery, len(opts))
157 }157 }
158 }158 }
159- lines = append(lines, th.FG256(th.Muted, hint))159+ lines = append(lines, th.FGColor(th.Muted, hint))
160 start, end := d.providerPage(len(opts))160 start, end := d.providerPage(len(opts))
161 for i := start; i < end; i++ {161 for i := start; i < end; i++ {
162 o := opts[i]162 o := opts[i]
@@ -170,81 +170,81 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
170 if i == d.cursor {170 if i == d.cursor {
171 lines = append(lines, th.PadHighlight(plain, width))171 lines = append(lines, th.PadHighlight(plain, width))
172 } else {172 } else {
173- lines = append(lines, th.FG256(th.Muted, label)+th.FG256(th.Accent, tag))173+ lines = append(lines, th.FGColor(th.Muted, label)+th.FGColor(th.Accent, tag))
174 }174 }
175 }175 }
176 if len(opts) == 0 {176 if len(opts) == 0 {
177- lines = append(lines, th.FG256(th.Muted, " no providers match "+fmt.Sprintf("%q", d.providerQuery)))177+ lines = append(lines, th.FGColor(th.Muted, " no providers match "+fmt.Sprintf("%q", d.providerQuery)))
178 } else if len(opts) > loginProviderPageSize {178 } else if len(opts) > loginProviderPageSize {
179- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))179+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
180 }180 }
181 lines = append(lines, frameRule(th, width))181 lines = append(lines, frameRule(th, width))
182 case loginStepLlamaURL:182 case loginStepLlamaURL:
183 lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width))183 lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width))
184- lines = append(lines, th.FG256(th.Muted, "llama.cpp router URL:"))184+ lines = append(lines, th.FGColor(th.Muted, "llama.cpp router URL:"))
185 if d.llamaEd == nil {185 if d.llamaEd == nil {
186 d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent))186 d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent))
187 }187 }
188 edLines, _, _ := d.llamaEd.Render(width - 2)188 edLines, _, _ := d.llamaEd.Render(width - 2)
189 lines = append(lines, edLines...)189 lines = append(lines, edLines...)
190 if d.message != "" {190 if d.message != "" {
191- lines = append(lines, th.FG256(th.Error, d.message))191+ lines = append(lines, th.FGColor(th.Error, d.message))
192 }192 }
193- lines = append(lines, "", th.FG256(th.Muted, "enter continues - esc cancels"), frameRule(th, width))193+ lines = append(lines, "", th.FGColor(th.Muted, "enter continues - esc cancels"), frameRule(th, width))
194 case loginStepLlamaKey:194 case loginStepLlamaKey:
195 lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width))195 lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width))
196- lines = append(lines, th.FG256(th.Muted, "router: "+d.llamaURL), "", th.FG256(th.Muted, "API key (optional):"))196+ lines = append(lines, th.FGColor(th.Muted, "router: "+d.llamaURL), "", th.FGColor(th.Muted, "API key (optional):"))
197 if d.llamaEd == nil {197 if d.llamaEd == nil {
198 d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent))198 d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent))
199 }199 }
200 d.llamaEd.Mask = true200 d.llamaEd.Mask = true
201 edLines, _, _ := d.llamaEd.Render(width - 2)201 edLines, _, _ := d.llamaEd.Render(width - 2)
202 lines = append(lines, edLines...)202 lines = append(lines, edLines...)
203- lines = append(lines, "", th.FG256(th.Muted, "enter saves - esc cancels"), frameRule(th, width))203+ lines = append(lines, "", th.FGColor(th.Muted, "enter saves - esc cancels"), frameRule(th, width))
204 case loginStepWaiting:204 case loginStepWaiting:
205 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))205 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))
206- lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:"))206+ lines = append(lines, th.FGColor(th.Muted, "open this URL in a browser:"))
207 wrapW := width - 2207 wrapW := width - 2
208 if wrapW < 20 {208 if wrapW < 20 {
209 wrapW = 20209 wrapW = 20
210 }210 }
211 for _, seg := range tui.WrapANSILine(d.url, wrapW) {211 for _, seg := range tui.WrapANSILine(d.url, wrapW) {
212- lines = append(lines, th.FG256(th.Accent, seg))212+ lines = append(lines, th.FGColor(th.Accent, seg))
213 }213 }
214 lines = append(lines, "")214 lines = append(lines, "")
215 if d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" {215 if d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" {
216- lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))216+ lines = append(lines, th.FGColor(th.Muted, "complete sign-in in the browser - esc cancels"))
217 lines = append(lines, frameRule(th, width))217 lines = append(lines, frameRule(th, width))
218 break218 break
219 }219 }
220- lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))220+ lines = append(lines, th.FGColor(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
221 if d.codeEd == nil {221 if d.codeEd == nil {
222 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))222 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
223 }223 }
224 edLines, _, _ := d.codeEd.Render(width - 2)224 edLines, _, _ := d.codeEd.Render(width - 2)
225 lines = append(lines, edLines...)225 lines = append(lines, edLines...)
226 lines = append(lines, "")226 lines = append(lines, "")
227- lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels - waiting for browser callback in background"))227+ lines = append(lines, th.FGColor(th.Muted, "enter submits - esc cancels - waiting for browser callback in background"))
228 lines = append(lines, frameRule(th, width))228 lines = append(lines, frameRule(th, width))
229 case loginStepPasteCode:229 case loginStepPasteCode:
230 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider)+" - paste code", width))230 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider)+" - paste code", width))
231- lines = append(lines, th.FG256(th.Muted, "open this URL in any browser:"))231+ lines = append(lines, th.FGColor(th.Muted, "open this URL in any browser:"))
232 wrapW := width - 2232 wrapW := width - 2
233 if wrapW < 20 {233 if wrapW < 20 {
234 wrapW = 20234 wrapW = 20
235 }235 }
236 for _, seg := range tui.WrapANSILine(d.url, wrapW) {236 for _, seg := range tui.WrapANSILine(d.url, wrapW) {
237- lines = append(lines, th.FG256(th.Accent, seg))237+ lines = append(lines, th.FGColor(th.Accent, seg))
238 }238 }
239 lines = append(lines, "")239 lines = append(lines, "")
240- lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))240+ lines = append(lines, th.FGColor(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
241 if d.codeEd == nil {241 if d.codeEd == nil {
242 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))242 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
243 }243 }
244 edLines, _, _ := d.codeEd.Render(width - 2)244 edLines, _, _ := d.codeEd.Render(width - 2)
245 lines = append(lines, edLines...)245 lines = append(lines, edLines...)
246 lines = append(lines, "")246 lines = append(lines, "")
247- lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels"))247+ lines = append(lines, th.FGColor(th.Muted, "enter submits - esc cancels"))
248 lines = append(lines, frameRule(th, width))248 lines = append(lines, frameRule(th, width))
249 case loginStepInfo:249 case loginStepInfo:
250 title := d.infoTitle250 title := d.infoTitle
@@ -254,18 +254,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
254 lines = append(lines, frameHeader(th, title, width))254 lines = append(lines, frameHeader(th, title, width))
255 lines = append(lines, d.infoLines...)255 lines = append(lines, d.infoLines...)
256 lines = append(lines, "")256 lines = append(lines, "")
257- lines = append(lines, th.FG256(th.Muted, "press any key to close"))257+ lines = append(lines, th.FGColor(th.Muted, "press any key to close"))
258 lines = append(lines, frameRule(th, width))258 lines = append(lines, frameRule(th, width))
259 case loginStepDone:259 case loginStepDone:
260 title := "login - failed"260 title := "login - failed"
261- body := th.FG256(th.Error, d.message)261+ body := th.FGColor(th.Error, d.message)
262 if d.success {262 if d.success {
263 title = "login - success"263 title = "login - success"
264- body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", providerLabel(d.provider), d.method))264+ body = th.FGColor(th.Tool, fmt.Sprintf("logged in to %s via %s", providerLabel(d.provider), d.method))
265 }265 }
266 lines = append(lines, frameHeader(th, title, width))266 lines = append(lines, frameHeader(th, title, width))
267 lines = append(lines, body)267 lines = append(lines, body)
268- lines = append(lines, th.FG256(th.Muted, "press any key to close"))268+ lines = append(lines, th.FGColor(th.Muted, "press any key to close"))
269 lines = append(lines, frameRule(th, width))269 lines = append(lines, frameRule(th, width))
270 }270 }
271 return lines271 return lines
@@ -384,14 +384,14 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
384 var mark, body string384 var mark, body string
385 switch method {385 switch method {
386 case "apikey":386 case "apikey":
387- mark = th.FG256(th.Tool, "✓")387+ mark = th.FGColor(th.Tool, "✓")
388- body = th.FG256(th.Muted, label+": api key")388+ body = th.FGColor(th.Muted, label+": api key")
389 case "oauth":389 case "oauth":
390- mark = th.FG256(th.Tool, "✓")390+ mark = th.FGColor(th.Tool, "✓")
391- body = th.FG256(th.Muted, label+": subscription")391+ body = th.FGColor(th.Muted, label+": subscription")
392 default:392 default:
393- mark = th.FG256(th.Muted, "–")393+ mark = th.FGColor(th.Muted, "–")
394- body = th.FG256(th.Muted, label+": not logged in")394+ body = th.FGColor(th.Muted, label+": not logged in")
395 }395 }
396 return " " + mark + " " + body396 return " " + mark + " " + body
397 }397 }
M packages/agent/modes/logout_dialog.go +2 -2
@@ -86,7 +86,7 @@ func (d *logoutDialog) Render(th tui.Theme, width int) []string {
8686 }
8787 var lines []string
8888 lines = append(lines, frameHeader(th, "logout", width))
89- lines = append(lines, th.FG256(th.Muted, "choose what to log out of (\u2191/\u2193, enter, esc to cancel):"))
89+ lines = append(lines, th.FGColor(th.Muted, "choose what to log out of (\u2191/\u2193, enter, esc to cancel):"))
9090 for i, it := range d.items {
9191 plain := " " + it.label
9292 if it.method != "" {
@@ -95,7 +95,7 @@ func (d *logoutDialog) Render(th tui.Theme, width int) []string {
9595 if i == d.cursor {
9696 lines = append(lines, th.PadHighlight(plain, width))
9797 } else {
98- lines = append(lines, th.FG256(th.Muted, plain))
98+ lines = append(lines, th.FGColor(th.Muted, plain))
9999 }
100100 }
101101 lines = append(lines, frameRule(th, width))
@@ -86,7 +86,7 @@ func (d *logoutDialog) Render(th tui.Theme, width int) []string {
86 }86 }
87 var lines []string87 var lines []string
88 lines = append(lines, frameHeader(th, "logout", width))88 lines = append(lines, frameHeader(th, "logout", width))
89- lines = append(lines, th.FG256(th.Muted, "choose what to log out of (\u2191/\u2193, enter, esc to cancel):"))89+ lines = append(lines, th.FGColor(th.Muted, "choose what to log out of (\u2191/\u2193, enter, esc to cancel):"))
90 for i, it := range d.items {90 for i, it := range d.items {
91 plain := " " + it.label91 plain := " " + it.label
92 if it.method != "" {92 if it.method != "" {
@@ -95,7 +95,7 @@ func (d *logoutDialog) Render(th tui.Theme, width int) []string {
95 if i == d.cursor {95 if i == d.cursor {
96 lines = append(lines, th.PadHighlight(plain, width))96 lines = append(lines, th.PadHighlight(plain, width))
97 } else {97 } else {
98- lines = append(lines, th.FG256(th.Muted, plain))98+ lines = append(lines, th.FGColor(th.Muted, plain))
99 }99 }
100 }100 }
101 lines = append(lines, frameRule(th, width))101 lines = append(lines, frameRule(th, width))
M packages/agent/modes/logout_dialog_test.go +3 -1
@@ -12,7 +12,9 @@ func TestLogoutDialogSelectionBackgroundSpansFullWidth(t *testing.T) {
1212 d := newLogoutDialog()
1313 d.Open([]logoutItem{{label: "Google", target: "google", method: "api key"}})
1414 const width = 60
15- lines := d.Render(tui.Theme{SelectionFG: 15, SelectionBG: 4, Muted: 8}, width)
15+ lines := d.Render(tui.Theme{
16+ SelectionFG: tui.Color256(15), SelectionBG: tui.Color256(4), Muted: tui.Color256(8),
17+ }, width)
1618 selected := lines[2]
1719 if got := runewidth.StringWidth(stripANSIBytes(selected)); got != width {
1820 t.Fatalf("selected row width = %d, want %d", got, width)
@@ -12,7 +12,9 @@ func TestLogoutDialogSelectionBackgroundSpansFullWidth(t *testing.T) {
12 d := newLogoutDialog()12 d := newLogoutDialog()
13 d.Open([]logoutItem{{label: "Google", target: "google", method: "api key"}})13 d.Open([]logoutItem{{label: "Google", target: "google", method: "api key"}})
14 const width = 6014 const width = 60
15- lines := d.Render(tui.Theme{SelectionFG: 15, SelectionBG: 4, Muted: 8}, width)15+ lines := d.Render(tui.Theme{
16+ SelectionFG: tui.Color256(15), SelectionBG: tui.Color256(4), Muted: tui.Color256(8),
17+ }, width)
16 selected := lines[2]18 selected := lines[2]
17 if got := runewidth.StringWidth(stripANSIBytes(selected)); got != width {19 if got := runewidth.StringWidth(stripANSIBytes(selected)); got != width {
18 t.Fatalf("selected row width = %d, want %d", got, width)20 t.Fatalf("selected row width = %d, want %d", got, width)
M packages/agent/modes/model_dialog.go +7 -7
@@ -153,13 +153,13 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
153153 } else {
154154 hint += " - type to filter"
155155 }
156- lines = append(lines, th.FG256(th.Muted, hint))
156+ lines = append(lines, th.FGColor(th.Muted, hint))
157157 if d.showReasoning {
158158 reasoning := d.reasoning
159159 if reasoning == "" {
160160 reasoning = "off"
161161 }
162- lines = append(lines, th.FG256(th.Muted, "✦ supports reasoning; current reasoning: "+reasoning+"; change with /reasoning"))
162+ lines = append(lines, th.FGColor(th.Muted, "✦ supports reasoning; current reasoning: "+reasoning+"; change with /reasoning"))
163163 }
164164
165165 if len(d.view) == 0 {
@@ -167,7 +167,7 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
167167 if len(d.all) == 0 {
168168 msg = " no credentials found - run /login to add an api key or subscription"
169169 }
170- lines = append(lines, th.FG256(th.Muted, msg))
170+ lines = append(lines, th.FGColor(th.Muted, msg))
171171 lines = append(lines, frameRule(th, width))
172172 return lines
173173 }
@@ -217,18 +217,18 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
217217 if i == d.cursor {
218218 lines = append(lines, th.PadHighlight(plain, width))
219219 } else {
220- lines = append(lines, th.FG256(th.Muted, plain))
220+ lines = append(lines, th.FGColor(th.Muted, plain))
221221 }
222222 }
223223
224224 if start > 0 {
225- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more above", start)))
225+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more above", start)))
226226 }
227227 if end < len(d.view) {
228- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
228+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
229229 }
230230 if len(d.view) > visible {
231- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(d.view))))
231+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(d.view))))
232232 }
233233
234234 lines = append(lines, frameRule(th, width))
@@ -153,13 +153,13 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
153 } else {153 } else {
154 hint += " - type to filter"154 hint += " - type to filter"
155 }155 }
156- lines = append(lines, th.FG256(th.Muted, hint))156+ lines = append(lines, th.FGColor(th.Muted, hint))
157 if d.showReasoning {157 if d.showReasoning {
158 reasoning := d.reasoning158 reasoning := d.reasoning
159 if reasoning == "" {159 if reasoning == "" {
160 reasoning = "off"160 reasoning = "off"
161 }161 }
162- lines = append(lines, th.FG256(th.Muted, "✦ supports reasoning; current reasoning: "+reasoning+"; change with /reasoning"))162+ lines = append(lines, th.FGColor(th.Muted, "✦ supports reasoning; current reasoning: "+reasoning+"; change with /reasoning"))
163 }163 }
164 164
165 if len(d.view) == 0 {165 if len(d.view) == 0 {
@@ -167,7 +167,7 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
167 if len(d.all) == 0 {167 if len(d.all) == 0 {
168 msg = " no credentials found - run /login to add an api key or subscription"168 msg = " no credentials found - run /login to add an api key or subscription"
169 }169 }
170- lines = append(lines, th.FG256(th.Muted, msg))170+ lines = append(lines, th.FGColor(th.Muted, msg))
171 lines = append(lines, frameRule(th, width))171 lines = append(lines, frameRule(th, width))
172 return lines172 return lines
173 }173 }
@@ -217,18 +217,18 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
217 if i == d.cursor {217 if i == d.cursor {
218 lines = append(lines, th.PadHighlight(plain, width))218 lines = append(lines, th.PadHighlight(plain, width))
219 } else {219 } else {
220- lines = append(lines, th.FG256(th.Muted, plain))220+ lines = append(lines, th.FGColor(th.Muted, plain))
221 }221 }
222 }222 }
223 223
224 if start > 0 {224 if start > 0 {
225- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more above", start)))225+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more above", start)))
226 }226 }
227 if end < len(d.view) {227 if end < len(d.view) {
228- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))228+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
229 }229 }
230 if len(d.view) > visible {230 if len(d.view) > visible {
231- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(d.view))))231+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(d.view))))
232 }232 }
233 233
234 lines = append(lines, frameRule(th, width))234 lines = append(lines, frameRule(th, width))
M packages/agent/modes/reload_ext.go +1 -1
@@ -64,7 +64,7 @@ func renderReloadErrors(theme tui.Theme, errors []string, width int) []string {
6464 if idx > 0 {
6565 prefix = indent
6666 }
67- out = append(out, theme.FG256(theme.Error, prefix+line))
67+ out = append(out, theme.FGColor(theme.Error, prefix+line))
6868 }
6969 }
7070 return out
@@ -64,7 +64,7 @@ func renderReloadErrors(theme tui.Theme, errors []string, width int) []string {
64 if idx > 0 {64 if idx > 0 {
65 prefix = indent65 prefix = indent
66 }66 }
67- out = append(out, theme.FG256(theme.Error, prefix+line))67+ out = append(out, theme.FGColor(theme.Error, prefix+line))
68 }68 }
69 }69 }
70 return out70 return out
M packages/agent/modes/reload_ext_test.go +2 -2
@@ -67,7 +67,7 @@ func TestStartupExtensionFailureAppearsInChatWithoutEnteringContext(t *testing.T
6767 agent := core.NewAgent(nil, "", "", nil)
6868 i := NewInteractive(InteractiveConfig{
6969 Agent: agent,
70- Theme: tui.Theme{Error: 1},
70+ Theme: tui.Theme{Error: tui.Color256(1)},
7171 StartupExtensionErrors: []string{"startup failed: stderr log: /tmp/startup.log"},
7272 })
7373
@@ -84,7 +84,7 @@ func TestReloadFailureRemainsInChatWithoutEnteringContext(t *testing.T) {
8484 agent := core.NewAgent(nil, "", "", nil)
8585 i := NewInteractive(InteractiveConfig{
8686 Agent: agent,
87- Theme: tui.Theme{Error: 1},
87+ Theme: tui.Theme{Error: tui.Color256(1)},
8888 })
8989 seq := i.setReloadStatus("broken extension: stderr log: /tmp/ext.log", true)
9090 if !i.clearReloadStatus(seq, i.statusErr, true) {
@@ -67,7 +67,7 @@ func TestStartupExtensionFailureAppearsInChatWithoutEnteringContext(t *testing.T
67 agent := core.NewAgent(nil, "", "", nil)67 agent := core.NewAgent(nil, "", "", nil)
68 i := NewInteractive(InteractiveConfig{68 i := NewInteractive(InteractiveConfig{
69 Agent: agent,69 Agent: agent,
70- Theme: tui.Theme{Error: 1},70+ Theme: tui.Theme{Error: tui.Color256(1)},
71 StartupExtensionErrors: []string{"startup failed: stderr log: /tmp/startup.log"},71 StartupExtensionErrors: []string{"startup failed: stderr log: /tmp/startup.log"},
72 })72 })
73 73
@@ -84,7 +84,7 @@ func TestReloadFailureRemainsInChatWithoutEnteringContext(t *testing.T) {
84 agent := core.NewAgent(nil, "", "", nil)84 agent := core.NewAgent(nil, "", "", nil)
85 i := NewInteractive(InteractiveConfig{85 i := NewInteractive(InteractiveConfig{
86 Agent: agent,86 Agent: agent,
87- Theme: tui.Theme{Error: 1},87+ Theme: tui.Theme{Error: tui.Color256(1)},
88 })88 })
89 seq := i.setReloadStatus("broken extension: stderr log: /tmp/ext.log", true)89 seq := i.setReloadStatus("broken extension: stderr log: /tmp/ext.log", true)
90 if !i.clearReloadStatus(seq, i.statusErr, true) {90 if !i.clearReloadStatus(seq, i.statusErr, true) {
M packages/agent/modes/rescue_dialog.go +7 -7
@@ -107,7 +107,7 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string {
107107 lines = append(lines, frameHeader(th, header, width))
108108
109109 if d.reason != "" {
110- lines = append(lines, th.FG256(th.Warning, " "+d.reason))
110+ lines = append(lines, th.FGColor(th.Warning, " "+d.reason))
111111 }
112112
113113 hint := "retry this turn with another model (↑/↓, enter, esc to cancel)"
@@ -119,13 +119,13 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string {
119119 } else {
120120 hint += " - type to filter"
121121 }
122- lines = append(lines, th.FG256(th.Muted, hint))
122+ lines = append(lines, th.FGColor(th.Muted, hint))
123123
124124 if len(d.view) == 0 {
125125 if len(d.all) == 0 {
126- lines = append(lines, th.FG256(th.Muted, " no other models available — log in to another provider with /login"))
126+ lines = append(lines, th.FGColor(th.Muted, " no other models available — log in to another provider with /login"))
127127 } else {
128- lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))
128+ lines = append(lines, th.FGColor(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))
129129 }
130130 lines = append(lines, frameRule(th, width))
131131 return lines
@@ -161,14 +161,14 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string {
161161 if i == d.cursor {
162162 lines = append(lines, th.PadHighlight(plain, width))
163163 } else {
164- lines = append(lines, th.FG256(th.Muted, plain))
164+ lines = append(lines, th.FGColor(th.Muted, plain))
165165 }
166166 }
167167 if start > 0 {
168- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more above", start)))
168+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more above", start)))
169169 }
170170 if end < len(d.view) {
171- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
171+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
172172 }
173173
174174 lines = append(lines, frameRule(th, width))
@@ -107,7 +107,7 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string {
107 lines = append(lines, frameHeader(th, header, width))107 lines = append(lines, frameHeader(th, header, width))
108 108
109 if d.reason != "" {109 if d.reason != "" {
110- lines = append(lines, th.FG256(th.Warning, " "+d.reason))110+ lines = append(lines, th.FGColor(th.Warning, " "+d.reason))
111 }111 }
112 112
113 hint := "retry this turn with another model (↑/↓, enter, esc to cancel)"113 hint := "retry this turn with another model (↑/↓, enter, esc to cancel)"
@@ -119,13 +119,13 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string {
119 } else {119 } else {
120 hint += " - type to filter"120 hint += " - type to filter"
121 }121 }
122- lines = append(lines, th.FG256(th.Muted, hint))122+ lines = append(lines, th.FGColor(th.Muted, hint))
123 123
124 if len(d.view) == 0 {124 if len(d.view) == 0 {
125 if len(d.all) == 0 {125 if len(d.all) == 0 {
126- lines = append(lines, th.FG256(th.Muted, " no other models available — log in to another provider with /login"))126+ lines = append(lines, th.FGColor(th.Muted, " no other models available — log in to another provider with /login"))
127 } else {127 } else {
128- lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))128+ lines = append(lines, th.FGColor(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))
129 }129 }
130 lines = append(lines, frameRule(th, width))130 lines = append(lines, frameRule(th, width))
131 return lines131 return lines
@@ -161,14 +161,14 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string {
161 if i == d.cursor {161 if i == d.cursor {
162 lines = append(lines, th.PadHighlight(plain, width))162 lines = append(lines, th.PadHighlight(plain, width))
163 } else {163 } else {
164- lines = append(lines, th.FG256(th.Muted, plain))164+ lines = append(lines, th.FGColor(th.Muted, plain))
165 }165 }
166 }166 }
167 if start > 0 {167 if start > 0 {
168- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more above", start)))168+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more above", start)))
169 }169 }
170 if end < len(d.view) {170 if end < len(d.view) {
171- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))171+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
172 }172 }
173 173
174 lines = append(lines, frameRule(th, width))174 lines = append(lines, frameRule(th, width))
M packages/agent/modes/session_dialog.go +13 -13
@@ -357,27 +357,27 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
357357 var lines []string
358358 lines = append(lines, frameHeader(th, "sessions", width))
359359 if d.loading {
360- lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th)))
360+ lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th)))
361361 if len(d.sessions) == 0 {
362- lines = append(lines, th.FG256(th.Muted, "session entries appear as they load"))
362+ lines = append(lines, th.FGColor(th.Muted, "session entries appear as they load"))
363363 lines = append(lines, frameRule(th, width))
364364 return lines
365365 }
366366 } else if len(d.sessions) == 0 {
367- lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory"))
368- lines = append(lines, th.FG256(th.Muted, "press esc to close"))
367+ lines = append(lines, th.FGColor(th.Muted, "no previous sessions for this directory"))
368+ lines = append(lines, th.FGColor(th.Muted, "press esc to close"))
369369 lines = append(lines, frameRule(th, width))
370370 return lines
371371 }
372372 if d.renaming {
373- lines = append(lines, th.FG256(th.Muted, "rename session (enter save, esc cancel):"))
373+ lines = append(lines, th.FGColor(th.Muted, "rename session (enter save, esc cancel):"))
374374 text := d.rename
375375 if text == "" {
376- text = th.FG256(th.Muted, "session name")
376+ text = th.FGColor(th.Muted, "session name")
377377 } else {
378- text = th.FG256(th.FG, text)
378+ text = th.FGColor(th.FG, text)
379379 }
380- lines = append(lines, " "+th.FG256(th.Accent, "▌ ")+text)
380+ lines = append(lines, " "+th.FGColor(th.Accent, "▌ ")+text)
381381 lines = append(lines, frameRule(th, width))
382382 return lines
383383 }
@@ -385,7 +385,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
385385 if d.loading {
386386 hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)"
387387 }
388- lines = append(lines, th.FG256(th.Muted, hint))
388+ lines = append(lines, th.FGColor(th.Muted, hint))
389389
390390 // Viewport: windowed slice of d.sessions around d.cursor so a
391391 // list taller than the terminal still scrolls. Caller sets
@@ -406,7 +406,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
406406 // Top indicator: how many rows are above the viewport.
407407 if d.viewTop > 0 {
408408 hidden := d.viewTop
409- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", hidden)))
409+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↑ %d more above", hidden)))
410410 }
411411 for i := d.viewTop; i < viewBot; i++ {
412412 s := d.sessions[i]
@@ -414,13 +414,13 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
414414 if i == d.cursor {
415415 lines = append(lines, th.PadHighlight(plain, width))
416416 } else {
417- lines = append(lines, th.FG256(th.Muted, plain))
417+ lines = append(lines, th.FGColor(th.Muted, plain))
418418 }
419419 }
420420 // Bottom indicator: how many rows are below the viewport.
421421 if viewBot < total {
422422 hidden := total - viewBot
423- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))
423+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))
424424 }
425425 lines = append(lines, frameRule(th, width))
426426 return lines
@@ -518,7 +518,7 @@ func (d *sessionDialog) loadingMessage(th tui.Theme) string {
518518 if d.loadingTotal > 0 {
519519 progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)
520520 }
521- return th.FG256(th.Spinner, frames[idx]) + " " + th.FG256(th.Muted, progress+" (esc cancel)")
521+ return th.FGColor(th.Spinner, frames[idx]) + " " + th.FGColor(th.Muted, progress+" (esc cancel)")
522522 }
523523
524524 func formatRelative(t time.Time) string {
@@ -357,27 +357,27 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
357 var lines []string357 var lines []string
358 lines = append(lines, frameHeader(th, "sessions", width))358 lines = append(lines, frameHeader(th, "sessions", width))
359 if d.loading {359 if d.loading {
360- lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th)))360+ lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th)))
361 if len(d.sessions) == 0 {361 if len(d.sessions) == 0 {
362- lines = append(lines, th.FG256(th.Muted, "session entries appear as they load"))362+ lines = append(lines, th.FGColor(th.Muted, "session entries appear as they load"))
363 lines = append(lines, frameRule(th, width))363 lines = append(lines, frameRule(th, width))
364 return lines364 return lines
365 }365 }
366 } else if len(d.sessions) == 0 {366 } else if len(d.sessions) == 0 {
367- lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory"))367+ lines = append(lines, th.FGColor(th.Muted, "no previous sessions for this directory"))
368- lines = append(lines, th.FG256(th.Muted, "press esc to close"))368+ lines = append(lines, th.FGColor(th.Muted, "press esc to close"))
369 lines = append(lines, frameRule(th, width))369 lines = append(lines, frameRule(th, width))
370 return lines370 return lines
371 }371 }
372 if d.renaming {372 if d.renaming {
373- lines = append(lines, th.FG256(th.Muted, "rename session (enter save, esc cancel):"))373+ lines = append(lines, th.FGColor(th.Muted, "rename session (enter save, esc cancel):"))
374 text := d.rename374 text := d.rename
375 if text == "" {375 if text == "" {
376- text = th.FG256(th.Muted, "session name")376+ text = th.FGColor(th.Muted, "session name")
377 } else {377 } else {
378- text = th.FG256(th.FG, text)378+ text = th.FGColor(th.FG, text)
379 }379 }
380- lines = append(lines, " "+th.FG256(th.Accent, "▌ ")+text)380+ lines = append(lines, " "+th.FGColor(th.Accent, "▌ ")+text)
381 lines = append(lines, frameRule(th, width))381 lines = append(lines, frameRule(th, width))
382 return lines382 return lines
383 }383 }
@@ -385,7 +385,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
385 if d.loading {385 if d.loading {
386 hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)"386 hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)"
387 }387 }
388- lines = append(lines, th.FG256(th.Muted, hint))388+ lines = append(lines, th.FGColor(th.Muted, hint))
389 389
390 // Viewport: windowed slice of d.sessions around d.cursor so a390 // Viewport: windowed slice of d.sessions around d.cursor so a
391 // list taller than the terminal still scrolls. Caller sets391 // list taller than the terminal still scrolls. Caller sets
@@ -406,7 +406,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
406 // Top indicator: how many rows are above the viewport.406 // Top indicator: how many rows are above the viewport.
407 if d.viewTop > 0 {407 if d.viewTop > 0 {
408 hidden := d.viewTop408 hidden := d.viewTop
409- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", hidden)))409+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↑ %d more above", hidden)))
410 }410 }
411 for i := d.viewTop; i < viewBot; i++ {411 for i := d.viewTop; i < viewBot; i++ {
412 s := d.sessions[i]412 s := d.sessions[i]
@@ -414,13 +414,13 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
414 if i == d.cursor {414 if i == d.cursor {
415 lines = append(lines, th.PadHighlight(plain, width))415 lines = append(lines, th.PadHighlight(plain, width))
416 } else {416 } else {
417- lines = append(lines, th.FG256(th.Muted, plain))417+ lines = append(lines, th.FGColor(th.Muted, plain))
418 }418 }
419 }419 }
420 // Bottom indicator: how many rows are below the viewport.420 // Bottom indicator: how many rows are below the viewport.
421 if viewBot < total {421 if viewBot < total {
422 hidden := total - viewBot422 hidden := total - viewBot
423- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))423+ lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))
424 }424 }
425 lines = append(lines, frameRule(th, width))425 lines = append(lines, frameRule(th, width))
426 return lines426 return lines
@@ -518,7 +518,7 @@ func (d *sessionDialog) loadingMessage(th tui.Theme) string {
518 if d.loadingTotal > 0 {518 if d.loadingTotal > 0 {
519 progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)519 progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)
520 }520 }
521- return th.FG256(th.Spinner, frames[idx]) + " " + th.FG256(th.Muted, progress+" (esc cancel)")521+ return th.FGColor(th.Spinner, frames[idx]) + " " + th.FGColor(th.Muted, progress+" (esc cancel)")
522 }522 }
523 523
524 func formatRelative(t time.Time) string {524 func formatRelative(t time.Time) string {
M packages/agent/modes/session_ops_dialog.go +2 -2
@@ -84,7 +84,7 @@ func (d *sessionOpsDialog) Render(th tui.Theme, width int) []string {
8484 }
8585 var lines []string
8686 lines = append(lines, frameHeader(th, "session", width))
87- lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
87+ lines = append(lines, th.FGColor(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
8888 for i, it := range d.items {
8989 text := " " + it.label
9090 if it.hint != "" {
@@ -93,7 +93,7 @@ func (d *sessionOpsDialog) Render(th tui.Theme, width int) []string {
9393 if i == d.cursor {
9494 lines = append(lines, th.PadHighlight(text, width))
9595 } else {
96- lines = append(lines, th.FG256(th.Muted, text))
96+ lines = append(lines, th.FGColor(th.Muted, text))
9797 }
9898 }
9999 lines = append(lines, frameRule(th, width))
@@ -84,7 +84,7 @@ func (d *sessionOpsDialog) Render(th tui.Theme, width int) []string {
84 }84 }
85 var lines []string85 var lines []string
86 lines = append(lines, frameHeader(th, "session", width))86 lines = append(lines, frameHeader(th, "session", width))
87- lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))87+ lines = append(lines, th.FGColor(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
88 for i, it := range d.items {88 for i, it := range d.items {
89 text := " " + it.label89 text := " " + it.label
90 if it.hint != "" {90 if it.hint != "" {
@@ -93,7 +93,7 @@ func (d *sessionOpsDialog) Render(th tui.Theme, width int) []string {
93 if i == d.cursor {93 if i == d.cursor {
94 lines = append(lines, th.PadHighlight(text, width))94 lines = append(lines, th.PadHighlight(text, width))
95 } else {95 } else {
96- lines = append(lines, th.FG256(th.Muted, text))96+ lines = append(lines, th.FGColor(th.Muted, text))
97 }97 }
98 }98 }
99 lines = append(lines, frameRule(th, width))99 lines = append(lines, frameRule(th, width))
M packages/agent/modes/session_tree_dialog.go +10 -10
@@ -292,13 +292,13 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
292292 lines := []string{line(frameHeader(th, "session tree", renderWidth))}
293293 if len(d.items) == 0 {
294294 lines = append(lines,
295- line(th.FG256(th.Muted, "no messages in this session yet")),
296- line(th.FG256(th.Muted, "press esc to close")),
295+ line(th.FGColor(th.Muted, "no messages in this session yet")),
296+ line(th.FGColor(th.Muted, "press esc to close")),
297297 line(frameRule(th, renderWidth)),
298298 )
299299 return lines
300300 }
301- lines = append(lines, line(th.FG256(th.Muted,
301+ lines = append(lines, line(th.FGColor(th.Muted,
302302 "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):")))
303303
304304 d.followCursor()
@@ -311,7 +311,7 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
311311 start = end
312312 }
313313 if start > 0 {
314- lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start))))
314+ lines = append(lines, line(th.FGColor(th.Muted, fmt.Sprintf(" ↑ %d more above", start))))
315315 }
316316 for i := start; i < end; i++ {
317317 it := d.items[i]
@@ -323,9 +323,9 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
323323 }
324324 }
325325 if end < len(d.items) {
326- lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end))))
326+ lines = append(lines, line(th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end))))
327327 }
328- lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items)))), line(frameRule(th, renderWidth)))
328+ lines = append(lines, line(th.FGColor(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items)))), line(frameRule(th, renderWidth)))
329329 return lines
330330 }
331331
@@ -821,13 +821,13 @@ func colorSessionTreeLine(th tui.Theme, line string) string {
821821 plain := strings.TrimSpace(line)
822822 switch {
823823 case strings.HasPrefix(plain, "you:"):
824- return th.FG256(th.FG, line)
824+ return th.FGColor(th.FG, line)
825825 case strings.HasPrefix(plain, "zot:"):
826- return th.FG256(th.Muted, line)
826+ return th.FGColor(th.Muted, line)
827827 case strings.HasPrefix(plain, "tool:"):
828- return th.FG256(th.ToolOut, line)
828+ return th.FGColor(th.ToolOut, line)
829829 default:
830- return th.FG256(th.Muted, line)
830+ return th.FGColor(th.Muted, line)
831831 }
832832 }
833833
@@ -292,13 +292,13 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
292 lines := []string{line(frameHeader(th, "session tree", renderWidth))}292 lines := []string{line(frameHeader(th, "session tree", renderWidth))}
293 if len(d.items) == 0 {293 if len(d.items) == 0 {
294 lines = append(lines,294 lines = append(lines,
295- line(th.FG256(th.Muted, "no messages in this session yet")),295+ line(th.FGColor(th.Muted, "no messages in this session yet")),
296- line(th.FG256(th.Muted, "press esc to close")),296+ line(th.FGColor(th.Muted, "press esc to close")),
297 line(frameRule(th, renderWidth)),297 line(frameRule(th, renderWidth)),
298 )298 )
299 return lines299 return lines
300 }300 }
301- lines = append(lines, line(th.FG256(th.Muted,301+ lines = append(lines, line(th.FGColor(th.Muted,
302 "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):")))302 "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):")))
303 303
304 d.followCursor()304 d.followCursor()
@@ -311,7 +311,7 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
311 start = end311 start = end
312 }312 }
313 if start > 0 {313 if start > 0 {
314- lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start))))314+ lines = append(lines, line(th.FGColor(th.Muted, fmt.Sprintf(" ↑ %d more above", start))))
315 }315 }
316 for i := start; i < end; i++ {316 for i := start; i < end; i++ {
317 it := d.items[i]317 it := d.items[i]
@@ -323,9 +323,9 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
323 }323 }
324 }324 }
325 if end < len(d.items) {325 if end < len(d.items) {
326- lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end))))326+ lines = append(lines, line(th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end))))
327 }327 }
328- lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items)))), line(frameRule(th, renderWidth)))328+ lines = append(lines, line(th.FGColor(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items)))), line(frameRule(th, renderWidth)))
329 return lines329 return lines
330 }330 }
331 331
@@ -821,13 +821,13 @@ func colorSessionTreeLine(th tui.Theme, line string) string {
821 plain := strings.TrimSpace(line)821 plain := strings.TrimSpace(line)
822 switch {822 switch {
823 case strings.HasPrefix(plain, "you:"):823 case strings.HasPrefix(plain, "you:"):
824- return th.FG256(th.FG, line)824+ return th.FGColor(th.FG, line)
825 case strings.HasPrefix(plain, "zot:"):825 case strings.HasPrefix(plain, "zot:"):
826- return th.FG256(th.Muted, line)826+ return th.FGColor(th.Muted, line)
827 case strings.HasPrefix(plain, "tool:"):827 case strings.HasPrefix(plain, "tool:"):
828- return th.FG256(th.ToolOut, line)828+ return th.FGColor(th.ToolOut, line)
829 default:829 default:
830- return th.FG256(th.Muted, line)830+ return th.FGColor(th.Muted, line)
831 }831 }
832 }832 }
833 833
M packages/agent/modes/settings_dialog.go +8 -8
@@ -238,9 +238,9 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
238238 var lines []string
239239 lines = append(lines, frameHeader(th, d.title, width))
240240 if len(d.parentItems) > 0 {
241- lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to go back:"))
241+ lines = append(lines, th.FGColor(th.Muted, "change with enter/space, esc to go back:"))
242242 } else {
243- lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:"))
243+ lines = append(lines, th.FGColor(th.Muted, "change with enter/space, esc to close:"))
244244 }
245245 for i, it := range d.items {
246246 box := "[ ]"
@@ -259,10 +259,10 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
259259 plain = " " + box + " " + it.label + ": " + it.options[it.choice].label
260260 }
261261 if it.hint != "" {
262- plain += " " + th.FG256(th.Muted, "("+it.hint+")")
262+ plain += " " + th.FGColor(th.Muted, "("+it.hint+")")
263263 }
264264 if it.disabled {
265- lines = append(lines, th.FG256(th.Muted, plain))
265+ lines = append(lines, th.FGColor(th.Muted, plain))
266266 } else if i == d.cursor {
267267 lines = append(lines, th.PadHighlight(plain, width))
268268 } else {
@@ -270,7 +270,7 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
270270 }
271271 if it.desc != "" {
272272 for _, desc := range wrapSettingDescription(it.desc, width, 6) {
273- lines = append(lines, th.FG256(th.Muted, desc))
273+ lines = append(lines, th.FGColor(th.Muted, desc))
274274 }
275275 }
276276 }
@@ -290,9 +290,9 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
290290 }
291291 lines := []string{frameHeader(th, title, width)}
292292 if it.desc != "" {
293- lines = append(lines, th.FG256(th.Muted, it.desc))
293+ lines = append(lines, th.FGColor(th.Muted, it.desc))
294294 }
295- lines = append(lines, th.FG256(th.Muted, "select with enter/space, esc to go back:"))
295+ lines = append(lines, th.FGColor(th.Muted, "select with enter/space, esc to go back:"))
296296 for idx, opt := range it.options {
297297 marker := " "
298298 if idx == it.choice {
@@ -306,7 +306,7 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
306306 }
307307 if opt.desc != "" {
308308 for _, desc := range wrapSettingDescription(opt.desc, width, 6) {
309- lines = append(lines, th.FG256(th.Muted, desc))
309+ lines = append(lines, th.FGColor(th.Muted, desc))
310310 }
311311 }
312312 }
@@ -238,9 +238,9 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
238 var lines []string238 var lines []string
239 lines = append(lines, frameHeader(th, d.title, width))239 lines = append(lines, frameHeader(th, d.title, width))
240 if len(d.parentItems) > 0 {240 if len(d.parentItems) > 0 {
241- lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to go back:"))241+ lines = append(lines, th.FGColor(th.Muted, "change with enter/space, esc to go back:"))
242 } else {242 } else {
243- lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:"))243+ lines = append(lines, th.FGColor(th.Muted, "change with enter/space, esc to close:"))
244 }244 }
245 for i, it := range d.items {245 for i, it := range d.items {
246 box := "[ ]"246 box := "[ ]"
@@ -259,10 +259,10 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
259 plain = " " + box + " " + it.label + ": " + it.options[it.choice].label259 plain = " " + box + " " + it.label + ": " + it.options[it.choice].label
260 }260 }
261 if it.hint != "" {261 if it.hint != "" {
262- plain += " " + th.FG256(th.Muted, "("+it.hint+")")262+ plain += " " + th.FGColor(th.Muted, "("+it.hint+")")
263 }263 }
264 if it.disabled {264 if it.disabled {
265- lines = append(lines, th.FG256(th.Muted, plain))265+ lines = append(lines, th.FGColor(th.Muted, plain))
266 } else if i == d.cursor {266 } else if i == d.cursor {
267 lines = append(lines, th.PadHighlight(plain, width))267 lines = append(lines, th.PadHighlight(plain, width))
268 } else {268 } else {
@@ -270,7 +270,7 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
270 }270 }
271 if it.desc != "" {271 if it.desc != "" {
272 for _, desc := range wrapSettingDescription(it.desc, width, 6) {272 for _, desc := range wrapSettingDescription(it.desc, width, 6) {
273- lines = append(lines, th.FG256(th.Muted, desc))273+ lines = append(lines, th.FGColor(th.Muted, desc))
274 }274 }
275 }275 }
276 }276 }
@@ -290,9 +290,9 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
290 }290 }
291 lines := []string{frameHeader(th, title, width)}291 lines := []string{frameHeader(th, title, width)}
292 if it.desc != "" {292 if it.desc != "" {
293- lines = append(lines, th.FG256(th.Muted, it.desc))293+ lines = append(lines, th.FGColor(th.Muted, it.desc))
294 }294 }
295- lines = append(lines, th.FG256(th.Muted, "select with enter/space, esc to go back:"))295+ lines = append(lines, th.FGColor(th.Muted, "select with enter/space, esc to go back:"))
296 for idx, opt := range it.options {296 for idx, opt := range it.options {
297 marker := " "297 marker := " "
298 if idx == it.choice {298 if idx == it.choice {
@@ -306,7 +306,7 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
306 }306 }
307 if opt.desc != "" {307 if opt.desc != "" {
308 for _, desc := range wrapSettingDescription(opt.desc, width, 6) {308 for _, desc := range wrapSettingDescription(opt.desc, width, 6) {
309- lines = append(lines, th.FG256(th.Muted, desc))309+ lines = append(lines, th.FGColor(th.Muted, desc))
310 }310 }
311 }311 }
312 }312 }
M packages/agent/modes/skills_dialog.go +9 -9
@@ -99,8 +99,8 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
9999
100100 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
101101 if len(d.skills) == 0 {
102- out = append(out, " "+th.FG256(th.Muted, "no user skills loaded"))
103- out = append(out, " "+th.FG256(th.Muted, "add SKILL.md under $ZOT_HOME/skills, .zot/skills, .claude/skills, or .agents/skills"))
102+ out = append(out, " "+th.FGColor(th.Muted, "no user skills loaded"))
103+ out = append(out, " "+th.FGColor(th.Muted, "add SKILL.md under $ZOT_HOME/skills, .zot/skills, .claude/skills, or .agents/skills"))
104104 out = append(out, frameRule(th, width))
105105 return out
106106 }
@@ -108,7 +108,7 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
108108 const maxRows = 12
109109 start, end := visibleWindow(d.cursor, len(d.skills), maxRows)
110110 if start > 0 {
111- out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2191 %d more above", start)))
111+ out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("\u2191 %d more above", start)))
112112 }
113113 for i := start; i < end; i++ {
114114 s := d.skills[i]
@@ -116,13 +116,13 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
116116 if i == d.cursor {
117117 out = append(out, th.PadHighlight(" "+row, width))
118118 } else {
119- out = append(out, " "+th.FG256(th.Muted, row))
119+ out = append(out, " "+th.FGColor(th.Muted, row))
120120 }
121121 }
122122 if end < len(d.skills) {
123- out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more below", len(d.skills)-end)))
123+ out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("\u2193 %d more below", len(d.skills)-end)))
124124 }
125- out = append(out, " "+th.FG256(th.Muted, "run with /skill:<name> [request]"))
125+ out = append(out, " "+th.FGColor(th.Muted, "run with /skill:<name> [request]"))
126126 out = append(out, frameRule(th, width))
127127 return out
128128 }
@@ -131,8 +131,8 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
131131 s := d.viewing
132132 out := []string{
133133 frameHeader(th, "skill: "+s.Name+" (esc / enter to go back)", width),
134- " " + th.FG256(th.Muted, s.Description),
135- " " + th.FG256(th.Muted, "source: "+s.Source+" ("+s.Path+")"),
134+ " " + th.FGColor(th.Muted, s.Description),
135+ " " + th.FGColor(th.Muted, "source: "+s.Source+" ("+s.Path+")"),
136136 "",
137137 }
138138
@@ -159,7 +159,7 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
159159 out = append(out, " "+line)
160160 }
161161 if end < len(bodyLines) {
162- out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))
162+ out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))
163163 }
164164 out = append(out, frameRule(th, width))
165165 return out
@@ -99,8 +99,8 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
99 99
100 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}100 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
101 if len(d.skills) == 0 {101 if len(d.skills) == 0 {
102- out = append(out, " "+th.FG256(th.Muted, "no user skills loaded"))102+ out = append(out, " "+th.FGColor(th.Muted, "no user skills loaded"))
103- out = append(out, " "+th.FG256(th.Muted, "add SKILL.md under $ZOT_HOME/skills, .zot/skills, .claude/skills, or .agents/skills"))103+ out = append(out, " "+th.FGColor(th.Muted, "add SKILL.md under $ZOT_HOME/skills, .zot/skills, .claude/skills, or .agents/skills"))
104 out = append(out, frameRule(th, width))104 out = append(out, frameRule(th, width))
105 return out105 return out
106 }106 }
@@ -108,7 +108,7 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
108 const maxRows = 12108 const maxRows = 12
109 start, end := visibleWindow(d.cursor, len(d.skills), maxRows)109 start, end := visibleWindow(d.cursor, len(d.skills), maxRows)
110 if start > 0 {110 if start > 0 {
111- out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2191 %d more above", start)))111+ out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("\u2191 %d more above", start)))
112 }112 }
113 for i := start; i < end; i++ {113 for i := start; i < end; i++ {
114 s := d.skills[i]114 s := d.skills[i]
@@ -116,13 +116,13 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
116 if i == d.cursor {116 if i == d.cursor {
117 out = append(out, th.PadHighlight(" "+row, width))117 out = append(out, th.PadHighlight(" "+row, width))
118 } else {118 } else {
119- out = append(out, " "+th.FG256(th.Muted, row))119+ out = append(out, " "+th.FGColor(th.Muted, row))
120 }120 }
121 }121 }
122 if end < len(d.skills) {122 if end < len(d.skills) {
123- out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more below", len(d.skills)-end)))123+ out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("\u2193 %d more below", len(d.skills)-end)))
124 }124 }
125- out = append(out, " "+th.FG256(th.Muted, "run with /skill:<name> [request]"))125+ out = append(out, " "+th.FGColor(th.Muted, "run with /skill:<name> [request]"))
126 out = append(out, frameRule(th, width))126 out = append(out, frameRule(th, width))
127 return out127 return out
128 }128 }
@@ -131,8 +131,8 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
131 s := d.viewing131 s := d.viewing
132 out := []string{132 out := []string{
133 frameHeader(th, "skill: "+s.Name+" (esc / enter to go back)", width),133 frameHeader(th, "skill: "+s.Name+" (esc / enter to go back)", width),
134- " " + th.FG256(th.Muted, s.Description),134+ " " + th.FGColor(th.Muted, s.Description),
135- " " + th.FG256(th.Muted, "source: "+s.Source+" ("+s.Path+")"),135+ " " + th.FGColor(th.Muted, "source: "+s.Source+" ("+s.Path+")"),
136 "",136 "",
137 }137 }
138 138
@@ -159,7 +159,7 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
159 out = append(out, " "+line)159 out = append(out, " "+line)
160 }160 }
161 if end < len(bodyLines) {161 if end < len(bodyLines) {
162- out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))162+ out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))
163 }163 }
164 out = append(out, frameRule(th, width))164 out = append(out, frameRule(th, width))
165 return out165 return out
M packages/agent/modes/slash_suggest.go +3 -3
@@ -492,7 +492,7 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
492492 if lw := runewidth.StringWidth(label); lw < width {
493493 rule = label + strings.Repeat("─", width-lw)
494494 }
495- lines = append(lines, th.FG256(th.Muted, rule))
495+ lines = append(lines, th.FGColor(th.Muted, rule))
496496 lines = append(lines, "")
497497 continue
498498 }
@@ -504,13 +504,13 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
504504 if i == s.cursor {
505505 lines = append(lines, th.PadHighlight(plain, width))
506506 } else {
507- lines = append(lines, th.FG256(th.Muted, plain))
507+ lines = append(lines, th.FGColor(th.Muted, plain))
508508 }
509509 }
510510 // Blank row before the hint visually detaches it from the
511511 // command list and groups it with its trailing blank.
512512 lines = append(lines, "")
513- lines = append(lines, th.FG256(th.Muted, " ↑/↓ navigate - tab complete - enter run"))
513+ lines = append(lines, th.FGColor(th.Muted, " ↑/↓ navigate - tab complete - enter run"))
514514 // Blank row after the hint separates the popup from the status
515515 // bar / editor below it.
516516 lines = append(lines, "")
@@ -492,7 +492,7 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
492 if lw := runewidth.StringWidth(label); lw < width {492 if lw := runewidth.StringWidth(label); lw < width {
493 rule = label + strings.Repeat("─", width-lw)493 rule = label + strings.Repeat("─", width-lw)
494 }494 }
495- lines = append(lines, th.FG256(th.Muted, rule))495+ lines = append(lines, th.FGColor(th.Muted, rule))
496 lines = append(lines, "")496 lines = append(lines, "")
497 continue497 continue
498 }498 }
@@ -504,13 +504,13 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
504 if i == s.cursor {504 if i == s.cursor {
505 lines = append(lines, th.PadHighlight(plain, width))505 lines = append(lines, th.PadHighlight(plain, width))
506 } else {506 } else {
507- lines = append(lines, th.FG256(th.Muted, plain))507+ lines = append(lines, th.FGColor(th.Muted, plain))
508 }508 }
509 }509 }
510 // Blank row before the hint visually detaches it from the510 // Blank row before the hint visually detaches it from the
511 // command list and groups it with its trailing blank.511 // command list and groups it with its trailing blank.
512 lines = append(lines, "")512 lines = append(lines, "")
513- lines = append(lines, th.FG256(th.Muted, " ↑/↓ navigate - tab complete - enter run"))513+ lines = append(lines, th.FGColor(th.Muted, " ↑/↓ navigate - tab complete - enter run"))
514 // Blank row after the hint separates the popup from the status514 // Blank row after the hint separates the popup from the status
515 // bar / editor below it.515 // bar / editor below it.
516 lines = append(lines, "")516 lines = append(lines, "")
M packages/agent/modes/subagents_dialog.go +24 -24
@@ -1018,7 +1018,7 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
10181018 d.openModelPicker()
10191019 }
10201020 out = d.modelPicker.Render(th, width)
1021- out = append(out, " "+th.FG256(th.Muted, "select model for next spawn, esc to cancel"))
1021+ out = append(out, " "+th.FGColor(th.Muted, "select model for next spawn, esc to cancel"))
10221022 return out
10231023 }
10241024 if d.spawning {
@@ -1036,7 +1036,7 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
10361036 if d.pendingProvider != "" {
10371037 label += " (" + d.pendingProvider + ")"
10381038 }
1039- out = append(out, " "+th.FG256(th.Accent, label))
1039+ out = append(out, " "+th.FGColor(th.Accent, label))
10401040 }
10411041 if d.fileSuggest != nil {
10421042 d.fileSuggest.SetCWD(d.cwd)
@@ -1056,26 +1056,26 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
10561056 // Matching blank row below before the hint, mirroring the
10571057 // main input's editor breathing room.
10581058 out = append(out, "")
1059- out = append(out, " "+th.FG256(th.Muted, "enter spawn, /model pick model, @ file/dir picker, paste/drop paths become [file:] / [dir:] chips, esc cancel"))
1059+ out = append(out, " "+th.FGColor(th.Muted, "enter spawn, /model pick model, @ file/dir picker, paste/drop paths become [file:] / [dir:] chips, esc cancel"))
10601060 out = append(out, frameRule(th, width))
10611061 return out
10621062 }
10631063 if len(d.rows) == 0 {
1064- out = append(out, " "+th.FG256(th.Muted, "no agents — press n to spawn one (or /subagents new <task>)"))
1064+ out = append(out, " "+th.FGColor(th.Muted, "no agents — press n to spawn one (or /subagents new <task>)"))
10651065 out = append(out, frameRule(th, width))
10661066 return out
10671067 }
10681068
10691069 // Column header for readability.
10701070 header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")
1071- out = append(out, th.FG256(th.Muted, header))
1071+ out = append(out, th.FGColor(th.Muted, header))
10721072
10731073 for i, r := range d.rows {
10741074 row := formatSupervisorRow(r, width-2)
10751075 if i == d.cursor {
10761076 out = append(out, th.PadHighlight(" "+row, width))
10771077 } else {
1078- out = append(out, " "+th.FG256(th.FG, row))
1078+ out = append(out, " "+th.FGColor(th.FG, row))
10791079 }
10801080 }
10811081 out = append(out, frameRule(th, width))
@@ -1090,29 +1090,29 @@ func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string {
10901090 }
10911091 header := []string{
10921092 frameHeader(th, "subagent: "+a.ID+" (type to send, esc back)", width),
1093- " " + th.FG256(th.Muted, "task: "+a.Task),
1094- " " + th.FG256(th.Muted, "dir: "+a.Dir),
1095- " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),
1093+ " " + th.FGColor(th.Muted, "task: "+a.Task),
1094+ " " + th.FGColor(th.Muted, "dir: "+a.Dir),
1095+ " " + th.FGColor(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),
10961096 }
10971097 if a.Model != "" {
10981098 modelLine := "model: " + a.Model
10991099 if a.Provider != "" {
11001100 modelLine += " (" + a.Provider + ")"
11011101 }
1102- header = append(header, " "+th.FG256(th.Muted, modelLine))
1102+ header = append(header, " "+th.FGColor(th.Muted, modelLine))
11031103 }
11041104 if a.Subagent != "" {
1105- header = append(header, " "+th.FG256(th.Muted, "agent: "+a.Subagent))
1105+ header = append(header, " "+th.FGColor(th.Muted, "agent: "+a.Subagent))
11061106 }
11071107 if a.Reasoning != "" {
1108- header = append(header, " "+th.FG256(th.Muted, "reasoning: "+a.Reasoning))
1108+ header = append(header, " "+th.FGColor(th.Muted, "reasoning: "+a.Reasoning))
11091109 }
11101110 if a.Err != "" {
1111- header = append(header, " "+th.FG256(th.Muted, "error: "+a.Err))
1111+ header = append(header, " "+th.FGColor(th.Muted, "error: "+a.Err))
11121112 }
11131113 header = append(header,
11141114 "",
1115- " "+th.FG256(th.Muted, "── transcript ──"),
1115+ " "+th.FGColor(th.Muted, "── transcript ──"),
11161116 "",
11171117 )
11181118
@@ -1123,7 +1123,7 @@ func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string {
11231123 raw = strings.Split(a.Tail, "\n")
11241124 }
11251125 if len(raw) == 0 {
1126- header = append(header, " "+th.FG256(th.Muted, "(no transcript yet)"))
1126+ header = append(header, " "+th.FGColor(th.Muted, "(no transcript yet)"))
11271127 header = d.appendTranscriptEditor(header, th, width, a)
11281128 header = append(header, frameRule(th, width))
11291129 return header
@@ -1167,7 +1167,7 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid
11671167 }
11681168 if a.Status != subagents.StatusRunning || d.send == nil {
11691169 out = append(out, "")
1170- out = append(out, " "+th.FG256(th.Muted, promptDisabledHint(a.Status)))
1170+ out = append(out, " "+th.FGColor(th.Muted, promptDisabledHint(a.Status)))
11711171 return out
11721172 }
11731173 // Lazy-init the editor here too so the very first render of the
@@ -1201,9 +1201,9 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid
12011201 }
12021202 out = append(out, "")
12031203 prefix := fmt.Sprintf("%s %s, %s",
1204- th.FG256(th.Assistant, d.transcriptSpin.Frame()),
1205- th.FG256(th.Assistant, a.Activity),
1206- th.FG256(th.Muted, d.transcriptSpin.Elapsed().String()),
1204+ th.FGColor(th.Assistant, d.transcriptSpin.Frame()),
1205+ th.FGColor(th.Assistant, a.Activity),
1206+ th.FGColor(th.Muted, d.transcriptSpin.Elapsed().String()),
12071207 )
12081208 out = append(out, " "+prefix)
12091209 } else {
@@ -1218,7 +1218,7 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid
12181218 out = append(out, " "+l)
12191219 }
12201220 out = append(out, "")
1221- out = append(out, " "+th.FG256(th.Muted, "enter send, @ file/dir picker, esc back"))
1221+ out = append(out, " "+th.FGColor(th.Muted, "enter send, @ file/dir picker, esc back"))
12221222 return out
12231223 }
12241224
@@ -1325,11 +1325,11 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
13251325 out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...)
13261326 case kindStderr:
13271327 for _, line := range b.body {
1328- out = append(out, wrapDialogTextRows(th.FG256(th.Muted, "stderr "+line), width)...)
1328+ out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "stderr "+line), width)...)
13291329 }
13301330 case kindError:
13311331 for _, line := range b.body {
1332- out = append(out, wrapDialogTextRows(th.FG256(th.Error, "✖ "+line), width)...)
1332+ out = append(out, wrapDialogTextRows(th.FGColor(th.Error, "✖ "+line), width)...)
13331333 }
13341334 }
13351335 }
@@ -1353,7 +1353,7 @@ func (d *subagentsDialog) renderPromptEditor(th tui.Theme, width int, out []stri
13531353 if target == "" {
13541354 target = "<unknown>"
13551355 }
1356- out = append(out, " "+th.FG256(th.Muted, "send to "+target+":"))
1356+ out = append(out, " "+th.FGColor(th.Muted, "send to "+target+":"))
13571357 if d.promptSuggest != nil {
13581358 d.promptSuggest.SetCWD(d.cwd)
13591359 if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 {
@@ -1370,7 +1370,7 @@ func (d *subagentsDialog) renderPromptEditor(th tui.Theme, width int, out []stri
13701370 }
13711371 // Matching blank row below before the hint.
13721372 out = append(out, "")
1373- out = append(out, " "+th.FG256(th.Muted, "enter send, @ file/dir picker, esc cancel"))
1373+ out = append(out, " "+th.FGColor(th.Muted, "enter send, @ file/dir picker, esc cancel"))
13741374 out = append(out, frameRule(th, width))
13751375 return out
13761376 }
@@ -1018,7 +1018,7 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
1018 d.openModelPicker()1018 d.openModelPicker()
1019 }1019 }
1020 out = d.modelPicker.Render(th, width)1020 out = d.modelPicker.Render(th, width)
1021- out = append(out, " "+th.FG256(th.Muted, "select model for next spawn, esc to cancel"))1021+ out = append(out, " "+th.FGColor(th.Muted, "select model for next spawn, esc to cancel"))
1022 return out1022 return out
1023 }1023 }
1024 if d.spawning {1024 if d.spawning {
@@ -1036,7 +1036,7 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
1036 if d.pendingProvider != "" {1036 if d.pendingProvider != "" {
1037 label += " (" + d.pendingProvider + ")"1037 label += " (" + d.pendingProvider + ")"
1038 }1038 }
1039- out = append(out, " "+th.FG256(th.Accent, label))1039+ out = append(out, " "+th.FGColor(th.Accent, label))
1040 }1040 }
1041 if d.fileSuggest != nil {1041 if d.fileSuggest != nil {
1042 d.fileSuggest.SetCWD(d.cwd)1042 d.fileSuggest.SetCWD(d.cwd)
@@ -1056,26 +1056,26 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
1056 // Matching blank row below before the hint, mirroring the1056 // Matching blank row below before the hint, mirroring the
1057 // main input's editor breathing room.1057 // main input's editor breathing room.
1058 out = append(out, "")1058 out = append(out, "")
1059- out = append(out, " "+th.FG256(th.Muted, "enter spawn, /model pick model, @ file/dir picker, paste/drop paths become [file:] / [dir:] chips, esc cancel"))1059+ out = append(out, " "+th.FGColor(th.Muted, "enter spawn, /model pick model, @ file/dir picker, paste/drop paths become [file:] / [dir:] chips, esc cancel"))
1060 out = append(out, frameRule(th, width))1060 out = append(out, frameRule(th, width))
1061 return out1061 return out
1062 }1062 }
1063 if len(d.rows) == 0 {1063 if len(d.rows) == 0 {
1064- out = append(out, " "+th.FG256(th.Muted, "no agents — press n to spawn one (or /subagents new <task>)"))1064+ out = append(out, " "+th.FGColor(th.Muted, "no agents — press n to spawn one (or /subagents new <task>)"))
1065 out = append(out, frameRule(th, width))1065 out = append(out, frameRule(th, width))
1066 return out1066 return out
1067 }1067 }
1068 1068
1069 // Column header for readability.1069 // Column header for readability.
1070 header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")1070 header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")
1071- out = append(out, th.FG256(th.Muted, header))1071+ out = append(out, th.FGColor(th.Muted, header))
1072 1072
1073 for i, r := range d.rows {1073 for i, r := range d.rows {
1074 row := formatSupervisorRow(r, width-2)1074 row := formatSupervisorRow(r, width-2)
1075 if i == d.cursor {1075 if i == d.cursor {
1076 out = append(out, th.PadHighlight(" "+row, width))1076 out = append(out, th.PadHighlight(" "+row, width))
1077 } else {1077 } else {
1078- out = append(out, " "+th.FG256(th.FG, row))1078+ out = append(out, " "+th.FGColor(th.FG, row))
1079 }1079 }
1080 }1080 }
1081 out = append(out, frameRule(th, width))1081 out = append(out, frameRule(th, width))
@@ -1090,29 +1090,29 @@ func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string {
1090 }1090 }
1091 header := []string{1091 header := []string{
1092 frameHeader(th, "subagent: "+a.ID+" (type to send, esc back)", width),1092 frameHeader(th, "subagent: "+a.ID+" (type to send, esc back)", width),
1093- " " + th.FG256(th.Muted, "task: "+a.Task),1093+ " " + th.FGColor(th.Muted, "task: "+a.Task),
1094- " " + th.FG256(th.Muted, "dir: "+a.Dir),1094+ " " + th.FGColor(th.Muted, "dir: "+a.Dir),
1095- " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),1095+ " " + th.FGColor(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),
1096 }1096 }
1097 if a.Model != "" {1097 if a.Model != "" {
1098 modelLine := "model: " + a.Model1098 modelLine := "model: " + a.Model
1099 if a.Provider != "" {1099 if a.Provider != "" {
1100 modelLine += " (" + a.Provider + ")"1100 modelLine += " (" + a.Provider + ")"
1101 }1101 }
1102- header = append(header, " "+th.FG256(th.Muted, modelLine))1102+ header = append(header, " "+th.FGColor(th.Muted, modelLine))
1103 }1103 }
1104 if a.Subagent != "" {1104 if a.Subagent != "" {
1105- header = append(header, " "+th.FG256(th.Muted, "agent: "+a.Subagent))1105+ header = append(header, " "+th.FGColor(th.Muted, "agent: "+a.Subagent))
1106 }1106 }
1107 if a.Reasoning != "" {1107 if a.Reasoning != "" {
1108- header = append(header, " "+th.FG256(th.Muted, "reasoning: "+a.Reasoning))1108+ header = append(header, " "+th.FGColor(th.Muted, "reasoning: "+a.Reasoning))
1109 }1109 }
1110 if a.Err != "" {1110 if a.Err != "" {
1111- header = append(header, " "+th.FG256(th.Muted, "error: "+a.Err))1111+ header = append(header, " "+th.FGColor(th.Muted, "error: "+a.Err))
1112 }1112 }
1113 header = append(header,1113 header = append(header,
1114 "",1114 "",
1115- " "+th.FG256(th.Muted, "── transcript ──"),1115+ " "+th.FGColor(th.Muted, "── transcript ──"),
1116 "",1116 "",
1117 )1117 )
1118 1118
@@ -1123,7 +1123,7 @@ func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string {
1123 raw = strings.Split(a.Tail, "\n")1123 raw = strings.Split(a.Tail, "\n")
1124 }1124 }
1125 if len(raw) == 0 {1125 if len(raw) == 0 {
1126- header = append(header, " "+th.FG256(th.Muted, "(no transcript yet)"))1126+ header = append(header, " "+th.FGColor(th.Muted, "(no transcript yet)"))
1127 header = d.appendTranscriptEditor(header, th, width, a)1127 header = d.appendTranscriptEditor(header, th, width, a)
1128 header = append(header, frameRule(th, width))1128 header = append(header, frameRule(th, width))
1129 return header1129 return header
@@ -1167,7 +1167,7 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid
1167 }1167 }
1168 if a.Status != subagents.StatusRunning || d.send == nil {1168 if a.Status != subagents.StatusRunning || d.send == nil {
1169 out = append(out, "")1169 out = append(out, "")
1170- out = append(out, " "+th.FG256(th.Muted, promptDisabledHint(a.Status)))1170+ out = append(out, " "+th.FGColor(th.Muted, promptDisabledHint(a.Status)))
1171 return out1171 return out
1172 }1172 }
1173 // Lazy-init the editor here too so the very first render of the1173 // Lazy-init the editor here too so the very first render of the
@@ -1201,9 +1201,9 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid
1201 }1201 }
1202 out = append(out, "")1202 out = append(out, "")
1203 prefix := fmt.Sprintf("%s %s, %s",1203 prefix := fmt.Sprintf("%s %s, %s",
1204- th.FG256(th.Assistant, d.transcriptSpin.Frame()),1204+ th.FGColor(th.Assistant, d.transcriptSpin.Frame()),
1205- th.FG256(th.Assistant, a.Activity),1205+ th.FGColor(th.Assistant, a.Activity),
1206- th.FG256(th.Muted, d.transcriptSpin.Elapsed().String()),1206+ th.FGColor(th.Muted, d.transcriptSpin.Elapsed().String()),
1207 )1207 )
1208 out = append(out, " "+prefix)1208 out = append(out, " "+prefix)
1209 } else {1209 } else {
@@ -1218,7 +1218,7 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid
1218 out = append(out, " "+l)1218 out = append(out, " "+l)
1219 }1219 }
1220 out = append(out, "")1220 out = append(out, "")
1221- out = append(out, " "+th.FG256(th.Muted, "enter send, @ file/dir picker, esc back"))1221+ out = append(out, " "+th.FGColor(th.Muted, "enter send, @ file/dir picker, esc back"))
1222 return out1222 return out
1223 }1223 }
1224 1224
@@ -1325,11 +1325,11 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
1325 out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...)1325 out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...)
1326 case kindStderr:1326 case kindStderr:
1327 for _, line := range b.body {1327 for _, line := range b.body {
1328- out = append(out, wrapDialogTextRows(th.FG256(th.Muted, "stderr "+line), width)...)1328+ out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "stderr "+line), width)...)
1329 }1329 }
1330 case kindError:1330 case kindError:
1331 for _, line := range b.body {1331 for _, line := range b.body {
1332- out = append(out, wrapDialogTextRows(th.FG256(th.Error, "✖ "+line), width)...)1332+ out = append(out, wrapDialogTextRows(th.FGColor(th.Error, "✖ "+line), width)...)
1333 }1333 }
1334 }1334 }
1335 }1335 }
@@ -1353,7 +1353,7 @@ func (d *subagentsDialog) renderPromptEditor(th tui.Theme, width int, out []stri
1353 if target == "" {1353 if target == "" {
1354 target = "<unknown>"1354 target = "<unknown>"
1355 }1355 }
1356- out = append(out, " "+th.FG256(th.Muted, "send to "+target+":"))1356+ out = append(out, " "+th.FGColor(th.Muted, "send to "+target+":"))
1357 if d.promptSuggest != nil {1357 if d.promptSuggest != nil {
1358 d.promptSuggest.SetCWD(d.cwd)1358 d.promptSuggest.SetCWD(d.cwd)
1359 if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 {1359 if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 {
@@ -1370,7 +1370,7 @@ func (d *subagentsDialog) renderPromptEditor(th tui.Theme, width int, out []stri
1370 }1370 }
1371 // Matching blank row below before the hint.1371 // Matching blank row below before the hint.
1372 out = append(out, "")1372 out = append(out, "")
1373- out = append(out, " "+th.FG256(th.Muted, "enter send, @ file/dir picker, esc cancel"))1373+ out = append(out, " "+th.FGColor(th.Muted, "enter send, @ file/dir picker, esc cancel"))
1374 out = append(out, frameRule(th, width))1374 out = append(out, frameRule(th, width))
1375 return out1375 return out
1376 }1376 }
M packages/agent/modes/telegram_dialog.go +3 -3
@@ -83,16 +83,16 @@ func (d *telegramDialog) Render(th tui.Theme, width int) []string {
8383 }
8484 var lines []string
8585 lines = append(lines, frameHeader(th, "telegram", width))
86- lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
86+ lines = append(lines, th.FGColor(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
8787 for i, it := range d.items {
8888 plain := " " + it.label
8989 if it.hint != "" {
90- plain += " " + th.FG256(th.Muted, "("+it.hint+")")
90+ plain += " " + th.FGColor(th.Muted, "("+it.hint+")")
9191 }
9292 if i == d.cursor {
9393 lines = append(lines, th.PadHighlight(plain, width))
9494 } else {
95- lines = append(lines, th.FG256(th.Muted, plain))
95+ lines = append(lines, th.FGColor(th.Muted, plain))
9696 }
9797 }
9898 lines = append(lines, frameRule(th, width))
@@ -83,16 +83,16 @@ func (d *telegramDialog) Render(th tui.Theme, width int) []string {
83 }83 }
84 var lines []string84 var lines []string
85 lines = append(lines, frameHeader(th, "telegram", width))85 lines = append(lines, frameHeader(th, "telegram", width))
86- lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))86+ lines = append(lines, th.FGColor(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
87 for i, it := range d.items {87 for i, it := range d.items {
88 plain := " " + it.label88 plain := " " + it.label
89 if it.hint != "" {89 if it.hint != "" {
90- plain += " " + th.FG256(th.Muted, "("+it.hint+")")90+ plain += " " + th.FGColor(th.Muted, "("+it.hint+")")
91 }91 }
92 if i == d.cursor {92 if i == d.cursor {
93 lines = append(lines, th.PadHighlight(plain, width))93 lines = append(lines, th.PadHighlight(plain, width))
94 } else {94 } else {
95- lines = append(lines, th.FG256(th.Muted, plain))95+ lines = append(lines, th.FGColor(th.Muted, plain))
96 }96 }
97 }97 }
98 lines = append(lines, frameRule(th, width))98 lines = append(lines, frameRule(th, width))
M packages/agent/modes/theme_setting_test.go +2 -2
@@ -43,8 +43,8 @@ func TestApplyingInheritedThemeUsesCapturedTerminalProfile(t *testing.T) {
4343 if !interactive.cfg.Theme.Inherited {
4444 t.Fatal("applying inherited theme did not switch the live theme")
4545 }
46- if interactive.cfg.Theme.ColorMode != tui.ColorModeTrueColor {
47- t.Fatalf("live inherited color mode = %v, want truecolor", interactive.cfg.Theme.ColorMode)
46+ if !interactive.cfg.Theme.Terminal.TrueColor {
47+ t.Fatal("live inherited color mode = 256, want truecolor")
4848 }
4949 if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground {
5050 t.Fatal("live inherited theme lost the captured terminal profile")
@@ -43,8 +43,8 @@ func TestApplyingInheritedThemeUsesCapturedTerminalProfile(t *testing.T) {
43 if !interactive.cfg.Theme.Inherited {43 if !interactive.cfg.Theme.Inherited {
44 t.Fatal("applying inherited theme did not switch the live theme")44 t.Fatal("applying inherited theme did not switch the live theme")
45 }45 }
46- if interactive.cfg.Theme.ColorMode != tui.ColorModeTrueColor {46+ if !interactive.cfg.Theme.Terminal.TrueColor {
47- t.Fatalf("live inherited color mode = %v, want truecolor", interactive.cfg.Theme.ColorMode)47+ t.Fatal("live inherited color mode = 256, want truecolor")
48 }48 }
49 if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground {49 if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground {
50 t.Fatal("live inherited theme lost the captured terminal profile")50 t.Fatal("live inherited theme lost the captured terminal profile")
M packages/agent/modes/update_banner.go +3 -3
@@ -33,12 +33,12 @@ func renderUpdateBanner(th tui.Theme, info UpdateInfo, width int) []string {
3333 out = append(out, "")
3434
3535 title := fmt.Sprintf("zot %s is available (you're on %s).", info.Latest, info.Current)
36- out = append(out, " "+th.FG256(color, tui.Bold(title)))
36+ out = append(out, " "+th.FGColor(color, tui.Bold(title)))
3737 out = append(out, "")
38- out = append(out, " "+th.FG256(th.Muted, "run: ")+th.FG256(color, "zot update"))
38+ out = append(out, " "+th.FGColor(th.Muted, "run: ")+th.FGColor(color, "zot update"))
3939
4040 if info.URL != "" {
41- out = append(out, " "+th.FG256(th.Muted, "changelog: ")+th.FG256(color, info.URL))
41+ out = append(out, " "+th.FGColor(th.Muted, "changelog: ")+th.FGColor(color, info.URL))
4242 }
4343
4444 out = append(out, "")
@@ -33,12 +33,12 @@ func renderUpdateBanner(th tui.Theme, info UpdateInfo, width int) []string {
33 out = append(out, "")33 out = append(out, "")
34 34
35 title := fmt.Sprintf("zot %s is available (you're on %s).", info.Latest, info.Current)35 title := fmt.Sprintf("zot %s is available (you're on %s).", info.Latest, info.Current)
36- out = append(out, " "+th.FG256(color, tui.Bold(title)))36+ out = append(out, " "+th.FGColor(color, tui.Bold(title)))
37 out = append(out, "")37 out = append(out, "")
38- out = append(out, " "+th.FG256(th.Muted, "run: ")+th.FG256(color, "zot update"))38+ out = append(out, " "+th.FGColor(th.Muted, "run: ")+th.FGColor(color, "zot update"))
39 39
40 if info.URL != "" {40 if info.URL != "" {
41- out = append(out, " "+th.FG256(th.Muted, "changelog: ")+th.FG256(color, info.URL))41+ out = append(out, " "+th.FGColor(th.Muted, "changelog: ")+th.FGColor(color, info.URL))
42 }42 }
43 43
44 out = append(out, "")44 out = append(out, "")
M packages/agent/modes/welcome.go +2 -2
@@ -14,10 +14,10 @@ func welcomeBanner(th tui.Theme, version string, showVersion bool) []string {
1414 if showVersion && version != "" {
1515 text = "zot (" + version + "). yet another coding agent harness."
1616 }
17- headline := th.AccentBar(th.Assistant) + th.FG256(th.Assistant, tui.Bold(text))
17+ headline := th.AccentBar(th.Assistant) + th.FGColor(th.Assistant, tui.Bold(text))
1818 return []string{
1919 headline,
20- th.FG256(th.Muted, " ask anything, or type /help to see commands."),
20+ th.FGColor(th.Muted, " ask anything, or type /help to see commands."),
2121 "",
2222 }
2323 }
@@ -14,10 +14,10 @@ func welcomeBanner(th tui.Theme, version string, showVersion bool) []string {
14 if showVersion && version != "" {14 if showVersion && version != "" {
15 text = "zot (" + version + "). yet another coding agent harness."15 text = "zot (" + version + "). yet another coding agent harness."
16 }16 }
17- headline := th.AccentBar(th.Assistant) + th.FG256(th.Assistant, tui.Bold(text))17+ headline := th.AccentBar(th.Assistant) + th.FGColor(th.Assistant, tui.Bold(text))
18 return []string{18 return []string{
19 headline,19 headline,
20- th.FG256(th.Muted, " ask anything, or type /help to see commands."),20+ th.FGColor(th.Muted, " ask anything, or type /help to see commands."),
21 "",21 "",
22 }22 }
23 }23 }
M packages/agent/skills/builtin/write-zot-themes/SKILL.md +13 -5
@@ -19,8 +19,10 @@ inherit from the built-in detected light/dark default.
1919
2020 The `/settings` picker also provides an **inherited (from terminal)**
2121 built-in theme. It uses the terminal's reported colors and emits truecolor
22-when advertised, with a best-effort xterm-256 fallback otherwise. JSON theme
23-files remain explicit zot palettes and use the color forms documented below.
22+when advertised, with a best-effort xterm-256 fallback otherwise. JSON themes
23+use the same first-class terminal-color model: every semantic role accepts
24+xterm-256, ANSI, or RGB values, with RGB quantized when truecolor is
25+unavailable.
2426
2527 User themes are discovered from:
2628
@@ -191,6 +193,8 @@ under `colors`, when they should apply to both modes:
191193 - `fg` — default foreground text.
192194 - `muted` — secondary text, dividers, gutters, inactive hints.
193195 - `accent` — prompt bar, bullets, links, headings, active markers.
196+- `background` — optional full-row TUI background; missing values leave the
197+ terminal background unchanged.
194198 - `user` — user role label color; mostly compatibility.
195199 - `user_bubble_bg` — background behind user message rows.
196200 - `user_bubble_fg` — foreground inside user message rows.
@@ -203,9 +207,7 @@ under `colors`, when they should apply to both modes:
203207 - `selection_bg` — highlighted row background.
204208 - `selection_fg` — highlighted row foreground.
205209
206-Most color fields are xterm-256 indexes (`0`–`255`).
207-
208-`user_bubble_bg` supports richer terminal color forms:
210+Every color field accepts the following JSON forms:
209211
210212 ```json
211213 254
@@ -215,6 +217,12 @@ Most color fields are xterm-256 indexes (`0`–`255`).
215217 { "mode": "rgb", "r": 66, "g": 69, "b": 75 }
216218 ```
217219
220+Numeric values are xterm-256 indexes. ANSI values use SGR codes, with
221+standard foreground/background ranges normalized to the role where necessary.
222+RGB values are emitted directly when truecolor is advertised and quantized to
223+the nearest xterm-256 color otherwise. The forms apply to `background`,
224+`user_bubble_bg`, and all other semantic theme colors.
225+
218226 ## Spinner fields
219227
220228 Spinner settings can appear at top level, under `colors`, or under
@@ -19,8 +19,10 @@ inherit from the built-in detected light/dark default.
19 19
20 The `/settings` picker also provides an **inherited (from terminal)**20 The `/settings` picker also provides an **inherited (from terminal)**
21 built-in theme. It uses the terminal's reported colors and emits truecolor21 built-in theme. It uses the terminal's reported colors and emits truecolor
22-when advertised, with a best-effort xterm-256 fallback otherwise. JSON theme22+when advertised, with a best-effort xterm-256 fallback otherwise. JSON themes
23-files remain explicit zot palettes and use the color forms documented below.23+use the same first-class terminal-color model: every semantic role accepts
24+xterm-256, ANSI, or RGB values, with RGB quantized when truecolor is
25+unavailable.
24 26
25 User themes are discovered from:27 User themes are discovered from:
26 28
@@ -191,6 +193,8 @@ under `colors`, when they should apply to both modes:
191 - `fg` — default foreground text.193 - `fg` — default foreground text.
192 - `muted` — secondary text, dividers, gutters, inactive hints.194 - `muted` — secondary text, dividers, gutters, inactive hints.
193 - `accent` — prompt bar, bullets, links, headings, active markers.195 - `accent` — prompt bar, bullets, links, headings, active markers.
196+- `background` — optional full-row TUI background; missing values leave the
197+ terminal background unchanged.
194 - `user` — user role label color; mostly compatibility.198 - `user` — user role label color; mostly compatibility.
195 - `user_bubble_bg` — background behind user message rows.199 - `user_bubble_bg` — background behind user message rows.
196 - `user_bubble_fg` — foreground inside user message rows.200 - `user_bubble_fg` — foreground inside user message rows.
@@ -203,9 +207,7 @@ under `colors`, when they should apply to both modes:
203 - `selection_bg` — highlighted row background.207 - `selection_bg` — highlighted row background.
204 - `selection_fg` — highlighted row foreground.208 - `selection_fg` — highlighted row foreground.
205 209
206-Most color fields are xterm-256 indexes (`0`–`255`).210+Every color field accepts the following JSON forms:
207-
208-`user_bubble_bg` supports richer terminal color forms:
209 211
210 ```json212 ```json
211 254213 254
@@ -215,6 +217,12 @@ Most color fields are xterm-256 indexes (`0`–`255`).
215 { "mode": "rgb", "r": 66, "g": 69, "b": 75 }217 { "mode": "rgb", "r": 66, "g": 69, "b": 75 }
216 ```218 ```
217 219
220+Numeric values are xterm-256 indexes. ANSI values use SGR codes, with
221+standard foreground/background ranges normalized to the role where necessary.
222+RGB values are emitted directly when truecolor is advertised and quantized to
223+the nearest xterm-256 color otherwise. The forms apply to `background`,
224+`user_bubble_bg`, and all other semantic theme colors.
225+
218 ## Spinner fields226 ## Spinner fields
219 227
220 Spinner settings can appear at top level, under `colors`, or under228 Spinner settings can appear at top level, under `colors`, or under
M packages/agent/zotfile.go +5 -5
@@ -846,11 +846,11 @@ func acceptZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet) (bool, er
846846 var errZotfileConsentInterrupted = errors.New("interrupted")
847847
848848 func formatZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string {
849- style := func(c int, text string) string {
849+ style := func(c tui.TerminalColor, text string) string {
850850 if !color {
851851 return text
852852 }
853- return th.FG256(c, text)
853+ return th.FGColor(c, text)
854854 }
855855
856856 var sb strings.Builder
@@ -886,7 +886,7 @@ func readZotfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error)
886886 fmt.Fprint(out, "\b \b")
887887 return
888888 }
889- fmt.Fprint(out, th.FG256(th.FG, string(answer)))
889+ fmt.Fprint(out, th.FGColor(th.FG, string(answer)))
890890 })
891891 switch {
892892 case errors.Is(err, errZotfileConsentInterrupted):
@@ -938,11 +938,11 @@ func permissionSummary(p tools.PermissionSet) string {
938938 }
939939
940940 func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string {
941- style := func(c int, text string) string {
941+ style := func(c tui.TerminalColor, text string) string {
942942 if !color {
943943 return text
944944 }
945- return th.FG256(c, text)
945+ return th.FGColor(c, text)
946946 }
947947 permissionValue := func(value string) string { return style(th.FG, value) }
948948
@@ -846,11 +846,11 @@ func acceptZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet) (bool, er
846 var errZotfileConsentInterrupted = errors.New("interrupted")846 var errZotfileConsentInterrupted = errors.New("interrupted")
847 847
848 func formatZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string {848 func formatZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string {
849- style := func(c int, text string) string {849+ style := func(c tui.TerminalColor, text string) string {
850 if !color {850 if !color {
851 return text851 return text
852 }852 }
853- return th.FG256(c, text)853+ return th.FGColor(c, text)
854 }854 }
855 855
856 var sb strings.Builder856 var sb strings.Builder
@@ -886,7 +886,7 @@ func readZotfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error)
886 fmt.Fprint(out, "\b \b")886 fmt.Fprint(out, "\b \b")
887 return887 return
888 }888 }
889- fmt.Fprint(out, th.FG256(th.FG, string(answer)))889+ fmt.Fprint(out, th.FGColor(th.FG, string(answer)))
890 })890 })
891 switch {891 switch {
892 case errors.Is(err, errZotfileConsentInterrupted):892 case errors.Is(err, errZotfileConsentInterrupted):
@@ -938,11 +938,11 @@ func permissionSummary(p tools.PermissionSet) string {
938 }938 }
939 939
940 func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string {940 func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string {
941- style := func(c int, text string) string {941+ style := func(c tui.TerminalColor, text string) string {
942 if !color {942 if !color {
943 return text943 return text
944 }944 }
945- return th.FG256(c, text)945+ return th.FGColor(c, text)
946 }946 }
947 permissionValue := func(value string) string { return style(th.FG, value) }947 permissionValue := func(value string) string { return style(th.FG, value) }
948 948
M packages/agent/zotfile_test.go +11 -8
@@ -329,7 +329,10 @@ func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) {
329329 }
330330
331331 func TestFormatZotfileConsentUsesThemeColors(t *testing.T) {
332- th := tui.Theme{FG: 1, Muted: 2, Accent: 3, Assistant: 4, Warning: 5}
332+ th := tui.Theme{
333+ FG: tui.Color256(1), Muted: tui.Color256(2), Accent: tui.Color256(3),
334+ Assistant: tui.Color256(4), Warning: tui.Color256(5),
335+ }
333336 zf := zotfileLoaded{}
334337 zf.Manifest.Name = "reviewer"
335338 zf.Manifest.Version = "1.2.3"
@@ -339,13 +342,13 @@ func TestFormatZotfileConsentUsesThemeColors(t *testing.T) {
339342
340343 got := formatZotfileConsent(zf, perms, th, true)
341344 for _, want := range []string{
342- th.FG256(th.Assistant, "Agent"),
343- th.FG256(th.Accent, "reviewer@1.2.3"),
344- th.FG256(th.Muted, " fs read: "),
345- th.FG256(th.FG, "/workspace"),
346- th.FG256(th.Warning, "ask"),
347- th.FG256(th.Assistant, "Allow?"),
348- th.FG256(th.Muted, "[y/n]"),
345+ th.FGColor(th.Assistant, "Agent"),
346+ th.FGColor(th.Accent, "reviewer@1.2.3"),
347+ th.FGColor(th.Muted, " fs read: "),
348+ th.FGColor(th.FG, "/workspace"),
349+ th.FGColor(th.Warning, "ask"),
350+ th.FGColor(th.Assistant, "Allow?"),
351+ th.FGColor(th.Muted, "[y/n]"),
349352 } {
350353 if !strings.Contains(got, want) {
351354 t.Fatalf("themed consent missing %q:\n%s", want, got)
@@ -329,7 +329,10 @@ func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) {
329 }329 }
330 330
331 func TestFormatZotfileConsentUsesThemeColors(t *testing.T) {331 func TestFormatZotfileConsentUsesThemeColors(t *testing.T) {
332- th := tui.Theme{FG: 1, Muted: 2, Accent: 3, Assistant: 4, Warning: 5}332+ th := tui.Theme{
333+ FG: tui.Color256(1), Muted: tui.Color256(2), Accent: tui.Color256(3),
334+ Assistant: tui.Color256(4), Warning: tui.Color256(5),
335+ }
333 zf := zotfileLoaded{}336 zf := zotfileLoaded{}
334 zf.Manifest.Name = "reviewer"337 zf.Manifest.Name = "reviewer"
335 zf.Manifest.Version = "1.2.3"338 zf.Manifest.Version = "1.2.3"
@@ -339,13 +342,13 @@ func TestFormatZotfileConsentUsesThemeColors(t *testing.T) {
339 342
340 got := formatZotfileConsent(zf, perms, th, true)343 got := formatZotfileConsent(zf, perms, th, true)
341 for _, want := range []string{344 for _, want := range []string{
342- th.FG256(th.Assistant, "Agent"),345+ th.FGColor(th.Assistant, "Agent"),
343- th.FG256(th.Accent, "reviewer@1.2.3"),346+ th.FGColor(th.Accent, "reviewer@1.2.3"),
344- th.FG256(th.Muted, " fs read: "),347+ th.FGColor(th.Muted, " fs read: "),
345- th.FG256(th.FG, "/workspace"),348+ th.FGColor(th.FG, "/workspace"),
346- th.FG256(th.Warning, "ask"),349+ th.FGColor(th.Warning, "ask"),
347- th.FG256(th.Assistant, "Allow?"),350+ th.FGColor(th.Assistant, "Allow?"),
348- th.FG256(th.Muted, "[y/n]"),351+ th.FGColor(th.Muted, "[y/n]"),
349 } {352 } {
350 if !strings.Contains(got, want) {353 if !strings.Contains(got, want) {
351 t.Fatalf("themed consent missing %q:\n%s", want, got)354 t.Fatalf("themed consent missing %q:\n%s", want, got)
M packages/tui/highlight.go +2 -2
@@ -38,7 +38,7 @@ func (th Theme) HighlightCode(src, lang string) []string {
3838 }
3939 style := th.chromaStyle()
4040 formatterName := "terminal256"
41- if th.ColorMode == ColorModeTrueColor {
41+ if th.Terminal.TrueColor {
4242 formatterName = "terminal16m"
4343 }
4444 formatter := formatters.Get(formatterName)
@@ -243,7 +243,7 @@ func (th Theme) chromaStyle() *chroma.Style {
243243 func (th Theme) syntaxKey() string {
244244 syn := th.Syntax
245245 outputMode := "256"
246- if th.ColorMode == ColorModeTrueColor {
246+ if th.Terminal.TrueColor {
247247 outputMode = "truecolor"
248248 }
249249 return strings.Join([]string{
@@ -38,7 +38,7 @@ func (th Theme) HighlightCode(src, lang string) []string {
38 }38 }
39 style := th.chromaStyle()39 style := th.chromaStyle()
40 formatterName := "terminal256"40 formatterName := "terminal256"
41- if th.ColorMode == ColorModeTrueColor {41+ if th.Terminal.TrueColor {
42 formatterName = "terminal16m"42 formatterName = "terminal16m"
43 }43 }
44 formatter := formatters.Get(formatterName)44 formatter := formatters.Get(formatterName)
@@ -243,7 +243,7 @@ func (th Theme) chromaStyle() *chroma.Style {
243 func (th Theme) syntaxKey() string {243 func (th Theme) syntaxKey() string {
244 syn := th.Syntax244 syn := th.Syntax
245 outputMode := "256"245 outputMode := "256"
246- if th.ColorMode == ColorModeTrueColor {246+ if th.Terminal.TrueColor {
247 outputMode = "truecolor"247 outputMode = "truecolor"
248 }248 }
249 return strings.Join([]string{249 return strings.Join([]string{
M packages/tui/layout.go +3 -3
@@ -47,7 +47,7 @@ func InputLines(th Theme, lines []string, width int) []string {
4747 if width < 1 {
4848 width = 1
4949 }
50- rule := th.FG256(th.Muted, strings.Repeat("─", width))
50+ rule := th.FGColor(th.Muted, strings.Repeat("─", width))
5151 out := make([]string, 0, len(lines)+2)
5252 out = append(out, rule)
5353 out = append(out, lines...)
@@ -63,13 +63,13 @@ func InputBlock(th Theme, lines []string, width int) []string {
6363 if bubbleW < 1 {
6464 bubbleW = 1
6565 }
66+ bg := th.sgrBGColor(th.UserBubbleBG)
67+ bar := bg + th.FGColor(th.Accent, "▌ ")
6668 row := func(content string) string {
6769 visible := visibleWidth(content)
6870 if visible < bubbleW {
6971 content += strings.Repeat(" ", bubbleW-visible)
7072 }
71- bg := th.sgrBGColor(th.UserBubbleBG)
72- bar := bg + th.FG256(th.Accent, "▌ ")
7373 body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset
7474 return bar + body
7575 }
@@ -47,7 +47,7 @@ func InputLines(th Theme, lines []string, width int) []string {
47 if width < 1 {47 if width < 1 {
48 width = 148 width = 1
49 }49 }
50- rule := th.FG256(th.Muted, strings.Repeat("─", width))50+ rule := th.FGColor(th.Muted, strings.Repeat("─", width))
51 out := make([]string, 0, len(lines)+2)51 out := make([]string, 0, len(lines)+2)
52 out = append(out, rule)52 out = append(out, rule)
53 out = append(out, lines...)53 out = append(out, lines...)
@@ -63,13 +63,13 @@ func InputBlock(th Theme, lines []string, width int) []string {
63 if bubbleW < 1 {63 if bubbleW < 1 {
64 bubbleW = 164 bubbleW = 1
65 }65 }
66+ bg := th.sgrBGColor(th.UserBubbleBG)
67+ bar := bg + th.FGColor(th.Accent, "▌ ")
66 row := func(content string) string {68 row := func(content string) string {
67 visible := visibleWidth(content)69 visible := visibleWidth(content)
68 if visible < bubbleW {70 if visible < bubbleW {
69 content += strings.Repeat(" ", bubbleW-visible)71 content += strings.Repeat(" ", bubbleW-visible)
70 }72 }
71- bg := th.sgrBGColor(th.UserBubbleBG)
72- bar := bg + th.FG256(th.Accent, "▌ ")
73 body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset73 body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset
74 return bar + body74 return bar + body
75 }75 }
M packages/tui/markdown.go +11 -11
@@ -52,7 +52,7 @@ func RenderMarkdown(src string, th Theme, width int) string {
5252 }
5353 } else {
5454 for _, l := range strings.Split(code, "\n") {
55- out.WriteString(th.FG256(th.Accent, l))
55+ out.WriteString(th.FGColor(th.Accent, l))
5656 out.WriteString("\n")
5757 }
5858 }
@@ -106,25 +106,25 @@ func RenderMarkdown(src string, th Theme, width int) string {
106106 level := len(m[1])
107107 body := strings.TrimSpace(m[2])
108108 prefix := strings.Repeat("#", level) + " "
109- out.WriteString(Bold(th.FG256(th.Accent, prefix+body)) + "\n")
109+ out.WriteString(Bold(th.FGColor(th.Accent, prefix+body)) + "\n")
110110 continue
111111 }
112112 // Blockquote.
113113 if strings.HasPrefix(trim, "> ") {
114114 body := strings.TrimPrefix(trim, "> ")
115- out.WriteString(th.FG256(th.Muted, "┃ ") + renderInline(body, th) + "\n")
115+ out.WriteString(th.FGColor(th.Muted, "┃ ") + renderInline(body, th) + "\n")
116116 continue
117117 }
118118 // Bullet list.
119119 if m := bulletRE.FindStringSubmatch(line); m != nil {
120120 indent, body := m[1], m[2]
121- out.WriteString(indent + th.FG256(th.Accent, "• ") + renderInline(body, th) + "\n")
121+ out.WriteString(indent + th.FGColor(th.Accent, "• ") + renderInline(body, th) + "\n")
122122 continue
123123 }
124124 // Numbered list.
125125 if m := numberRE.FindStringSubmatch(line); m != nil {
126126 indent, num, body := m[1], m[2], m[3]
127- out.WriteString(indent + th.FG256(th.Accent, num+". ") + renderInline(body, th) + "\n")
127+ out.WriteString(indent + th.FGColor(th.Accent, num+". ") + renderInline(body, th) + "\n")
128128 continue
129129 }
130130 out.WriteString(renderInline(line, th) + "\n")
@@ -354,7 +354,7 @@ func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, h
354354 out := make([]string, 0, height)
355355 for r := 0; r < height; r++ {
356356 var b strings.Builder
357- b.WriteString(th.FG256(th.Muted, "|"))
357+ b.WriteString(th.FGColor(th.Muted, "|"))
358358 for c := range widths {
359359 cell := ""
360360 if r < len(wrapped[c]) {
@@ -363,7 +363,7 @@ func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, h
363363 b.WriteByte(' ')
364364 b.WriteString(alignCell(cell, widths[c], aligns[c]))
365365 b.WriteByte(' ')
366- b.WriteString(th.FG256(th.Muted, "|"))
366+ b.WriteString(th.FGColor(th.Muted, "|"))
367367 }
368368 out = append(out, b.String())
369369 }
@@ -372,7 +372,7 @@ func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, h
372372
373373 func renderTableSeparator(widths []int, aligns []tableAlign, th Theme) string {
374374 var b strings.Builder
375- b.WriteString(th.FG256(th.Muted, "|"))
375+ b.WriteString(th.FGColor(th.Muted, "|"))
376376 for c, w := range widths {
377377 dashes := strings.Repeat("-", w)
378378 switch aligns[c] {
@@ -382,9 +382,9 @@ func renderTableSeparator(widths []int, aligns []tableAlign, th Theme) string {
382382 dashes = strings.Repeat("-", maxInt(1, w-1)) + ":"
383383 }
384384 b.WriteByte(' ')
385- b.WriteString(th.FG256(th.Muted, dashes))
385+ b.WriteString(th.FGColor(th.Muted, dashes))
386386 b.WriteByte(' ')
387- b.WriteString(th.FG256(th.Muted, "|"))
387+ b.WriteString(th.FGColor(th.Muted, "|"))
388388 }
389389 return b.String()
390390 }
@@ -416,7 +416,7 @@ func maxInt(a, b int) int {
416416 func renderInline(s string, th Theme) string {
417417 s = codeRE.ReplaceAllStringFunc(s, func(m string) string {
418418 inner := m[1 : len(m)-1]
419- return th.FG256(th.Accent, inner)
419+ return th.FGColor(th.Accent, inner)
420420 })
421421 s = boldRE.ReplaceAllStringFunc(s, func(m string) string {
422422 inner := m[2 : len(m)-2]
@@ -52,7 +52,7 @@ func RenderMarkdown(src string, th Theme, width int) string {
52 }52 }
53 } else {53 } else {
54 for _, l := range strings.Split(code, "\n") {54 for _, l := range strings.Split(code, "\n") {
55- out.WriteString(th.FG256(th.Accent, l))55+ out.WriteString(th.FGColor(th.Accent, l))
56 out.WriteString("\n")56 out.WriteString("\n")
57 }57 }
58 }58 }
@@ -106,25 +106,25 @@ func RenderMarkdown(src string, th Theme, width int) string {
106 level := len(m[1])106 level := len(m[1])
107 body := strings.TrimSpace(m[2])107 body := strings.TrimSpace(m[2])
108 prefix := strings.Repeat("#", level) + " "108 prefix := strings.Repeat("#", level) + " "
109- out.WriteString(Bold(th.FG256(th.Accent, prefix+body)) + "\n")109+ out.WriteString(Bold(th.FGColor(th.Accent, prefix+body)) + "\n")
110 continue110 continue
111 }111 }
112 // Blockquote.112 // Blockquote.
113 if strings.HasPrefix(trim, "> ") {113 if strings.HasPrefix(trim, "> ") {
114 body := strings.TrimPrefix(trim, "> ")114 body := strings.TrimPrefix(trim, "> ")
115- out.WriteString(th.FG256(th.Muted, "┃ ") + renderInline(body, th) + "\n")115+ out.WriteString(th.FGColor(th.Muted, "┃ ") + renderInline(body, th) + "\n")
116 continue116 continue
117 }117 }
118 // Bullet list.118 // Bullet list.
119 if m := bulletRE.FindStringSubmatch(line); m != nil {119 if m := bulletRE.FindStringSubmatch(line); m != nil {
120 indent, body := m[1], m[2]120 indent, body := m[1], m[2]
121- out.WriteString(indent + th.FG256(th.Accent, "• ") + renderInline(body, th) + "\n")121+ out.WriteString(indent + th.FGColor(th.Accent, "• ") + renderInline(body, th) + "\n")
122 continue122 continue
123 }123 }
124 // Numbered list.124 // Numbered list.
125 if m := numberRE.FindStringSubmatch(line); m != nil {125 if m := numberRE.FindStringSubmatch(line); m != nil {
126 indent, num, body := m[1], m[2], m[3]126 indent, num, body := m[1], m[2], m[3]
127- out.WriteString(indent + th.FG256(th.Accent, num+". ") + renderInline(body, th) + "\n")127+ out.WriteString(indent + th.FGColor(th.Accent, num+". ") + renderInline(body, th) + "\n")
128 continue128 continue
129 }129 }
130 out.WriteString(renderInline(line, th) + "\n")130 out.WriteString(renderInline(line, th) + "\n")
@@ -354,7 +354,7 @@ func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, h
354 out := make([]string, 0, height)354 out := make([]string, 0, height)
355 for r := 0; r < height; r++ {355 for r := 0; r < height; r++ {
356 var b strings.Builder356 var b strings.Builder
357- b.WriteString(th.FG256(th.Muted, "|"))357+ b.WriteString(th.FGColor(th.Muted, "|"))
358 for c := range widths {358 for c := range widths {
359 cell := ""359 cell := ""
360 if r < len(wrapped[c]) {360 if r < len(wrapped[c]) {
@@ -363,7 +363,7 @@ func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, h
363 b.WriteByte(' ')363 b.WriteByte(' ')
364 b.WriteString(alignCell(cell, widths[c], aligns[c]))364 b.WriteString(alignCell(cell, widths[c], aligns[c]))
365 b.WriteByte(' ')365 b.WriteByte(' ')
366- b.WriteString(th.FG256(th.Muted, "|"))366+ b.WriteString(th.FGColor(th.Muted, "|"))
367 }367 }
368 out = append(out, b.String())368 out = append(out, b.String())
369 }369 }
@@ -372,7 +372,7 @@ func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, h
372 372
373 func renderTableSeparator(widths []int, aligns []tableAlign, th Theme) string {373 func renderTableSeparator(widths []int, aligns []tableAlign, th Theme) string {
374 var b strings.Builder374 var b strings.Builder
375- b.WriteString(th.FG256(th.Muted, "|"))375+ b.WriteString(th.FGColor(th.Muted, "|"))
376 for c, w := range widths {376 for c, w := range widths {
377 dashes := strings.Repeat("-", w)377 dashes := strings.Repeat("-", w)
378 switch aligns[c] {378 switch aligns[c] {
@@ -382,9 +382,9 @@ func renderTableSeparator(widths []int, aligns []tableAlign, th Theme) string {
382 dashes = strings.Repeat("-", maxInt(1, w-1)) + ":"382 dashes = strings.Repeat("-", maxInt(1, w-1)) + ":"
383 }383 }
384 b.WriteByte(' ')384 b.WriteByte(' ')
385- b.WriteString(th.FG256(th.Muted, dashes))385+ b.WriteString(th.FGColor(th.Muted, dashes))
386 b.WriteByte(' ')386 b.WriteByte(' ')
387- b.WriteString(th.FG256(th.Muted, "|"))387+ b.WriteString(th.FGColor(th.Muted, "|"))
388 }388 }
389 return b.String()389 return b.String()
390 }390 }
@@ -416,7 +416,7 @@ func maxInt(a, b int) int {
416 func renderInline(s string, th Theme) string {416 func renderInline(s string, th Theme) string {
417 s = codeRE.ReplaceAllStringFunc(s, func(m string) string {417 s = codeRE.ReplaceAllStringFunc(s, func(m string) string {
418 inner := m[1 : len(m)-1]418 inner := m[1 : len(m)-1]
419- return th.FG256(th.Accent, inner)419+ return th.FGColor(th.Accent, inner)
420 })420 })
421 s = boldRE.ReplaceAllStringFunc(s, func(m string) string {421 s = boldRE.ReplaceAllStringFunc(s, func(m string) string {
422 inner := m[2 : len(m)-2]422 inner := m[2 : len(m)-2]
M packages/tui/render.go +14 -13
@@ -73,7 +73,8 @@ type Renderer struct {
7373 // row-writing boundary. In particular, Theme.Background is painted
7474 // as a full-width row background without making every View renderer
7575 // know about terminal padding and reset semantics.
76- theme Theme
76+ theme Theme
77+ backgroundStyle string
7778 }
7879
7980 // NewRenderer returns a renderer that writes to out.
@@ -94,6 +95,7 @@ func NewRenderer(out io.Writer) *Renderer {
9495 // background affects every row, so cached frame state is invalidated.
9596 func (r *Renderer) SetTheme(th Theme) {
9697 r.theme = th
98+ r.backgroundStyle = th.BackgroundStyle()
9799 r.Invalidate()
98100 }
99101
@@ -224,16 +226,15 @@ func imageFootprintBlank(s string) bool {
224226 // background reaches the right edge, and re-applies the background
225227 // after full SGR resets inside the row so local styling does not punch
226228 // transparent holes through the global tint.
227-func paintBackgroundRow(line string, cols int, th Theme) string {
228- bg := th.BackgroundStyle()
229- if bg == "" || cols <= 0 || containsImageEscape(line) {
229+func paintBackgroundRow(line string, cols int, background string) string {
230+ if background == "" || cols <= 0 || containsImageEscape(line) {
230231 return line
231232 }
232- line = strings.ReplaceAll(line, reset, reset+bg)
233+ line = strings.ReplaceAll(line, reset, reset+background)
233234 if w := visibleWidth(line); w < cols {
234235 line += strings.Repeat(" ", cols-w)
235236 }
236- return bg + line + reset
237+ return background + line + reset
237238 }
238239
239240 // truncateToWidth clips s so its on-screen width doesn't exceed cols
@@ -322,11 +323,11 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
322323 frame[i] = ""
323324 }
324325 for i, line := range visible {
325- frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
326+ frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
326327 }
327328 if r.theme.Background != nil {
328329 for i := 0; i < top; i++ {
329- frame[i] = paintBackgroundRow("", r.cols, r.theme)
330+ frame[i] = paintBackgroundRow("", r.cols, r.backgroundStyle)
330331 }
331332 }
332333
@@ -371,7 +372,7 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
371372 // overwrites, leaving ghost highlights behind.
372373 hasSelection := false
373374 if r.theme.Background == nil {
374- selectionBG := r.theme.bgPrefix(Color256(r.theme.SelectionBG))
375+ selectionBG := r.theme.bgPrefix(r.theme.SelectionBG)
375376 for _, l := range frame {
376377 if selectionBG != "" && strings.Contains(l, selectionBG) {
377378 hasSelection = true
@@ -431,11 +432,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
431432 }
432433 chatFrame := make([]string, len(chat))
433434 for i, line := range chat {
434- chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
435+ chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
435436 }
436437 bottomFrame := make([]string, len(bottom))
437438 for i, line := range bottom {
438- bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
439+ bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
439440 }
440441
441442 // Always reserve one real row below the editor/status band. This is
@@ -447,7 +448,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
447448 lines = append(lines, chatFrame...)
448449 lines = append(lines, bottomFrame...)
449450 for range bottomMarginRows {
450- lines = append(lines, paintBackgroundRow("", r.cols, r.theme))
451+ lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))
451452 }
452453 // In main-screen flow mode zot normally emits only its logical
453454 // content rows and leaves the rest of the terminal viewport alone.
@@ -457,7 +458,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
457458 // default transparent case.
458459 if r.theme.Background != nil {
459460 for len(lines) < r.rows {
460- lines = append(lines, paintBackgroundRow("", r.cols, r.theme))
461+ lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))
461462 }
462463 }
463464 if len(lines) == 0 {
@@ -73,7 +73,8 @@ type Renderer struct {
73 // row-writing boundary. In particular, Theme.Background is painted73 // row-writing boundary. In particular, Theme.Background is painted
74 // as a full-width row background without making every View renderer74 // as a full-width row background without making every View renderer
75 // know about terminal padding and reset semantics.75 // know about terminal padding and reset semantics.
76- theme Theme76+ theme Theme
77+ backgroundStyle string
77 }78 }
78 79
79 // NewRenderer returns a renderer that writes to out.80 // NewRenderer returns a renderer that writes to out.
@@ -94,6 +95,7 @@ func NewRenderer(out io.Writer) *Renderer {
94 // background affects every row, so cached frame state is invalidated.95 // background affects every row, so cached frame state is invalidated.
95 func (r *Renderer) SetTheme(th Theme) {96 func (r *Renderer) SetTheme(th Theme) {
96 r.theme = th97 r.theme = th
98+ r.backgroundStyle = th.BackgroundStyle()
97 r.Invalidate()99 r.Invalidate()
98 }100 }
99 101
@@ -224,16 +226,15 @@ func imageFootprintBlank(s string) bool {
224 // background reaches the right edge, and re-applies the background226 // background reaches the right edge, and re-applies the background
225 // after full SGR resets inside the row so local styling does not punch227 // after full SGR resets inside the row so local styling does not punch
226 // transparent holes through the global tint.228 // transparent holes through the global tint.
227-func paintBackgroundRow(line string, cols int, th Theme) string {229+func paintBackgroundRow(line string, cols int, background string) string {
228- bg := th.BackgroundStyle()230+ if background == "" || cols <= 0 || containsImageEscape(line) {
229- if bg == "" || cols <= 0 || containsImageEscape(line) {
230 return line231 return line
231 }232 }
232- line = strings.ReplaceAll(line, reset, reset+bg)233+ line = strings.ReplaceAll(line, reset, reset+background)
233 if w := visibleWidth(line); w < cols {234 if w := visibleWidth(line); w < cols {
234 line += strings.Repeat(" ", cols-w)235 line += strings.Repeat(" ", cols-w)
235 }236 }
236- return bg + line + reset237+ return background + line + reset
237 }238 }
238 239
239 // truncateToWidth clips s so its on-screen width doesn't exceed cols240 // truncateToWidth clips s so its on-screen width doesn't exceed cols
@@ -322,11 +323,11 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
322 frame[i] = ""323 frame[i] = ""
323 }324 }
324 for i, line := range visible {325 for i, line := range visible {
325- frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)326+ frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
326 }327 }
327 if r.theme.Background != nil {328 if r.theme.Background != nil {
328 for i := 0; i < top; i++ {329 for i := 0; i < top; i++ {
329- frame[i] = paintBackgroundRow("", r.cols, r.theme)330+ frame[i] = paintBackgroundRow("", r.cols, r.backgroundStyle)
330 }331 }
331 }332 }
332 333
@@ -371,7 +372,7 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
371 // overwrites, leaving ghost highlights behind.372 // overwrites, leaving ghost highlights behind.
372 hasSelection := false373 hasSelection := false
373 if r.theme.Background == nil {374 if r.theme.Background == nil {
374- selectionBG := r.theme.bgPrefix(Color256(r.theme.SelectionBG))375+ selectionBG := r.theme.bgPrefix(r.theme.SelectionBG)
375 for _, l := range frame {376 for _, l := range frame {
376 if selectionBG != "" && strings.Contains(l, selectionBG) {377 if selectionBG != "" && strings.Contains(l, selectionBG) {
377 hasSelection = true378 hasSelection = true
@@ -431,11 +432,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
431 }432 }
432 chatFrame := make([]string, len(chat))433 chatFrame := make([]string, len(chat))
433 for i, line := range chat {434 for i, line := range chat {
434- chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)435+ chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
435 }436 }
436 bottomFrame := make([]string, len(bottom))437 bottomFrame := make([]string, len(bottom))
437 for i, line := range bottom {438 for i, line := range bottom {
438- bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)439+ bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
439 }440 }
440 441
441 // Always reserve one real row below the editor/status band. This is442 // Always reserve one real row below the editor/status band. This is
@@ -447,7 +448,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
447 lines = append(lines, chatFrame...)448 lines = append(lines, chatFrame...)
448 lines = append(lines, bottomFrame...)449 lines = append(lines, bottomFrame...)
449 for range bottomMarginRows {450 for range bottomMarginRows {
450- lines = append(lines, paintBackgroundRow("", r.cols, r.theme))451+ lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))
451 }452 }
452 // In main-screen flow mode zot normally emits only its logical453 // In main-screen flow mode zot normally emits only its logical
453 // content rows and leaves the rest of the terminal viewport alone.454 // content rows and leaves the rest of the terminal viewport alone.
@@ -457,7 +458,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
457 // default transparent case.458 // default transparent case.
458 if r.theme.Background != nil {459 if r.theme.Background != nil {
459 for len(lines) < r.rows {460 for len(lines) < r.rows {
460- lines = append(lines, paintBackgroundRow("", r.cols, r.theme))461+ lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))
461 }462 }
462 }463 }
463 if len(lines) == 0 {464 if len(lines) == 0 {
M packages/tui/rightbar.go +19 -19
@@ -88,37 +88,37 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
8888 }
8989 return text
9090 }
91- paintLine := func(text string, color int, dim, phase bool) string {
91+ paintLine := func(text string, color *TerminalColor, dim, phase bool) string {
9292 text = padLine(text, phase)
93- if color < 0 {
93+ if color == nil {
9494 return text
9595 }
96- paint := func(value string, valueColor int) string {
96+ paint := func(value string, valueColor TerminalColor) string {
9797 if dim {
9898 value = Dim(value)
9999 }
100- return th.FG256(valueColor, value)
100+ return th.FGColor(valueColor, value)
101101 }
102102 if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok {
103103 if phase {
104104 if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok {
105- return paint(text[:markerStart], color) +
105+ return paint(text[:markerStart], *color) +
106106 paint(text[markerStart:markerEnd], th.Muted) +
107- paint(text[markerEnd:nameStart], color) +
108- paint(Bold(text[nameStart:nameEnd]), color) +
109- paint(text[nameEnd:], color)
107+ paint(text[markerEnd:nameStart], *color) +
108+ paint(Bold(text[nameStart:nameEnd]), *color) +
109+ paint(text[nameEnd:], *color)
110110 }
111111 }
112- return paint(text[:markerStart], color) +
112+ return paint(text[:markerStart], *color) +
113113 paint(text[markerStart:markerEnd], th.Muted) +
114- paint(text[markerEnd:], color)
114+ paint(text[markerEnd:], *color)
115115 }
116116 if phase {
117- return paint(Bold(text), color)
117+ return paint(Bold(text), *color)
118118 }
119- return paint(text, color)
119+ return paint(text, *color)
120120 }
121- appendContent := func(text string, color int, dim, phase bool) bool {
121+ appendContent := func(text string, color *TerminalColor, dim, phase bool) bool {
122122 text = strings.ReplaceAll(text, "\r", "")
123123 for _, line := range strings.Split(text, "\n") {
124124 if len(content) >= height {
@@ -131,17 +131,17 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
131131 }
132132
133133 for idx, widget := range ordered {
134- if idx > 0 && !appendContent("", -1, false, false) {
134+ if idx > 0 && !appendContent("", nil, false, false) {
135135 break
136136 }
137137 label := "[" + widget.Extension + "]"
138138 if widget.Title != "" {
139139 label += " " + widget.Title
140140 }
141- if !appendContent(label, th.Accent, false, false) {
141+ if !appendContent(label, &th.Accent, false, false) {
142142 break
143143 }
144- if !appendContent("", -1, false, false) {
144+ if !appendContent("", nil, false, false) {
145145 break
146146 }
147147 activePhaseIndent := rightBarActivePhaseIndent(widget.Lines)
@@ -157,7 +157,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
157157 line = " " + line
158158 dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase)
159159 phase := rightBarChecklistPhaseLine(line, phaseIndent)
160- if !appendContent(line, th.FG, dim, phase) {
160+ if !appendContent(line, &th.FG, dim, phase) {
161161 break
162162 }
163163 }
@@ -166,7 +166,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
166166 }
167167 }
168168 if truncated {
169- marker := paintLine("... right bar ...", th.Muted, false, false)
169+ marker := paintLine("... right bar ...", &th.Muted, false, false)
170170 if len(content) == height {
171171 content[len(content)-1] = marker
172172 } else {
@@ -350,7 +350,7 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int)
350350 if visible := visibleWidth(rightBar); visible < rightBarWidth {
351351 rightBar += strings.Repeat(" ", rightBarWidth-visible)
352352 }
353- return main + th.FG256(th.Muted, "│") + rightBar
353+ return main + th.FGColor(th.Muted, "│") + rightBar
354354 }
355355
356356 // DrawRightBar renders an already-laid-out visible chat slice and sticky
@@ -88,37 +88,37 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
88 }88 }
89 return text89 return text
90 }90 }
91- paintLine := func(text string, color int, dim, phase bool) string {91+ paintLine := func(text string, color *TerminalColor, dim, phase bool) string {
92 text = padLine(text, phase)92 text = padLine(text, phase)
93- if color < 0 {93+ if color == nil {
94 return text94 return text
95 }95 }
96- paint := func(value string, valueColor int) string {96+ paint := func(value string, valueColor TerminalColor) string {
97 if dim {97 if dim {
98 value = Dim(value)98 value = Dim(value)
99 }99 }
100- return th.FG256(valueColor, value)100+ return th.FGColor(valueColor, value)
101 }101 }
102 if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok {102 if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok {
103 if phase {103 if phase {
104 if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok {104 if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok {
105- return paint(text[:markerStart], color) +105+ return paint(text[:markerStart], *color) +
106 paint(text[markerStart:markerEnd], th.Muted) +106 paint(text[markerStart:markerEnd], th.Muted) +
107- paint(text[markerEnd:nameStart], color) +107+ paint(text[markerEnd:nameStart], *color) +
108- paint(Bold(text[nameStart:nameEnd]), color) +108+ paint(Bold(text[nameStart:nameEnd]), *color) +
109- paint(text[nameEnd:], color)109+ paint(text[nameEnd:], *color)
110 }110 }
111 }111 }
112- return paint(text[:markerStart], color) +112+ return paint(text[:markerStart], *color) +
113 paint(text[markerStart:markerEnd], th.Muted) +113 paint(text[markerStart:markerEnd], th.Muted) +
114- paint(text[markerEnd:], color)114+ paint(text[markerEnd:], *color)
115 }115 }
116 if phase {116 if phase {
117- return paint(Bold(text), color)117+ return paint(Bold(text), *color)
118 }118 }
119- return paint(text, color)119+ return paint(text, *color)
120 }120 }
121- appendContent := func(text string, color int, dim, phase bool) bool {121+ appendContent := func(text string, color *TerminalColor, dim, phase bool) bool {
122 text = strings.ReplaceAll(text, "\r", "")122 text = strings.ReplaceAll(text, "\r", "")
123 for _, line := range strings.Split(text, "\n") {123 for _, line := range strings.Split(text, "\n") {
124 if len(content) >= height {124 if len(content) >= height {
@@ -131,17 +131,17 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
131 }131 }
132 132
133 for idx, widget := range ordered {133 for idx, widget := range ordered {
134- if idx > 0 && !appendContent("", -1, false, false) {134+ if idx > 0 && !appendContent("", nil, false, false) {
135 break135 break
136 }136 }
137 label := "[" + widget.Extension + "]"137 label := "[" + widget.Extension + "]"
138 if widget.Title != "" {138 if widget.Title != "" {
139 label += " " + widget.Title139 label += " " + widget.Title
140 }140 }
141- if !appendContent(label, th.Accent, false, false) {141+ if !appendContent(label, &th.Accent, false, false) {
142 break142 break
143 }143 }
144- if !appendContent("", -1, false, false) {144+ if !appendContent("", nil, false, false) {
145 break145 break
146 }146 }
147 activePhaseIndent := rightBarActivePhaseIndent(widget.Lines)147 activePhaseIndent := rightBarActivePhaseIndent(widget.Lines)
@@ -157,7 +157,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
157 line = " " + line157 line = " " + line
158 dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase)158 dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase)
159 phase := rightBarChecklistPhaseLine(line, phaseIndent)159 phase := rightBarChecklistPhaseLine(line, phaseIndent)
160- if !appendContent(line, th.FG, dim, phase) {160+ if !appendContent(line, &th.FG, dim, phase) {
161 break161 break
162 }162 }
163 }163 }
@@ -166,7 +166,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
166 }166 }
167 }167 }
168 if truncated {168 if truncated {
169- marker := paintLine("... right bar ...", th.Muted, false, false)169+ marker := paintLine("... right bar ...", &th.Muted, false, false)
170 if len(content) == height {170 if len(content) == height {
171 content[len(content)-1] = marker171 content[len(content)-1] = marker
172 } else {172 } else {
@@ -350,7 +350,7 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int)
350 if visible := visibleWidth(rightBar); visible < rightBarWidth {350 if visible := visibleWidth(rightBar); visible < rightBarWidth {
351 rightBar += strings.Repeat(" ", rightBarWidth-visible)351 rightBar += strings.Repeat(" ", rightBarWidth-visible)
352 }352 }
353- return main + th.FG256(th.Muted, "│") + rightBar353+ return main + th.FGColor(th.Muted, "│") + rightBar
354 }354 }
355 355
356 // DrawRightBar renders an already-laid-out visible chat slice and sticky356 // DrawRightBar renders an already-laid-out visible chat slice and sticky
M packages/tui/rightbar_test.go +3 -3
@@ -127,8 +127,8 @@ func TestRenderRightBarDimsChecklistMarkers(t *testing.T) {
127127 }}, 24, 8)
128128 rendered := strings.Join(lines, "\n")
129129 for _, marker := range []string{"[x]", "[>]", "[ ]"} {
130- if !strings.Contains(rendered, Dark.FG256(Dark.Muted, marker)) &&
131- !strings.Contains(rendered, Dark.FG256(Dark.Muted, Dim(marker))) {
130+ if !strings.Contains(rendered, Dark.FGColor(Dark.Muted, marker)) &&
131+ !strings.Contains(rendered, Dark.FGColor(Dark.Muted, Dim(marker))) {
132132 t.Fatalf("marker %q was not muted: %q", marker, rendered)
133133 }
134134 }
@@ -169,7 +169,7 @@ func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) {
169169
170170 func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) {
171171 line := JoinRightBar(Dark, "main", "right", 8, 8)
172- if !strings.Contains(line, Dark.FG256(Dark.Muted, "│")) {
172+ if !strings.Contains(line, Dark.FGColor(Dark.Muted, "│")) {
173173 t.Fatalf("separator is not muted: %q", line)
174174 }
175175 if strings.ContainsAny(stripANSI(line), "┌┐└┘─") {
@@ -127,8 +127,8 @@ func TestRenderRightBarDimsChecklistMarkers(t *testing.T) {
127 }}, 24, 8)127 }}, 24, 8)
128 rendered := strings.Join(lines, "\n")128 rendered := strings.Join(lines, "\n")
129 for _, marker := range []string{"[x]", "[>]", "[ ]"} {129 for _, marker := range []string{"[x]", "[>]", "[ ]"} {
130- if !strings.Contains(rendered, Dark.FG256(Dark.Muted, marker)) &&130+ if !strings.Contains(rendered, Dark.FGColor(Dark.Muted, marker)) &&
131- !strings.Contains(rendered, Dark.FG256(Dark.Muted, Dim(marker))) {131+ !strings.Contains(rendered, Dark.FGColor(Dark.Muted, Dim(marker))) {
132 t.Fatalf("marker %q was not muted: %q", marker, rendered)132 t.Fatalf("marker %q was not muted: %q", marker, rendered)
133 }133 }
134 }134 }
@@ -169,7 +169,7 @@ func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) {
169 169
170 func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) {170 func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) {
171 line := JoinRightBar(Dark, "main", "right", 8, 8)171 line := JoinRightBar(Dark, "main", "right", 8, 8)
172- if !strings.Contains(line, Dark.FG256(Dark.Muted, "│")) {172+ if !strings.Contains(line, Dark.FGColor(Dark.Muted, "│")) {
173 t.Fatalf("separator is not muted: %q", line)173 t.Fatalf("separator is not muted: %q", line)
174 }174 }
175 if strings.ContainsAny(stripANSI(line), "┌┐└┘─") {175 if strings.ContainsAny(stripANSI(line), "┌┐└┘─") {
M packages/tui/statusbar_test.go +1 -1
@@ -105,7 +105,7 @@ func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {
105105
106106 func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {
107107 th := Dark
108- th.ThinkingMax = 201
108+ th.ThinkingMax = Color256(201)
109109 lines := StatusBar(StatusBarParams{
110110 Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200,
111111 })
@@ -105,7 +105,7 @@ func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {
105 105
106 func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {106 func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {
107 th := Dark107 th := Dark
108- th.ThinkingMax = 201108+ th.ThinkingMax = Color256(201)
109 lines := StatusBar(StatusBarParams{109 lines := StatusBar(StatusBarParams{
110 Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200,110 Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200,
111 })111 })
M packages/tui/theme.go +144 -112
@@ -3,14 +3,14 @@
33 // framework; just ANSI escape codes.
44 package tui
55
6-import "strings"
7-
8-// TerminalColor describes a terminal colour in one of the colour
9-// spaces terminals commonly support. Most of zot's theme still uses
10-// xterm-256 indexes, but user-bubble backgrounds can also use ANSI
11-// theme slots (for example SGR 100 / bright-black background) so they
12-// match the user's terminal theme rather than the fixed 256-colour
13-// cube.
6+import (
7+ "strings"
8+ "sync/atomic"
9+)
10+
11+// TerminalColor describes a theme colour in one of the colour spaces
12+// terminals commonly support. Every semantic theme colour uses this type;
13+// xterm-256, ANSI, and RGB values are rendered according to terminal capability.
1414 type TerminalColor struct {
1515 Mode terminalColorMode
1616 Index int
@@ -33,14 +33,6 @@ func ColorRGB(r, g, b int) TerminalColor {
3333 return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b}
3434 }
3535
36-// ColorMode is the output color depth used by an inherited theme.
37-type ColorMode uint8
38-
39-const (
40- ColorMode256 ColorMode = iota
41- ColorModeTrueColor
42-)
43-
4436 // TerminalProfile is the best-effort color snapshot reported by the
4537 // controlling terminal. The ANSI palette is optional; unknown slots are
4638 // resolved through the classic xterm-256 palette.
@@ -66,31 +58,31 @@ func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) {
6658 return p.Palette[index], true
6759 }
6860
69-// ANSI 256-color palette used by zot. Defined as numeric codes so we
70-// can swap themes without changing any render code.
61+// Semantic palette used by zot. Each role is a TerminalColor so themes can
62+// use indexed, ANSI, or RGB values without changing render code.
7163 type Theme struct {
72- FG int
73- Muted int
74- Accent int
64+ FG TerminalColor
65+ Muted TerminalColor
66+ Accent TerminalColor
7567 Background *TerminalColor // optional full-row TUI background; nil keeps terminal default
76- User int // label color for the user role
68+ User TerminalColor // label color for the user role
7769 UserBubbleBG TerminalColor // background tint behind user message rows
78- UserBubbleFG int // foreground colour for user message rows
79- Assistant int // label color for the zot role
80- Tool int
81- ToolOut int
82- Error int
83- Warning int
84- Spinner int // spinner + funny working line
85- ThinkingMax int // status color for the opt-in max reasoning level
86- SelectionBG int // background for highlighted rows
87- SelectionFG int // foreground for highlighted rows
88-
89- // Inherited selects terminal-owned colors and uses ColorMode to choose
90- // truecolor or a best-effort xterm-256 fallback. Explicit zot themes keep
91- // the historical indexed output because their values are already fixed.
70+ UserBubbleFG TerminalColor // foreground colour for user message rows
71+ Assistant TerminalColor // label color for the zot role
72+ Tool TerminalColor
73+ ToolOut TerminalColor
74+ Error TerminalColor
75+ Warning TerminalColor
76+ Spinner TerminalColor // spinner + funny working line
77+ ThinkingMax TerminalColor // status color for the opt-in max reasoning level
78+ SelectionBG TerminalColor // background for highlighted rows
79+ SelectionFG TerminalColor // foreground for highlighted rows
80+
81+ // Inherited selects terminal-owned colors. Terminal.TrueColor controls
82+ // whether fixed indexed values are emitted as xterm-256 or truecolor and
83+ // whether RGB values are quantized for terminals without direct-color
84+ // support.
9285 Inherited bool
93- ColorMode ColorMode
9486 Terminal TerminalProfile
9587
9688 SpinnerFrames []string
@@ -188,21 +180,21 @@ var defaultSpinnerMessages = []string{
188180 }
189181
190182 var Dark = Theme{
191- FG: 253,
192- Muted: 244,
193- Accent: 111, // soft blue
194- User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat)
183+ FG: Color256(253),
184+ Muted: Color256(244),
185+ Accent: Color256(111), // soft blue
186+ User: Color256(180), // warm tan (unused now that the speaker label is gone, kept for skin compat)
195187 UserBubbleBG: ColorRGB(66, 69, 75), // #42454B
196- UserBubbleFG: 248, // slightly lighter grey for readability on #42454B
197- Assistant: 117, // bright cyan — the zot label color
198- Tool: 114, // green
199- ToolOut: 245,
200- Error: 203,
201- Warning: 214,
202- Spinner: 183, // soft purple
203- ThinkingMax: 207, // vivid magenta
204- SelectionBG: 24, // deep blue background
205- SelectionFG: 231, // near-white foreground
188+ UserBubbleFG: Color256(248), // slightly lighter grey for readability on #42454B
189+ Assistant: Color256(117), // bright cyan — the zot label color
190+ Tool: Color256(114), // green
191+ ToolOut: Color256(245),
192+ Error: Color256(203),
193+ Warning: Color256(214),
194+ Spinner: Color256(183), // soft purple
195+ ThinkingMax: Color256(207), // vivid magenta
196+ SelectionBG: Color256(24), // deep blue background
197+ SelectionFG: Color256(231), // near-white foreground
206198 SpinnerFrames: defaultSpinnerFrames,
207199 SpinnerMessages: defaultSpinnerMessages,
208200 SpinnerIntervalMS: 80,
@@ -211,21 +203,21 @@ var Dark = Theme{
211203 }
212204
213205 var Light = Theme{
214- FG: 236,
215- Muted: 244,
216- Accent: 33,
217- User: 94,
206+ FG: Color256(236),
207+ Muted: Color256(244),
208+ Accent: Color256(33),
209+ User: Color256(94),
218210 UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme
219- UserBubbleFG: 240, // dark grey text, legible on the pale panel
220- Assistant: 31, // deep cyan
221- Tool: 28,
222- ToolOut: 240,
223- Error: 160,
224- Warning: 166,
225- Spinner: 91, // purple
226- ThinkingMax: 127, // deep magenta
227- SelectionBG: 153, // light blue
228- SelectionFG: 232, // near-black
211+ UserBubbleFG: Color256(240), // dark grey text, legible on the pale panel
212+ Assistant: Color256(31), // deep cyan
213+ Tool: Color256(28),
214+ ToolOut: Color256(240),
215+ Error: Color256(160),
216+ Warning: Color256(166),
217+ Spinner: Color256(91), // purple
218+ ThinkingMax: Color256(127), // deep magenta
219+ SelectionBG: Color256(153), // light blue
220+ SelectionFG: Color256(232), // near-black
229221 SpinnerFrames: defaultSpinnerFrames,
230222 SpinnerMessages: defaultSpinnerMessages,
231223 SpinnerIntervalMS: 80,
@@ -233,43 +225,48 @@ var Light = Theme{
233225 Syntax: nordSyntax,
234226 }
235227
236-// FG256 wraps s in a foreground color. Explicit themes retain the
237-// historical ANSI 256-color sequence; inherited themes resolve terminal
238-// palette entries and emit truecolor when the terminal advertises it.
239-func (t Theme) FG256(c int, s string) string {
228+// FGColor wraps s in a foreground color. The name is intentionally explicit
229+// because the color may be xterm-256, ANSI, or RGB.
230+func (t Theme) FGColor(c TerminalColor, s string) string {
240231 return t.fgPrefix(c) + s + reset
241232 }
242233
243-// BG256 wraps s in a background color using the theme's output capability.
244-// Useful when the visible cell needs a coloured fill but the underlying
245-// character should be a regular space (so mouse-copy yields whitespace).
246-func (t Theme) BG256(c int, s string) string {
247- return t.bgPrefix(Color256(c)) + s + reset
234+// FG256 retains the historical helper name while accepting the full
235+// TerminalColor model.
236+//
237+// Deprecated: use FGColor.
238+func (t Theme) FG256(c TerminalColor, s string) string {
239+ return t.FGColor(c, s)
248240 }
249241
250-// BG wraps s in a terminal background color. Inherited themes quantize RGB
251-// values on terminals without truecolor support.
242+// BG256 retains the historical helper name while accepting the full
243+// TerminalColor model.
244+//
245+// Deprecated: use BG.
246+func (t Theme) BG256(c TerminalColor, s string) string {
247+ return t.bgPrefix(c) + s + reset
248+}
249+
250+// BG wraps s in a terminal background color. RGB values are quantized when
251+// the active terminal does not advertise truecolor support.
252252 func (t Theme) BG(c TerminalColor, s string) string {
253253 return t.bgPrefix(c) + s + reset
254254 }
255255
256-func (t Theme) fgPrefix(index int) string {
257- if t.Inherited && index == t.FG && t.Terminal.HasForeground {
258- return sgrFGColor(t.resolveColor(t.Terminal.Foreground))
259- }
260- return sgrFGColor(t.resolveColor(Color256(index)))
256+func (t Theme) fgPrefix(color TerminalColor) string {
257+ return sgrFGColor(t.resolveColor(color))
261258 }
262259
263260 func (t Theme) bgPrefix(color TerminalColor) string {
264261 return sgrBGColor(t.resolveColor(color))
265262 }
266263
267-// DimColor fades an indexed color toward the terminal background. It is used
268-// by inherited theme construction for muted surfaces and is also available to
269-// callers that need terminal-aware dimming for overlays.
270-func (t Theme) DimColor(index, percent int) TerminalColor {
264+// DimColor fades a theme color toward the terminal background. It is used
265+// by inherited theme construction and is also available to callers that need
266+// terminal-aware dimming for overlays.
267+func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor {
271268 percent = clampPercent(percent)
272- foreground := t.resolveForeground(index)
269+ foreground := t.resolveColor(color)
273270 background := t.Terminal.Background
274271 if !t.Terminal.HasBackground {
275272 background = Color256(0)
@@ -283,36 +280,32 @@ func (t Theme) DimColor(index, percent int) TerminalColor {
283280 ))
284281 }
285282
286-func (t Theme) resolveForeground(index int) TerminalColor {
287- if t.Inherited && index == t.FG && t.Terminal.HasForeground {
288- return t.resolveColor(t.Terminal.Foreground)
289- }
290- return t.resolveColor(Color256(index))
291-}
292-
293283 func (t Theme) resolveColor(color TerminalColor) TerminalColor {
294284 switch color.Mode {
295285 case terminalColor256:
296286 index := clampXtermIndex(color.Index)
297- if t.Inherited {
298- if palette, ok := t.Terminal.PaletteColor(index); ok {
299- if t.ColorMode == ColorModeTrueColor {
287+ if t.Terminal.TrueColor {
288+ if t.Inherited {
289+ if palette, ok := t.Terminal.PaletteColor(index); ok {
300290 return palette
301291 }
302- return Color256(index)
303- }
304- if t.ColorMode == ColorModeTrueColor {
305- r, g, b := xterm256RGB(index)
306- return ColorRGB(r, g, b)
307292 }
308- }
309- if t.ColorMode == ColorModeTrueColor {
310293 r, g, b := xterm256RGB(index)
311294 return ColorRGB(r, g, b)
312295 }
313296 return Color256(index)
297+ case terminalColorANSI:
298+ if t.Inherited && t.Terminal.TrueColor {
299+ if index, ok := ansiSGRToXtermIndex(color.Index); ok {
300+ if palette, ok := t.Terminal.PaletteColor(index); ok {
301+ return palette
302+ }
303+ r, g, b := xterm256RGB(index)
304+ return ColorRGB(r, g, b)
305+ }
306+ }
314307 case terminalColorRGB:
315- if t.Inherited && t.ColorMode == ColorMode256 {
308+ if !t.Terminal.TrueColor {
316309 return Color256(nearestXtermColor(color.R, color.G, color.B))
317310 }
318311 }
@@ -322,7 +315,7 @@ func (t Theme) resolveColor(color TerminalColor) TerminalColor {
322315 func sgrFGColor(color TerminalColor) string {
323316 switch color.Mode {
324317 case terminalColorANSI:
325- return "\x1b[" + itoa(color.Index) + "m"
318+ return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m"
326319 case terminalColorRGB:
327320 return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m"
328321 default:
@@ -338,8 +331,8 @@ func (t Theme) sgrBGColor(color TerminalColor) string {
338331 // glyph followed by a plain space gutter. Used as the speaker-label
339332 // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so
340333 // the bar reads consistently across the UI.
341-func (t Theme) AccentBar(c int) string {
342- return t.FG256(c, "▌ ")
334+func (t Theme) AccentBar(c TerminalColor) string {
335+ return t.FGColor(c, "▌ ")
343336 }
344337
345338 // Highlight paints s with the theme's selection colors (foreground +
@@ -351,7 +344,7 @@ func (t Theme) Highlight(s string) string {
351344
352345 // SelectionStyle returns the SGR prefix for the theme's selected row.
353346 func (t Theme) SelectionStyle() string {
354- return t.fgPrefix(t.SelectionFG) + t.bgPrefix(Color256(t.SelectionBG))
347+ return t.fgPrefix(t.SelectionFG) + t.bgPrefix(t.SelectionBG)
355348 }
356349
357350 // BackgroundStyle returns the SGR prefix for the optional full-row
@@ -367,8 +360,8 @@ func (t Theme) BackgroundStyle() string {
367360 // SelectionStyleFG returns the SGR prefix for selected-row text with
368361 // a custom foreground. Useful for preserving semantic marks inside a
369362 // highlighted row without hardcoding escape sequences outside theme.go.
370-func (t Theme) SelectionStyleFG(fg int) string {
371- return t.fgPrefix(fg) + t.bgPrefix(Color256(t.SelectionBG))
363+func (t Theme) SelectionStyleFG(fg TerminalColor) string {
364+ return t.fgPrefix(fg) + t.bgPrefix(t.SelectionBG)
372365 }
373366
374367 // PadHighlight styles s and extends the selection background to the
@@ -404,7 +397,7 @@ func (t Theme) UserBubbleRow(content string, width int) string {
404397 // Bar plus a single space gutter, in the assistant accent colour
405398 // so it matches the tool-box / app accent and reads as zot's voice
406399 // marker. Two cells wide.
407- bar := t.FG256(t.Assistant, "▌ ")
400+ bar := t.FGColor(t.Assistant, "▌ ")
408401 bubbleW := width - 2
409402 if bubbleW < 1 {
410403 bubbleW = 1
@@ -428,7 +421,7 @@ func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m"
428421 func sgrBGColor(c TerminalColor) string {
429422 switch c.Mode {
430423 case terminalColorANSI:
431- return "\x1b[" + itoa(c.Index) + "m"
424+ return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m"
432425 case terminalColorRGB:
433426 return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m"
434427 default:
@@ -497,14 +490,52 @@ func ansiSGRToXtermIndex(sgr int) (int, bool) {
497490 }
498491 }
499492
493+func ansiForegroundSGR(sgr int) int {
494+ switch {
495+ case sgr >= 40 && sgr <= 47:
496+ return sgr - 10
497+ case sgr >= 100 && sgr <= 107:
498+ return sgr - 10
499+ default:
500+ return sgr
501+ }
502+}
503+
504+func ansiBackgroundSGR(sgr int) int {
505+ switch {
506+ case sgr >= 30 && sgr <= 37:
507+ return sgr + 10
508+ case sgr >= 90 && sgr <= 97:
509+ return sgr + 10
510+ default:
511+ return sgr
512+ }
513+}
514+
500515 func blendChannel(from, to, percent int) int {
501516 return clampByte((from*(100-percent) + to*percent + 50) / 100)
502517 }
503518
519+const xtermColorCacheSize = 256
520+
521+type xtermColorCacheEntry struct {
522+ value atomic.Uint64
523+}
524+
525+var xtermColorCache [xtermColorCacheSize]xtermColorCacheEntry
526+
504527 func nearestXtermColor(r, g, b int) int {
505528 r = clampByte(r)
506529 g = clampByte(g)
507530 b = clampByte(b)
531+ key := uint64(r)<<16 | uint64(g)<<8 | uint64(b)
532+ entry := &xtermColorCache[(key*2654435761)&(xtermColorCacheSize-1)]
533+ cached := entry.value.Load()
534+ keyPrefix := (key + 1) << 8
535+ if cached&^uint64(0xff) == keyPrefix {
536+ return int(cached & 0xff)
537+ }
538+
508539 best := 0
509540 bestDistance := int(^uint(0) >> 1)
510541 for index := 0; index < 256; index++ {
@@ -518,6 +549,7 @@ func nearestXtermColor(r, g, b int) int {
518549 bestDistance = distance
519550 }
520551 }
552+ entry.value.Store(keyPrefix | uint64(best))
521553 return best
522554 }
523555
@@ -3,14 +3,14 @@
3 // framework; just ANSI escape codes.3 // framework; just ANSI escape codes.
4 package tui4 package tui
5 5
6-import "strings"6+import (
7-7+ "strings"
8-// TerminalColor describes a terminal colour in one of the colour8+ "sync/atomic"
9-// spaces terminals commonly support. Most of zot's theme still uses9+)
10-// xterm-256 indexes, but user-bubble backgrounds can also use ANSI10+
11-// theme slots (for example SGR 100 / bright-black background) so they11+// TerminalColor describes a theme colour in one of the colour spaces
12-// match the user's terminal theme rather than the fixed 256-colour12+// terminals commonly support. Every semantic theme colour uses this type;
13-// cube.13+// xterm-256, ANSI, and RGB values are rendered according to terminal capability.
14 type TerminalColor struct {14 type TerminalColor struct {
15 Mode terminalColorMode15 Mode terminalColorMode
16 Index int16 Index int
@@ -33,14 +33,6 @@ func ColorRGB(r, g, b int) TerminalColor {
33 return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b}33 return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b}
34 }34 }
35 35
36-// ColorMode is the output color depth used by an inherited theme.
37-type ColorMode uint8
38-
39-const (
40- ColorMode256 ColorMode = iota
41- ColorModeTrueColor
42-)
43-
44 // TerminalProfile is the best-effort color snapshot reported by the36 // TerminalProfile is the best-effort color snapshot reported by the
45 // controlling terminal. The ANSI palette is optional; unknown slots are37 // controlling terminal. The ANSI palette is optional; unknown slots are
46 // resolved through the classic xterm-256 palette.38 // resolved through the classic xterm-256 palette.
@@ -66,31 +58,31 @@ func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) {
66 return p.Palette[index], true58 return p.Palette[index], true
67 }59 }
68 60
69-// ANSI 256-color palette used by zot. Defined as numeric codes so we61+// Semantic palette used by zot. Each role is a TerminalColor so themes can
70-// can swap themes without changing any render code.62+// use indexed, ANSI, or RGB values without changing render code.
71 type Theme struct {63 type Theme struct {
72- FG int64+ FG TerminalColor
73- Muted int65+ Muted TerminalColor
74- Accent int66+ Accent TerminalColor
75 Background *TerminalColor // optional full-row TUI background; nil keeps terminal default67 Background *TerminalColor // optional full-row TUI background; nil keeps terminal default
76- User int // label color for the user role68+ User TerminalColor // label color for the user role
77 UserBubbleBG TerminalColor // background tint behind user message rows69 UserBubbleBG TerminalColor // background tint behind user message rows
78- UserBubbleFG int // foreground colour for user message rows70+ UserBubbleFG TerminalColor // foreground colour for user message rows
79- Assistant int // label color for the zot role71+ Assistant TerminalColor // label color for the zot role
80- Tool int72+ Tool TerminalColor
81- ToolOut int73+ ToolOut TerminalColor
82- Error int74+ Error TerminalColor
83- Warning int75+ Warning TerminalColor
84- Spinner int // spinner + funny working line76+ Spinner TerminalColor // spinner + funny working line
85- ThinkingMax int // status color for the opt-in max reasoning level77+ ThinkingMax TerminalColor // status color for the opt-in max reasoning level
86- SelectionBG int // background for highlighted rows78+ SelectionBG TerminalColor // background for highlighted rows
87- SelectionFG int // foreground for highlighted rows79+ SelectionFG TerminalColor // foreground for highlighted rows
88-80+
89- // Inherited selects terminal-owned colors and uses ColorMode to choose81+ // Inherited selects terminal-owned colors. Terminal.TrueColor controls
90- // truecolor or a best-effort xterm-256 fallback. Explicit zot themes keep82+ // whether fixed indexed values are emitted as xterm-256 or truecolor and
91- // the historical indexed output because their values are already fixed.83+ // whether RGB values are quantized for terminals without direct-color
84+ // support.
92 Inherited bool85 Inherited bool
93- ColorMode ColorMode
94 Terminal TerminalProfile86 Terminal TerminalProfile
95 87
96 SpinnerFrames []string88 SpinnerFrames []string
@@ -188,21 +180,21 @@ var defaultSpinnerMessages = []string{
188 }180 }
189 181
190 var Dark = Theme{182 var Dark = Theme{
191- FG: 253,183+ FG: Color256(253),
192- Muted: 244,184+ Muted: Color256(244),
193- Accent: 111, // soft blue185+ Accent: Color256(111), // soft blue
194- User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat)186+ User: Color256(180), // warm tan (unused now that the speaker label is gone, kept for skin compat)
195 UserBubbleBG: ColorRGB(66, 69, 75), // #42454B187 UserBubbleBG: ColorRGB(66, 69, 75), // #42454B
196- UserBubbleFG: 248, // slightly lighter grey for readability on #42454B188+ UserBubbleFG: Color256(248), // slightly lighter grey for readability on #42454B
197- Assistant: 117, // bright cyan — the zot label color189+ Assistant: Color256(117), // bright cyan — the zot label color
198- Tool: 114, // green190+ Tool: Color256(114), // green
199- ToolOut: 245,191+ ToolOut: Color256(245),
200- Error: 203,192+ Error: Color256(203),
201- Warning: 214,193+ Warning: Color256(214),
202- Spinner: 183, // soft purple194+ Spinner: Color256(183), // soft purple
203- ThinkingMax: 207, // vivid magenta195+ ThinkingMax: Color256(207), // vivid magenta
204- SelectionBG: 24, // deep blue background196+ SelectionBG: Color256(24), // deep blue background
205- SelectionFG: 231, // near-white foreground197+ SelectionFG: Color256(231), // near-white foreground
206 SpinnerFrames: defaultSpinnerFrames,198 SpinnerFrames: defaultSpinnerFrames,
207 SpinnerMessages: defaultSpinnerMessages,199 SpinnerMessages: defaultSpinnerMessages,
208 SpinnerIntervalMS: 80,200 SpinnerIntervalMS: 80,
@@ -211,21 +203,21 @@ var Dark = Theme{
211 }203 }
212 204
213 var Light = Theme{205 var Light = Theme{
214- FG: 236,206+ FG: Color256(236),
215- Muted: 244,207+ Muted: Color256(244),
216- Accent: 33,208+ Accent: Color256(33),
217- User: 94,209+ User: Color256(94),
218 UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme210 UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme
219- UserBubbleFG: 240, // dark grey text, legible on the pale panel211+ UserBubbleFG: Color256(240), // dark grey text, legible on the pale panel
220- Assistant: 31, // deep cyan212+ Assistant: Color256(31), // deep cyan
221- Tool: 28,213+ Tool: Color256(28),
222- ToolOut: 240,214+ ToolOut: Color256(240),
223- Error: 160,215+ Error: Color256(160),
224- Warning: 166,216+ Warning: Color256(166),
225- Spinner: 91, // purple217+ Spinner: Color256(91), // purple
226- ThinkingMax: 127, // deep magenta218+ ThinkingMax: Color256(127), // deep magenta
227- SelectionBG: 153, // light blue219+ SelectionBG: Color256(153), // light blue
228- SelectionFG: 232, // near-black220+ SelectionFG: Color256(232), // near-black
229 SpinnerFrames: defaultSpinnerFrames,221 SpinnerFrames: defaultSpinnerFrames,
230 SpinnerMessages: defaultSpinnerMessages,222 SpinnerMessages: defaultSpinnerMessages,
231 SpinnerIntervalMS: 80,223 SpinnerIntervalMS: 80,
@@ -233,43 +225,48 @@ var Light = Theme{
233 Syntax: nordSyntax,225 Syntax: nordSyntax,
234 }226 }
235 227
236-// FG256 wraps s in a foreground color. Explicit themes retain the228+// FGColor wraps s in a foreground color. The name is intentionally explicit
237-// historical ANSI 256-color sequence; inherited themes resolve terminal229+// because the color may be xterm-256, ANSI, or RGB.
238-// palette entries and emit truecolor when the terminal advertises it.230+func (t Theme) FGColor(c TerminalColor, s string) string {
239-func (t Theme) FG256(c int, s string) string {
240 return t.fgPrefix(c) + s + reset231 return t.fgPrefix(c) + s + reset
241 }232 }
242 233
243-// BG256 wraps s in a background color using the theme's output capability.234+// FG256 retains the historical helper name while accepting the full
244-// Useful when the visible cell needs a coloured fill but the underlying235+// TerminalColor model.
245-// character should be a regular space (so mouse-copy yields whitespace).236+//
246-func (t Theme) BG256(c int, s string) string {237+// Deprecated: use FGColor.
247- return t.bgPrefix(Color256(c)) + s + reset238+func (t Theme) FG256(c TerminalColor, s string) string {
239+ return t.FGColor(c, s)
248 }240 }
249 241
250-// BG wraps s in a terminal background color. Inherited themes quantize RGB242+// BG256 retains the historical helper name while accepting the full
251-// values on terminals without truecolor support.243+// TerminalColor model.
244+//
245+// Deprecated: use BG.
246+func (t Theme) BG256(c TerminalColor, s string) string {
247+ return t.bgPrefix(c) + s + reset
248+}
249+
250+// BG wraps s in a terminal background color. RGB values are quantized when
251+// the active terminal does not advertise truecolor support.
252 func (t Theme) BG(c TerminalColor, s string) string {252 func (t Theme) BG(c TerminalColor, s string) string {
253 return t.bgPrefix(c) + s + reset253 return t.bgPrefix(c) + s + reset
254 }254 }
255 255
256-func (t Theme) fgPrefix(index int) string {256+func (t Theme) fgPrefix(color TerminalColor) string {
257- if t.Inherited && index == t.FG && t.Terminal.HasForeground {257+ return sgrFGColor(t.resolveColor(color))
258- return sgrFGColor(t.resolveColor(t.Terminal.Foreground))
259- }
260- return sgrFGColor(t.resolveColor(Color256(index)))
261 }258 }
262 259
263 func (t Theme) bgPrefix(color TerminalColor) string {260 func (t Theme) bgPrefix(color TerminalColor) string {
264 return sgrBGColor(t.resolveColor(color))261 return sgrBGColor(t.resolveColor(color))
265 }262 }
266 263
267-// DimColor fades an indexed color toward the terminal background. It is used264+// DimColor fades a theme color toward the terminal background. It is used
268-// by inherited theme construction for muted surfaces and is also available to265+// by inherited theme construction and is also available to callers that need
269-// callers that need terminal-aware dimming for overlays.266+// terminal-aware dimming for overlays.
270-func (t Theme) DimColor(index, percent int) TerminalColor {267+func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor {
271 percent = clampPercent(percent)268 percent = clampPercent(percent)
272- foreground := t.resolveForeground(index)269+ foreground := t.resolveColor(color)
273 background := t.Terminal.Background270 background := t.Terminal.Background
274 if !t.Terminal.HasBackground {271 if !t.Terminal.HasBackground {
275 background = Color256(0)272 background = Color256(0)
@@ -283,36 +280,32 @@ func (t Theme) DimColor(index, percent int) TerminalColor {
283 ))280 ))
284 }281 }
285 282
286-func (t Theme) resolveForeground(index int) TerminalColor {
287- if t.Inherited && index == t.FG && t.Terminal.HasForeground {
288- return t.resolveColor(t.Terminal.Foreground)
289- }
290- return t.resolveColor(Color256(index))
291-}
292-
293 func (t Theme) resolveColor(color TerminalColor) TerminalColor {283 func (t Theme) resolveColor(color TerminalColor) TerminalColor {
294 switch color.Mode {284 switch color.Mode {
295 case terminalColor256:285 case terminalColor256:
296 index := clampXtermIndex(color.Index)286 index := clampXtermIndex(color.Index)
297- if t.Inherited {287+ if t.Terminal.TrueColor {
298- if palette, ok := t.Terminal.PaletteColor(index); ok {288+ if t.Inherited {
299- if t.ColorMode == ColorModeTrueColor {289+ if palette, ok := t.Terminal.PaletteColor(index); ok {
300 return palette290 return palette
301 }291 }
302- return Color256(index)
303- }
304- if t.ColorMode == ColorModeTrueColor {
305- r, g, b := xterm256RGB(index)
306- return ColorRGB(r, g, b)
307 }292 }
308- }
309- if t.ColorMode == ColorModeTrueColor {
310 r, g, b := xterm256RGB(index)293 r, g, b := xterm256RGB(index)
311 return ColorRGB(r, g, b)294 return ColorRGB(r, g, b)
312 }295 }
313 return Color256(index)296 return Color256(index)
297+ case terminalColorANSI:
298+ if t.Inherited && t.Terminal.TrueColor {
299+ if index, ok := ansiSGRToXtermIndex(color.Index); ok {
300+ if palette, ok := t.Terminal.PaletteColor(index); ok {
301+ return palette
302+ }
303+ r, g, b := xterm256RGB(index)
304+ return ColorRGB(r, g, b)
305+ }
306+ }
314 case terminalColorRGB:307 case terminalColorRGB:
315- if t.Inherited && t.ColorMode == ColorMode256 {308+ if !t.Terminal.TrueColor {
316 return Color256(nearestXtermColor(color.R, color.G, color.B))309 return Color256(nearestXtermColor(color.R, color.G, color.B))
317 }310 }
318 }311 }
@@ -322,7 +315,7 @@ func (t Theme) resolveColor(color TerminalColor) TerminalColor {
322 func sgrFGColor(color TerminalColor) string {315 func sgrFGColor(color TerminalColor) string {
323 switch color.Mode {316 switch color.Mode {
324 case terminalColorANSI:317 case terminalColorANSI:
325- return "\x1b[" + itoa(color.Index) + "m"318+ return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m"
326 case terminalColorRGB:319 case terminalColorRGB:
327 return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m"320 return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m"
328 default:321 default:
@@ -338,8 +331,8 @@ func (t Theme) sgrBGColor(color TerminalColor) string {
338 // glyph followed by a plain space gutter. Used as the speaker-label331 // glyph followed by a plain space gutter. Used as the speaker-label
339 // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so332 // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so
340 // the bar reads consistently across the UI.333 // the bar reads consistently across the UI.
341-func (t Theme) AccentBar(c int) string {334+func (t Theme) AccentBar(c TerminalColor) string {
342- return t.FG256(c, "▌ ")335+ return t.FGColor(c, "▌ ")
343 }336 }
344 337
345 // Highlight paints s with the theme's selection colors (foreground +338 // Highlight paints s with the theme's selection colors (foreground +
@@ -351,7 +344,7 @@ func (t Theme) Highlight(s string) string {
351 344
352 // SelectionStyle returns the SGR prefix for the theme's selected row.345 // SelectionStyle returns the SGR prefix for the theme's selected row.
353 func (t Theme) SelectionStyle() string {346 func (t Theme) SelectionStyle() string {
354- return t.fgPrefix(t.SelectionFG) + t.bgPrefix(Color256(t.SelectionBG))347+ return t.fgPrefix(t.SelectionFG) + t.bgPrefix(t.SelectionBG)
355 }348 }
356 349
357 // BackgroundStyle returns the SGR prefix for the optional full-row350 // BackgroundStyle returns the SGR prefix for the optional full-row
@@ -367,8 +360,8 @@ func (t Theme) BackgroundStyle() string {
367 // SelectionStyleFG returns the SGR prefix for selected-row text with360 // SelectionStyleFG returns the SGR prefix for selected-row text with
368 // a custom foreground. Useful for preserving semantic marks inside a361 // a custom foreground. Useful for preserving semantic marks inside a
369 // highlighted row without hardcoding escape sequences outside theme.go.362 // highlighted row without hardcoding escape sequences outside theme.go.
370-func (t Theme) SelectionStyleFG(fg int) string {363+func (t Theme) SelectionStyleFG(fg TerminalColor) string {
371- return t.fgPrefix(fg) + t.bgPrefix(Color256(t.SelectionBG))364+ return t.fgPrefix(fg) + t.bgPrefix(t.SelectionBG)
372 }365 }
373 366
374 // PadHighlight styles s and extends the selection background to the367 // PadHighlight styles s and extends the selection background to the
@@ -404,7 +397,7 @@ func (t Theme) UserBubbleRow(content string, width int) string {
404 // Bar plus a single space gutter, in the assistant accent colour397 // Bar plus a single space gutter, in the assistant accent colour
405 // so it matches the tool-box / app accent and reads as zot's voice398 // so it matches the tool-box / app accent and reads as zot's voice
406 // marker. Two cells wide.399 // marker. Two cells wide.
407- bar := t.FG256(t.Assistant, "▌ ")400+ bar := t.FGColor(t.Assistant, "▌ ")
408 bubbleW := width - 2401 bubbleW := width - 2
409 if bubbleW < 1 {402 if bubbleW < 1 {
410 bubbleW = 1403 bubbleW = 1
@@ -428,7 +421,7 @@ func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m"
428 func sgrBGColor(c TerminalColor) string {421 func sgrBGColor(c TerminalColor) string {
429 switch c.Mode {422 switch c.Mode {
430 case terminalColorANSI:423 case terminalColorANSI:
431- return "\x1b[" + itoa(c.Index) + "m"424+ return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m"
432 case terminalColorRGB:425 case terminalColorRGB:
433 return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m"426 return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m"
434 default:427 default:
@@ -497,14 +490,52 @@ func ansiSGRToXtermIndex(sgr int) (int, bool) {
497 }490 }
498 }491 }
499 492
493+func ansiForegroundSGR(sgr int) int {
494+ switch {
495+ case sgr >= 40 && sgr <= 47:
496+ return sgr - 10
497+ case sgr >= 100 && sgr <= 107:
498+ return sgr - 10
499+ default:
500+ return sgr
501+ }
502+}
503+
504+func ansiBackgroundSGR(sgr int) int {
505+ switch {
506+ case sgr >= 30 && sgr <= 37:
507+ return sgr + 10
508+ case sgr >= 90 && sgr <= 97:
509+ return sgr + 10
510+ default:
511+ return sgr
512+ }
513+}
514+
500 func blendChannel(from, to, percent int) int {515 func blendChannel(from, to, percent int) int {
501 return clampByte((from*(100-percent) + to*percent + 50) / 100)516 return clampByte((from*(100-percent) + to*percent + 50) / 100)
502 }517 }
503 518
519+const xtermColorCacheSize = 256
520+
521+type xtermColorCacheEntry struct {
522+ value atomic.Uint64
523+}
524+
525+var xtermColorCache [xtermColorCacheSize]xtermColorCacheEntry
526+
504 func nearestXtermColor(r, g, b int) int {527 func nearestXtermColor(r, g, b int) int {
505 r = clampByte(r)528 r = clampByte(r)
506 g = clampByte(g)529 g = clampByte(g)
507 b = clampByte(b)530 b = clampByte(b)
531+ key := uint64(r)<<16 | uint64(g)<<8 | uint64(b)
532+ entry := &xtermColorCache[(key*2654435761)&(xtermColorCacheSize-1)]
533+ cached := entry.value.Load()
534+ keyPrefix := (key + 1) << 8
535+ if cached&^uint64(0xff) == keyPrefix {
536+ return int(cached & 0xff)
537+ }
538+
508 best := 0539 best := 0
509 bestDistance := int(^uint(0) >> 1)540 bestDistance := int(^uint(0) >> 1)
510 for index := 0; index < 256; index++ {541 for index := 0; index < 256; index++ {
@@ -518,6 +549,7 @@ func nearestXtermColor(r, g, b int) int {
518 bestDistance = distance549 bestDistance = distance
519 }550 }
520 }551 }
552+ entry.value.Store(keyPrefix | uint64(best))
521 return best553 return best
522 }554 }
523 555
M packages/tui/theme_inherited_test.go +47 -11
@@ -70,6 +70,34 @@ func TestParseOSCColorResponse(t *testing.T) {
7070 }
7171 }
7272
73+func TestANSIThemeColorsAdaptToTargetSGR(t *testing.T) {
74+ if got := Dark.FGColor(ColorANSI(100), "x"); got != "\x1b[90mx\x1b[0m" {
75+ t.Fatalf("ANSI foreground = %q, want bright-black foreground", got)
76+ }
77+ if got := Dark.BG(ColorANSI(31), "x"); got != "\x1b[41mx\x1b[0m" {
78+ t.Fatalf("ANSI background = %q, want red background", got)
79+ }
80+}
81+
82+func TestNearestXtermColorCacheIsStable(t *testing.T) {
83+ colors := [][3]int{
84+ {0, 0, 0},
85+ {66, 69, 75},
86+ {200, 100, 50},
87+ {-20, 300, 128},
88+ }
89+ for _, color := range colors {
90+ first := nearestXtermColor(color[0], color[1], color[2])
91+ second := nearestXtermColor(color[0], color[1], color[2])
92+ if first != second {
93+ t.Fatalf("nearest color changed for %#v: first=%d second=%d", color, first, second)
94+ }
95+ if first < 0 || first > 255 {
96+ t.Fatalf("nearest color out of range for %#v: %d", color, first)
97+ }
98+ }
99+}
100+
73101 func inheritedTestTheme(trueColor bool) Theme {
74102 detected := Dark
75103 detected.Terminal = TerminalProfile{
@@ -88,13 +116,13 @@ func inheritedTestTheme(trueColor bool) Theme {
88116
89117 func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) {
90118 th := inheritedTestTheme(true)
91- if !th.Inherited || th.ColorMode != ColorModeTrueColor {
92- t.Fatalf("inherited theme mode = (inherited=%v, colorMode=%v), want truecolor", th.Inherited, th.ColorMode)
119+ if !th.Inherited || !th.Terminal.TrueColor {
120+ t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want truecolor", th.Inherited, th.Terminal.TrueColor)
93121 }
94- if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") {
122+ if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") {
95123 t.Fatalf("accent did not use the reported terminal palette: %q", got)
96124 }
97- if got := th.FG256(th.FG, "x"); !strings.Contains(got, "\x1b[38;2;220;210;200m") {
125+ if got := th.FGColor(th.FG, "x"); !strings.Contains(got, "\x1b[38;2;220;210;200m") {
98126 t.Fatalf("foreground did not inherit the terminal default: %q", got)
99127 }
100128 if got := th.UserBubble("x", 3); !strings.Contains(got, "\x1b[48;2;") {
@@ -110,10 +138,10 @@ func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) {
110138
111139 func TestInheritedThemeFallsBackTo256Colors(t *testing.T) {
112140 th := inheritedTestTheme(false)
113- if !th.Inherited || th.ColorMode != ColorMode256 {
114- t.Fatalf("inherited theme mode = (inherited=%v, colorMode=%v), want 256", th.Inherited, th.ColorMode)
141+ if !th.Inherited || th.Terminal.TrueColor {
142+ t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want 256", th.Inherited, th.Terminal.TrueColor)
115143 }
116- if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") {
144+ if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") {
117145 t.Fatalf("accent did not use 256-color fallback: %q", got)
118146 }
119147 if got := th.UserBubble("x", 3); strings.Contains(got, "\x1b[48;2;") || !strings.Contains(got, "\x1b[48;5;") {
@@ -127,12 +155,20 @@ func TestInheritedThemeFallsBackTo256Colors(t *testing.T) {
127155 }
128156 }
129157
130-func TestExplicitThemesRemainIndexed(t *testing.T) {
131- if got, want := Dark.FG256(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want {
158+func TestExplicitThemesFollowTerminalCapability(t *testing.T) {
159+ if got, want := Dark.FGColor(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want {
132160 t.Fatalf("explicit theme output = %q, want %q", got, want)
133161 }
134- if got := Dark.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;2;66;69;75m") {
135- t.Fatalf("explicit RGB bubble changed output mode: %q", got)
162+ if got := Dark.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;5;") || strings.Contains(got, "\x1b[48;2;") {
163+ t.Fatalf("non-truecolor RGB bubble did not quantize: %q", got)
164+ }
165+
166+ trueColor := withTerminalProfile(Dark, Theme{Terminal: TerminalProfile{TrueColor: true}})
167+ if got := trueColor.FGColor(trueColor.Accent, "x"); !strings.Contains(got, "\x1b[38;2;") {
168+ t.Fatalf("truecolor explicit theme did not emit RGB: %q", got)
169+ }
170+ if got := trueColor.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;2;66;69;75m") {
171+ t.Fatalf("truecolor explicit RGB bubble did not emit RGB: %q", got)
136172 }
137173 }
138174
@@ -70,6 +70,34 @@ func TestParseOSCColorResponse(t *testing.T) {
70 }70 }
71 }71 }
72 72
73+func TestANSIThemeColorsAdaptToTargetSGR(t *testing.T) {
74+ if got := Dark.FGColor(ColorANSI(100), "x"); got != "\x1b[90mx\x1b[0m" {
75+ t.Fatalf("ANSI foreground = %q, want bright-black foreground", got)
76+ }
77+ if got := Dark.BG(ColorANSI(31), "x"); got != "\x1b[41mx\x1b[0m" {
78+ t.Fatalf("ANSI background = %q, want red background", got)
79+ }
80+}
81+
82+func TestNearestXtermColorCacheIsStable(t *testing.T) {
83+ colors := [][3]int{
84+ {0, 0, 0},
85+ {66, 69, 75},
86+ {200, 100, 50},
87+ {-20, 300, 128},
88+ }
89+ for _, color := range colors {
90+ first := nearestXtermColor(color[0], color[1], color[2])
91+ second := nearestXtermColor(color[0], color[1], color[2])
92+ if first != second {
93+ t.Fatalf("nearest color changed for %#v: first=%d second=%d", color, first, second)
94+ }
95+ if first < 0 || first > 255 {
96+ t.Fatalf("nearest color out of range for %#v: %d", color, first)
97+ }
98+ }
99+}
100+
73 func inheritedTestTheme(trueColor bool) Theme {101 func inheritedTestTheme(trueColor bool) Theme {
74 detected := Dark102 detected := Dark
75 detected.Terminal = TerminalProfile{103 detected.Terminal = TerminalProfile{
@@ -88,13 +116,13 @@ func inheritedTestTheme(trueColor bool) Theme {
88 116
89 func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) {117 func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) {
90 th := inheritedTestTheme(true)118 th := inheritedTestTheme(true)
91- if !th.Inherited || th.ColorMode != ColorModeTrueColor {119+ if !th.Inherited || !th.Terminal.TrueColor {
92- t.Fatalf("inherited theme mode = (inherited=%v, colorMode=%v), want truecolor", th.Inherited, th.ColorMode)120+ t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want truecolor", th.Inherited, th.Terminal.TrueColor)
93 }121 }
94- if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") {122+ if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") {
95 t.Fatalf("accent did not use the reported terminal palette: %q", got)123 t.Fatalf("accent did not use the reported terminal palette: %q", got)
96 }124 }
97- if got := th.FG256(th.FG, "x"); !strings.Contains(got, "\x1b[38;2;220;210;200m") {125+ if got := th.FGColor(th.FG, "x"); !strings.Contains(got, "\x1b[38;2;220;210;200m") {
98 t.Fatalf("foreground did not inherit the terminal default: %q", got)126 t.Fatalf("foreground did not inherit the terminal default: %q", got)
99 }127 }
100 if got := th.UserBubble("x", 3); !strings.Contains(got, "\x1b[48;2;") {128 if got := th.UserBubble("x", 3); !strings.Contains(got, "\x1b[48;2;") {
@@ -110,10 +138,10 @@ func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) {
110 138
111 func TestInheritedThemeFallsBackTo256Colors(t *testing.T) {139 func TestInheritedThemeFallsBackTo256Colors(t *testing.T) {
112 th := inheritedTestTheme(false)140 th := inheritedTestTheme(false)
113- if !th.Inherited || th.ColorMode != ColorMode256 {141+ if !th.Inherited || th.Terminal.TrueColor {
114- t.Fatalf("inherited theme mode = (inherited=%v, colorMode=%v), want 256", th.Inherited, th.ColorMode)142+ t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want 256", th.Inherited, th.Terminal.TrueColor)
115 }143 }
116- if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") {144+ if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") {
117 t.Fatalf("accent did not use 256-color fallback: %q", got)145 t.Fatalf("accent did not use 256-color fallback: %q", got)
118 }146 }
119 if got := th.UserBubble("x", 3); strings.Contains(got, "\x1b[48;2;") || !strings.Contains(got, "\x1b[48;5;") {147 if got := th.UserBubble("x", 3); strings.Contains(got, "\x1b[48;2;") || !strings.Contains(got, "\x1b[48;5;") {
@@ -127,12 +155,20 @@ func TestInheritedThemeFallsBackTo256Colors(t *testing.T) {
127 }155 }
128 }156 }
129 157
130-func TestExplicitThemesRemainIndexed(t *testing.T) {158+func TestExplicitThemesFollowTerminalCapability(t *testing.T) {
131- if got, want := Dark.FG256(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want {159+ if got, want := Dark.FGColor(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want {
132 t.Fatalf("explicit theme output = %q, want %q", got, want)160 t.Fatalf("explicit theme output = %q, want %q", got, want)
133 }161 }
134- if got := Dark.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;2;66;69;75m") {162+ if got := Dark.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;5;") || strings.Contains(got, "\x1b[48;2;") {
135- t.Fatalf("explicit RGB bubble changed output mode: %q", got)163+ t.Fatalf("non-truecolor RGB bubble did not quantize: %q", got)
164+ }
165+
166+ trueColor := withTerminalProfile(Dark, Theme{Terminal: TerminalProfile{TrueColor: true}})
167+ if got := trueColor.FGColor(trueColor.Accent, "x"); !strings.Contains(got, "\x1b[38;2;") {
168+ t.Fatalf("truecolor explicit theme did not emit RGB: %q", got)
169+ }
170+ if got := trueColor.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;2;66;69;75m") {
171+ t.Fatalf("truecolor explicit RGB bubble did not emit RGB: %q", got)
136 }172 }
137 }173 }
138 174
M packages/tui/theme_loader.go +63 -94
@@ -79,23 +79,23 @@ func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error {
7979 // override only the colors it cares about and inherit the built-in
8080 // dark/light defaults for everything else.
8181 type ThemeOverrides struct {
82- FG *int `json:"fg,omitempty"`
83- Muted *int `json:"muted,omitempty"`
84- Accent *int `json:"accent,omitempty"`
82+ FG *TerminalColorValue `json:"fg,omitempty"`
83+ Muted *TerminalColorValue `json:"muted,omitempty"`
84+ Accent *TerminalColorValue `json:"accent,omitempty"`
8585 Background *TerminalColorValue `json:"background,omitempty"`
86- User *int `json:"user,omitempty"`
86+ User *TerminalColorValue `json:"user,omitempty"`
8787 UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"`
88- UserBubbleFG *int `json:"user_bubble_fg,omitempty"`
89- Assistant *int `json:"assistant,omitempty"`
90- Tool *int `json:"tool,omitempty"`
91- ToolOut *int `json:"tool_out,omitempty"`
92- Error *int `json:"error,omitempty"`
93- Warning *int `json:"warning,omitempty"`
94- Spinner *int `json:"spinner,omitempty"`
95- ThinkingMax *int `json:"thinking_max,omitempty"`
96- ThinkingMaxCamel *int `json:"thinkingMax,omitempty"`
97- SelectionBG *int `json:"selection_bg,omitempty"`
98- SelectionFG *int `json:"selection_fg,omitempty"`
88+ UserBubbleFG *TerminalColorValue `json:"user_bubble_fg,omitempty"`
89+ Assistant *TerminalColorValue `json:"assistant,omitempty"`
90+ Tool *TerminalColorValue `json:"tool,omitempty"`
91+ ToolOut *TerminalColorValue `json:"tool_out,omitempty"`
92+ Error *TerminalColorValue `json:"error,omitempty"`
93+ Warning *TerminalColorValue `json:"warning,omitempty"`
94+ Spinner *TerminalColorValue `json:"spinner,omitempty"`
95+ ThinkingMax *TerminalColorValue `json:"thinking_max,omitempty"`
96+ ThinkingMaxCamel *TerminalColorValue `json:"thinkingMax,omitempty"`
97+ SelectionBG *TerminalColorValue `json:"selection_bg,omitempty"`
98+ SelectionFG *TerminalColorValue `json:"selection_fg,omitempty"`
9999 SpinnerFrames []string `json:"spinner_frames,omitempty"`
100100 SpinnerMessages []string `json:"spinner_messages,omitempty"`
101101 SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"`
@@ -187,51 +187,34 @@ func InheritedTheme(detected Theme) Theme {
187187 }
188188 t := withTerminalProfile(base, detected)
189189 t.Inherited = true
190- t.ColorMode = ColorMode256
191- if t.Terminal.TrueColor {
192- t.ColorMode = ColorModeTrueColor
193- }
194190
195191 if t.Terminal.HasForeground {
196- t.FG = terminalColorIndex(t.Terminal.Foreground)
192+ t.FG = t.Terminal.Foreground
197193 }
198194
199195 accentFallback := t.Accent
200196 if IsLightTheme(t) {
201197 accentFallback = Light.Accent
202198 }
203- t.Accent = terminalPaletteIndex(t.Terminal, accentFallback, 12, 4, 14, 6, 13, 5)
199+ t.Accent = terminalPaletteColor(t.Terminal, accentFallback,
200+ ansiBrightBlue, ansiBlue, ansiBrightCyan, ansiCyan, ansiBrightMagenta, ansiMagenta)
204201 t.Assistant = t.Accent
205- t.Tool = terminalPaletteIndex(t.Terminal, t.Tool, 10, 2)
206- t.Error = terminalPaletteIndex(t.Terminal, t.Error, 9, 1)
207- t.Warning = terminalPaletteIndex(t.Terminal, t.Warning, 11, 3)
208- t.Spinner = terminalPaletteIndex(t.Terminal, t.Spinner, 13, 5)
209- t.ThinkingMax = terminalPaletteIndex(t.Terminal, t.ThinkingMax, 13, 5)
210- t.User = terminalPaletteIndex(t.Terminal, t.User, 11, 3)
202+ t.Tool = terminalPaletteColor(t.Terminal, t.Tool, ansiBrightGreen, ansiGreen)
203+ t.Error = terminalPaletteColor(t.Terminal, t.Error, ansiBrightRed, ansiRed)
204+ t.Warning = terminalPaletteColor(t.Terminal, t.Warning, ansiBrightYellow, ansiYellow)
205+ t.Spinner = terminalPaletteColor(t.Terminal, t.Spinner, ansiBrightMagenta, ansiMagenta)
206+ t.ThinkingMax = terminalPaletteColor(t.Terminal, t.ThinkingMax, ansiBrightMagenta, ansiMagenta)
207+ t.User = terminalPaletteColor(t.Terminal, t.User, ansiBrightYellow, ansiYellow)
211208
212209 if t.Terminal.HasForeground && t.Terminal.HasBackground {
213- t.Muted = terminalColorIndex(t.DimColor(t.FG, 45))
210+ t.Muted = t.DimColor(t.FG, 45)
214211 t.ToolOut = t.Muted
215212 background := t.Terminal.Background
216213 foreground := t.Terminal.Foreground
217214 t.UserBubbleBG = blendTerminalColors(background, foreground, 12)
218- t.SelectionBG = terminalColorIndex(blendTerminalColors(background, foreground, 25))
215+ t.SelectionBG = blendTerminalColors(background, foreground, 25)
219216 }
220217
221- // FG256 receives an index rather than a semantic role. Keep the inherited
222- // foreground index distinct from accent/status roles so a terminal whose
223- // default foreground happens to be ANSI blue does not recolor every blue
224- // accent as the terminal default foreground.
225- if t.Terminal.HasForeground {
226- used := map[int]bool{
227- t.Muted: true, t.Accent: true, t.Assistant: true, t.Tool: true,
228- t.ToolOut: true, t.Error: true, t.Warning: true, t.Spinner: true,
229- t.ThinkingMax: true, t.User: true, t.SelectionBG: true,
230- }
231- if used[t.FG] {
232- t.FG = nearestXtermColorExcluding(t.Terminal.Foreground, used)
233- }
234- }
235218 t.UserBubbleFG = t.FG
236219 t.SelectionFG = t.FG
237220
@@ -247,28 +230,35 @@ func withTerminalProfile(base, detected Theme) Theme {
247230 return base
248231 }
249232
250-func terminalPaletteIndex(profile TerminalProfile, fallback int, preferred ...int) int {
233+// Standard ANSI palette slots used as inherited role preferences.
234+const (
235+ ansiRed = 1
236+ ansiGreen = 2
237+ ansiYellow = 3
238+ ansiBlue = 4
239+ ansiMagenta = 5
240+ ansiCyan = 6
241+ ansiBrightRed = 9
242+ ansiBrightGreen = 10
243+ ansiBrightYellow = 11
244+ ansiBrightBlue = 12
245+ ansiBrightMagenta = 13
246+ ansiBrightCyan = 14
247+)
248+
249+func terminalPaletteColor(profile TerminalProfile, fallback TerminalColor, preferred ...int) TerminalColor {
251250 for _, index := range preferred {
252251 if _, ok := profile.PaletteColor(index); ok {
253- return index
252+ // Keep the palette slot as the semantic value. Truecolor
253+ // resolution expands it back to the terminal-reported RGB;
254+ // 256-color output preserves the slot rather than re-quantizing
255+ // the reported RGB to a nearby cube entry.
256+ return Color256(index)
254257 }
255258 }
256259 return fallback
257260 }
258261
259-func terminalColorIndex(color TerminalColor) int {
260- if color.Mode == terminalColor256 {
261- return clampXtermIndex(color.Index)
262- }
263- if color.Mode == terminalColorANSI {
264- if index, ok := ansiSGRToXtermIndex(color.Index); ok {
265- return index
266- }
267- return 0
268- }
269- return nearestXtermColor(color.R, color.G, color.B)
270-}
271-
272262 func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
273263 fromRGB, _ := rgbForTerminalColor(from)
274264 toRGB, _ := rgbForTerminalColor(to)
@@ -280,27 +270,6 @@ func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
280270 )
281271 }
282272
283-func nearestXtermColorExcluding(color TerminalColor, excluded map[int]bool) int {
284- rgb, _ := rgbForTerminalColor(color)
285- best := 0
286- bestDistance := int(^uint(0) >> 1)
287- for index := 0; index < 256; index++ {
288- if excluded[index] {
289- continue
290- }
291- cr, cg, cb := xterm256RGB(index)
292- dr := rgb[0] - cr
293- dg := rgb[1] - cg
294- db := rgb[2] - cb
295- distance := dr*dr + dg*dg + db*db
296- if distance < bestDistance {
297- best = index
298- bestDistance = distance
299- }
300- }
301- return best
302-}
303-
304273 // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json
305274 // to detected. Empty/auto/default keeps the built-in detected theme.
306275 // If preferred is set, it may be a theme name, a basename without
@@ -480,55 +449,55 @@ func themeFilesIn(dir string) ([]string, error) {
480449
481450 func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
482451 if o.FG != nil {
483- th.FG = *o.FG
452+ th.FG = o.FG.TerminalColor
484453 }
485454 if o.Muted != nil {
486- th.Muted = *o.Muted
455+ th.Muted = o.Muted.TerminalColor
487456 }
488457 if o.Accent != nil {
489- th.Accent = *o.Accent
458+ th.Accent = o.Accent.TerminalColor
490459 }
491460 if o.Background != nil {
492461 bg := o.Background.TerminalColor
493462 th.Background = &bg
494463 }
495464 if o.User != nil {
496- th.User = *o.User
465+ th.User = o.User.TerminalColor
497466 }
498467 if o.UserBubbleBG != nil {
499468 th.UserBubbleBG = o.UserBubbleBG.TerminalColor
500469 }
501470 if o.UserBubbleFG != nil {
502- th.UserBubbleFG = *o.UserBubbleFG
471+ th.UserBubbleFG = o.UserBubbleFG.TerminalColor
503472 }
504473 if o.Assistant != nil {
505- th.Assistant = *o.Assistant
474+ th.Assistant = o.Assistant.TerminalColor
506475 }
507476 if o.Tool != nil {
508- th.Tool = *o.Tool
477+ th.Tool = o.Tool.TerminalColor
509478 }
510479 if o.ToolOut != nil {
511- th.ToolOut = *o.ToolOut
480+ th.ToolOut = o.ToolOut.TerminalColor
512481 }
513482 if o.Error != nil {
514- th.Error = *o.Error
483+ th.Error = o.Error.TerminalColor
515484 }
516485 if o.Warning != nil {
517- th.Warning = *o.Warning
486+ th.Warning = o.Warning.TerminalColor
518487 }
519488 if o.Spinner != nil {
520- th.Spinner = *o.Spinner
489+ th.Spinner = o.Spinner.TerminalColor
521490 }
522491 if o.ThinkingMax != nil {
523- th.ThinkingMax = *o.ThinkingMax
492+ th.ThinkingMax = o.ThinkingMax.TerminalColor
524493 } else if o.ThinkingMaxCamel != nil {
525- th.ThinkingMax = *o.ThinkingMaxCamel
494+ th.ThinkingMax = o.ThinkingMaxCamel.TerminalColor
526495 }
527496 if o.SelectionBG != nil {
528- th.SelectionBG = *o.SelectionBG
497+ th.SelectionBG = o.SelectionBG.TerminalColor
529498 }
530499 if o.SelectionFG != nil {
531- th.SelectionFG = *o.SelectionFG
500+ th.SelectionFG = o.SelectionFG.TerminalColor
532501 }
533502 if len(o.SpinnerFrames) > 0 {
534503 th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)
@@ -79,23 +79,23 @@ func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error {
79 // override only the colors it cares about and inherit the built-in79 // override only the colors it cares about and inherit the built-in
80 // dark/light defaults for everything else.80 // dark/light defaults for everything else.
81 type ThemeOverrides struct {81 type ThemeOverrides struct {
82- FG *int `json:"fg,omitempty"`82+ FG *TerminalColorValue `json:"fg,omitempty"`
83- Muted *int `json:"muted,omitempty"`83+ Muted *TerminalColorValue `json:"muted,omitempty"`
84- Accent *int `json:"accent,omitempty"`84+ Accent *TerminalColorValue `json:"accent,omitempty"`
85 Background *TerminalColorValue `json:"background,omitempty"`85 Background *TerminalColorValue `json:"background,omitempty"`
86- User *int `json:"user,omitempty"`86+ User *TerminalColorValue `json:"user,omitempty"`
87 UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"`87 UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"`
88- UserBubbleFG *int `json:"user_bubble_fg,omitempty"`88+ UserBubbleFG *TerminalColorValue `json:"user_bubble_fg,omitempty"`
89- Assistant *int `json:"assistant,omitempty"`89+ Assistant *TerminalColorValue `json:"assistant,omitempty"`
90- Tool *int `json:"tool,omitempty"`90+ Tool *TerminalColorValue `json:"tool,omitempty"`
91- ToolOut *int `json:"tool_out,omitempty"`91+ ToolOut *TerminalColorValue `json:"tool_out,omitempty"`
92- Error *int `json:"error,omitempty"`92+ Error *TerminalColorValue `json:"error,omitempty"`
93- Warning *int `json:"warning,omitempty"`93+ Warning *TerminalColorValue `json:"warning,omitempty"`
94- Spinner *int `json:"spinner,omitempty"`94+ Spinner *TerminalColorValue `json:"spinner,omitempty"`
95- ThinkingMax *int `json:"thinking_max,omitempty"`95+ ThinkingMax *TerminalColorValue `json:"thinking_max,omitempty"`
96- ThinkingMaxCamel *int `json:"thinkingMax,omitempty"`96+ ThinkingMaxCamel *TerminalColorValue `json:"thinkingMax,omitempty"`
97- SelectionBG *int `json:"selection_bg,omitempty"`97+ SelectionBG *TerminalColorValue `json:"selection_bg,omitempty"`
98- SelectionFG *int `json:"selection_fg,omitempty"`98+ SelectionFG *TerminalColorValue `json:"selection_fg,omitempty"`
99 SpinnerFrames []string `json:"spinner_frames,omitempty"`99 SpinnerFrames []string `json:"spinner_frames,omitempty"`
100 SpinnerMessages []string `json:"spinner_messages,omitempty"`100 SpinnerMessages []string `json:"spinner_messages,omitempty"`
101 SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"`101 SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"`
@@ -187,51 +187,34 @@ func InheritedTheme(detected Theme) Theme {
187 }187 }
188 t := withTerminalProfile(base, detected)188 t := withTerminalProfile(base, detected)
189 t.Inherited = true189 t.Inherited = true
190- t.ColorMode = ColorMode256
191- if t.Terminal.TrueColor {
192- t.ColorMode = ColorModeTrueColor
193- }
194 190
195 if t.Terminal.HasForeground {191 if t.Terminal.HasForeground {
196- t.FG = terminalColorIndex(t.Terminal.Foreground)192+ t.FG = t.Terminal.Foreground
197 }193 }
198 194
199 accentFallback := t.Accent195 accentFallback := t.Accent
200 if IsLightTheme(t) {196 if IsLightTheme(t) {
201 accentFallback = Light.Accent197 accentFallback = Light.Accent
202 }198 }
203- t.Accent = terminalPaletteIndex(t.Terminal, accentFallback, 12, 4, 14, 6, 13, 5)199+ t.Accent = terminalPaletteColor(t.Terminal, accentFallback,
200+ ansiBrightBlue, ansiBlue, ansiBrightCyan, ansiCyan, ansiBrightMagenta, ansiMagenta)
204 t.Assistant = t.Accent201 t.Assistant = t.Accent
205- t.Tool = terminalPaletteIndex(t.Terminal, t.Tool, 10, 2)202+ t.Tool = terminalPaletteColor(t.Terminal, t.Tool, ansiBrightGreen, ansiGreen)
206- t.Error = terminalPaletteIndex(t.Terminal, t.Error, 9, 1)203+ t.Error = terminalPaletteColor(t.Terminal, t.Error, ansiBrightRed, ansiRed)
207- t.Warning = terminalPaletteIndex(t.Terminal, t.Warning, 11, 3)204+ t.Warning = terminalPaletteColor(t.Terminal, t.Warning, ansiBrightYellow, ansiYellow)
208- t.Spinner = terminalPaletteIndex(t.Terminal, t.Spinner, 13, 5)205+ t.Spinner = terminalPaletteColor(t.Terminal, t.Spinner, ansiBrightMagenta, ansiMagenta)
209- t.ThinkingMax = terminalPaletteIndex(t.Terminal, t.ThinkingMax, 13, 5)206+ t.ThinkingMax = terminalPaletteColor(t.Terminal, t.ThinkingMax, ansiBrightMagenta, ansiMagenta)
210- t.User = terminalPaletteIndex(t.Terminal, t.User, 11, 3)207+ t.User = terminalPaletteColor(t.Terminal, t.User, ansiBrightYellow, ansiYellow)
211 208
212 if t.Terminal.HasForeground && t.Terminal.HasBackground {209 if t.Terminal.HasForeground && t.Terminal.HasBackground {
213- t.Muted = terminalColorIndex(t.DimColor(t.FG, 45))210+ t.Muted = t.DimColor(t.FG, 45)
214 t.ToolOut = t.Muted211 t.ToolOut = t.Muted
215 background := t.Terminal.Background212 background := t.Terminal.Background
216 foreground := t.Terminal.Foreground213 foreground := t.Terminal.Foreground
217 t.UserBubbleBG = blendTerminalColors(background, foreground, 12)214 t.UserBubbleBG = blendTerminalColors(background, foreground, 12)
218- t.SelectionBG = terminalColorIndex(blendTerminalColors(background, foreground, 25))215+ t.SelectionBG = blendTerminalColors(background, foreground, 25)
219 }216 }
220 217
221- // FG256 receives an index rather than a semantic role. Keep the inherited
222- // foreground index distinct from accent/status roles so a terminal whose
223- // default foreground happens to be ANSI blue does not recolor every blue
224- // accent as the terminal default foreground.
225- if t.Terminal.HasForeground {
226- used := map[int]bool{
227- t.Muted: true, t.Accent: true, t.Assistant: true, t.Tool: true,
228- t.ToolOut: true, t.Error: true, t.Warning: true, t.Spinner: true,
229- t.ThinkingMax: true, t.User: true, t.SelectionBG: true,
230- }
231- if used[t.FG] {
232- t.FG = nearestXtermColorExcluding(t.Terminal.Foreground, used)
233- }
234- }
235 t.UserBubbleFG = t.FG218 t.UserBubbleFG = t.FG
236 t.SelectionFG = t.FG219 t.SelectionFG = t.FG
237 220
@@ -247,28 +230,35 @@ func withTerminalProfile(base, detected Theme) Theme {
247 return base230 return base
248 }231 }
249 232
250-func terminalPaletteIndex(profile TerminalProfile, fallback int, preferred ...int) int {233+// Standard ANSI palette slots used as inherited role preferences.
234+const (
235+ ansiRed = 1
236+ ansiGreen = 2
237+ ansiYellow = 3
238+ ansiBlue = 4
239+ ansiMagenta = 5
240+ ansiCyan = 6
241+ ansiBrightRed = 9
242+ ansiBrightGreen = 10
243+ ansiBrightYellow = 11
244+ ansiBrightBlue = 12
245+ ansiBrightMagenta = 13
246+ ansiBrightCyan = 14
247+)
248+
249+func terminalPaletteColor(profile TerminalProfile, fallback TerminalColor, preferred ...int) TerminalColor {
251 for _, index := range preferred {250 for _, index := range preferred {
252 if _, ok := profile.PaletteColor(index); ok {251 if _, ok := profile.PaletteColor(index); ok {
253- return index252+ // Keep the palette slot as the semantic value. Truecolor
253+ // resolution expands it back to the terminal-reported RGB;
254+ // 256-color output preserves the slot rather than re-quantizing
255+ // the reported RGB to a nearby cube entry.
256+ return Color256(index)
254 }257 }
255 }258 }
256 return fallback259 return fallback
257 }260 }
258 261
259-func terminalColorIndex(color TerminalColor) int {
260- if color.Mode == terminalColor256 {
261- return clampXtermIndex(color.Index)
262- }
263- if color.Mode == terminalColorANSI {
264- if index, ok := ansiSGRToXtermIndex(color.Index); ok {
265- return index
266- }
267- return 0
268- }
269- return nearestXtermColor(color.R, color.G, color.B)
270-}
271-
272 func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {262 func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
273 fromRGB, _ := rgbForTerminalColor(from)263 fromRGB, _ := rgbForTerminalColor(from)
274 toRGB, _ := rgbForTerminalColor(to)264 toRGB, _ := rgbForTerminalColor(to)
@@ -280,27 +270,6 @@ func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
280 )270 )
281 }271 }
282 272
283-func nearestXtermColorExcluding(color TerminalColor, excluded map[int]bool) int {
284- rgb, _ := rgbForTerminalColor(color)
285- best := 0
286- bestDistance := int(^uint(0) >> 1)
287- for index := 0; index < 256; index++ {
288- if excluded[index] {
289- continue
290- }
291- cr, cg, cb := xterm256RGB(index)
292- dr := rgb[0] - cr
293- dg := rgb[1] - cg
294- db := rgb[2] - cb
295- distance := dr*dr + dg*dg + db*db
296- if distance < bestDistance {
297- best = index
298- bestDistance = distance
299- }
300- }
301- return best
302-}
303-
304 // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json273 // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json
305 // to detected. Empty/auto/default keeps the built-in detected theme.274 // to detected. Empty/auto/default keeps the built-in detected theme.
306 // If preferred is set, it may be a theme name, a basename without275 // If preferred is set, it may be a theme name, a basename without
@@ -480,55 +449,55 @@ func themeFilesIn(dir string) ([]string, error) {
480 449
481 func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {450 func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
482 if o.FG != nil {451 if o.FG != nil {
483- th.FG = *o.FG452+ th.FG = o.FG.TerminalColor
484 }453 }
485 if o.Muted != nil {454 if o.Muted != nil {
486- th.Muted = *o.Muted455+ th.Muted = o.Muted.TerminalColor
487 }456 }
488 if o.Accent != nil {457 if o.Accent != nil {
489- th.Accent = *o.Accent458+ th.Accent = o.Accent.TerminalColor
490 }459 }
491 if o.Background != nil {460 if o.Background != nil {
492 bg := o.Background.TerminalColor461 bg := o.Background.TerminalColor
493 th.Background = &bg462 th.Background = &bg
494 }463 }
495 if o.User != nil {464 if o.User != nil {
496- th.User = *o.User465+ th.User = o.User.TerminalColor
497 }466 }
498 if o.UserBubbleBG != nil {467 if o.UserBubbleBG != nil {
499 th.UserBubbleBG = o.UserBubbleBG.TerminalColor468 th.UserBubbleBG = o.UserBubbleBG.TerminalColor
500 }469 }
501 if o.UserBubbleFG != nil {470 if o.UserBubbleFG != nil {
502- th.UserBubbleFG = *o.UserBubbleFG471+ th.UserBubbleFG = o.UserBubbleFG.TerminalColor
503 }472 }
504 if o.Assistant != nil {473 if o.Assistant != nil {
505- th.Assistant = *o.Assistant474+ th.Assistant = o.Assistant.TerminalColor
506 }475 }
507 if o.Tool != nil {476 if o.Tool != nil {
508- th.Tool = *o.Tool477+ th.Tool = o.Tool.TerminalColor
509 }478 }
510 if o.ToolOut != nil {479 if o.ToolOut != nil {
511- th.ToolOut = *o.ToolOut480+ th.ToolOut = o.ToolOut.TerminalColor
512 }481 }
513 if o.Error != nil {482 if o.Error != nil {
514- th.Error = *o.Error483+ th.Error = o.Error.TerminalColor
515 }484 }
516 if o.Warning != nil {485 if o.Warning != nil {
517- th.Warning = *o.Warning486+ th.Warning = o.Warning.TerminalColor
518 }487 }
519 if o.Spinner != nil {488 if o.Spinner != nil {
520- th.Spinner = *o.Spinner489+ th.Spinner = o.Spinner.TerminalColor
521 }490 }
522 if o.ThinkingMax != nil {491 if o.ThinkingMax != nil {
523- th.ThinkingMax = *o.ThinkingMax492+ th.ThinkingMax = o.ThinkingMax.TerminalColor
524 } else if o.ThinkingMaxCamel != nil {493 } else if o.ThinkingMaxCamel != nil {
525- th.ThinkingMax = *o.ThinkingMaxCamel494+ th.ThinkingMax = o.ThinkingMaxCamel.TerminalColor
526 }495 }
527 if o.SelectionBG != nil {496 if o.SelectionBG != nil {
528- th.SelectionBG = *o.SelectionBG497+ th.SelectionBG = o.SelectionBG.TerminalColor
529 }498 }
530 if o.SelectionFG != nil {499 if o.SelectionFG != nil {
531- th.SelectionFG = *o.SelectionFG500+ th.SelectionFG = o.SelectionFG.TerminalColor
532 }501 }
533 if len(o.SpinnerFrames) > 0 {502 if len(o.SpinnerFrames) > 0 {
534 th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)503 th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)
M packages/tui/theme_loader_test.go +63 -45
@@ -3,18 +3,25 @@ package tui
33 import (
44 "os"
55 "path/filepath"
6+ "strings"
67 "testing"
78 )
89
9-func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
10+func writeThemeFile(t *testing.T, name, body string) string {
11+ t.Helper()
1012 home := t.TempDir()
1113 if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
1214 t.Fatal(err)
1315 }
14- path := filepath.Join(home, "themes", "partial.json")
15- if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"accent":204}}}`), 0o644); err != nil {
16+ path := filepath.Join(home, "themes", name+".json")
17+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
1618 t.Fatal(err)
1719 }
20+ return home
21+}
22+
23+func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
24+ home := writeThemeFile(t, "partial", `{"colors":{"dark":{"accent":204}}}`)
1825
1926 th, name, err := LoadThemeFromHome(home, "partial", Dark)
2027 if err != nil {
@@ -23,11 +30,11 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
2330 if name != "partial" {
2431 t.Fatalf("name = %q, want partial", name)
2532 }
26- if th.Accent != 204 {
27- t.Fatalf("accent = %d, want 204", th.Accent)
33+ if th.Accent != Color256(204) {
34+ t.Fatalf("accent = %#v, want 204", th.Accent)
2835 }
2936 if th.FG != Dark.FG {
30- t.Fatalf("fg = %d, want inherited %d", th.FG, Dark.FG)
37+ t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG)
3138 }
3239 if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 {
3340 t.Fatal("spinner defaults should be inherited")
@@ -35,33 +42,19 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
3542 }
3643
3744 func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) {
38- home := t.TempDir()
39- if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
40- t.Fatal(err)
41- }
42- path := filepath.Join(home, "themes", "thinking.json")
43- if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"thinkingMax":201}}}`), 0o644); err != nil {
44- t.Fatal(err)
45- }
45+ home := writeThemeFile(t, "thinking", `{"colors":{"dark":{"thinkingMax":201}}}`)
4646
4747 th, _, err := LoadThemeFromHome(home, "thinking", Dark)
4848 if err != nil {
4949 t.Fatal(err)
5050 }
51- if th.ThinkingMax != 201 {
52- t.Fatalf("thinking max = %d, want 201", th.ThinkingMax)
51+ if th.ThinkingMax != Color256(201) {
52+ t.Fatalf("thinking max = %#v, want 201", th.ThinkingMax)
5353 }
5454 }
5555
5656 func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
57- home := t.TempDir()
58- if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
59- t.Fatal(err)
60- }
61- path := filepath.Join(home, "themes", "spinner.json")
62- if err := os.WriteFile(path, []byte(`{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`), 0o644); err != nil {
63- t.Fatal(err)
64- }
57+ home := writeThemeFile(t, "spinner", `{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`)
6558
6659 th, _, err := LoadThemeFromHome(home, "spinner", Dark)
6760 if err != nil {
@@ -74,19 +67,12 @@ func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
7467 t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS)
7568 }
7669 if th.Accent != Dark.Accent {
77- t.Fatalf("accent = %d, want inherited %d", th.Accent, Dark.Accent)
70+ t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent)
7871 }
7972 }
8073
8174 func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
82- home := t.TempDir()
83- if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
84- t.Fatal(err)
85- }
86- path := filepath.Join(home, "themes", "darkonly.json")
87- if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`), 0o644); err != nil {
88- t.Fatal(err)
89- }
75+ home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`)
9076
9177 th, _, err := LoadThemeFromHome(home, "darkonly", Light)
9278 if err != nil {
@@ -102,31 +88,63 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
10288 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
10389 }
10490 if th.FG != Light.FG {
105- t.Fatalf("fg = %d, want inherited light fg %d", th.FG, Light.FG)
91+ t.Fatalf("fg = %#v, want inherited light fg %#v", th.FG, Light.FG)
10692 }
10793 }
10894
10995 func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) {
110- home := t.TempDir()
111- if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
112- t.Fatal(err)
113- }
114- path := filepath.Join(home, "themes", "shared.json")
115- if err := os.WriteFile(path, []byte(`{"colors":{"accent":204,"spinner_messages":["ship"]}}`), 0o644); err != nil {
116- t.Fatal(err)
117- }
96+ home := writeThemeFile(t, "shared", `{"colors":{"accent":204,"spinner_messages":["ship"]}}`)
11897
11998 th, _, err := LoadThemeFromHome(home, "shared", Light)
12099 if err != nil {
121100 t.Fatal(err)
122101 }
123- if th.Accent != 204 {
124- t.Fatalf("accent = %d, want 204", th.Accent)
102+ if th.Accent != Color256(204) {
103+ t.Fatalf("accent = %#v, want 204", th.Accent)
125104 }
126105 if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" {
127106 t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages)
128107 }
129108 if th.FG != Light.FG {
130- t.Fatalf("fg = %d, want inherited %d", th.FG, Light.FG)
109+ t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG)
110+ }
111+}
112+
113+func TestLoadThemeAcceptsRGBSemanticColors(t *testing.T) {
114+ home := writeThemeFile(t, "rgb", `{"colors":{"dark":{"fg":"#123456","muted":{"mode":"rgb","r":18,"g":52,"b":86},"accent":{"mode":"rgb","r":200,"g":100,"b":50},"user": {"mode":"ansi","index":31},"selection_bg":{"mode":"256","index":237},"selection_fg":250}}}`)
115+
116+ detected := Dark
117+ detected.Terminal.TrueColor = true
118+ th, _, err := LoadThemeFromHome(home, "rgb", detected)
119+ if err != nil {
120+ t.Fatal(err)
121+ }
122+ if !th.Terminal.TrueColor {
123+ t.Fatal("terminal color mode = 256, want truecolor")
124+ }
125+ if th.FG != ColorRGB(0x12, 0x34, 0x56) {
126+ t.Fatalf("foreground = %#v, want RGB", th.FG)
127+ }
128+ if th.Accent != ColorRGB(200, 100, 50) {
129+ t.Fatalf("accent = %#v, want RGB", th.Accent)
130+ }
131+ if th.SelectionBG != Color256(237) || th.SelectionFG != Color256(250) {
132+ t.Fatalf("indexed semantic colors were not preserved: bg=%#v fg=%#v", th.SelectionBG, th.SelectionFG)
133+ }
134+ if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;200;100;50m") {
135+ t.Fatalf("RGB accent was not rendered as truecolor: %q", got)
136+ }
137+}
138+
139+func TestLoadThemeQuantizesRGBSemanticColorsWithoutTrueColor(t *testing.T) {
140+ home := writeThemeFile(t, "rgb-fallback", `{"colors":{"dark":{"accent":"#c86432"}}}`)
141+
142+ th, _, err := LoadThemeFromHome(home, "rgb-fallback", Dark)
143+ if err != nil {
144+ t.Fatal(err)
145+ }
146+ want := nearestXtermColor(200, 100, 50)
147+ if got := th.FGColor(th.Accent, "x"); got != sgrFG(want)+"x\x1b[0m" {
148+ t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want)
131149 }
132150 }
@@ -3,18 +3,25 @@ package tui
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6+ "strings"
6 "testing"7 "testing"
7 )8 )
8 9
9-func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {10+func writeThemeFile(t *testing.T, name, body string) string {
11+ t.Helper()
10 home := t.TempDir()12 home := t.TempDir()
11 if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {13 if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
12 t.Fatal(err)14 t.Fatal(err)
13 }15 }
14- path := filepath.Join(home, "themes", "partial.json")16+ path := filepath.Join(home, "themes", name+".json")
15- if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"accent":204}}}`), 0o644); err != nil {17+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
16 t.Fatal(err)18 t.Fatal(err)
17 }19 }
20+ return home
21+}
22+
23+func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
24+ home := writeThemeFile(t, "partial", `{"colors":{"dark":{"accent":204}}}`)
18 25
19 th, name, err := LoadThemeFromHome(home, "partial", Dark)26 th, name, err := LoadThemeFromHome(home, "partial", Dark)
20 if err != nil {27 if err != nil {
@@ -23,11 +30,11 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
23 if name != "partial" {30 if name != "partial" {
24 t.Fatalf("name = %q, want partial", name)31 t.Fatalf("name = %q, want partial", name)
25 }32 }
26- if th.Accent != 204 {33+ if th.Accent != Color256(204) {
27- t.Fatalf("accent = %d, want 204", th.Accent)34+ t.Fatalf("accent = %#v, want 204", th.Accent)
28 }35 }
29 if th.FG != Dark.FG {36 if th.FG != Dark.FG {
30- t.Fatalf("fg = %d, want inherited %d", th.FG, Dark.FG)37+ t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG)
31 }38 }
32 if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 {39 if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 {
33 t.Fatal("spinner defaults should be inherited")40 t.Fatal("spinner defaults should be inherited")
@@ -35,33 +42,19 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
35 }42 }
36 43
37 func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) {44 func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) {
38- home := t.TempDir()45+ home := writeThemeFile(t, "thinking", `{"colors":{"dark":{"thinkingMax":201}}}`)
39- if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
40- t.Fatal(err)
41- }
42- path := filepath.Join(home, "themes", "thinking.json")
43- if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"thinkingMax":201}}}`), 0o644); err != nil {
44- t.Fatal(err)
45- }
46 46
47 th, _, err := LoadThemeFromHome(home, "thinking", Dark)47 th, _, err := LoadThemeFromHome(home, "thinking", Dark)
48 if err != nil {48 if err != nil {
49 t.Fatal(err)49 t.Fatal(err)
50 }50 }
51- if th.ThinkingMax != 201 {51+ if th.ThinkingMax != Color256(201) {
52- t.Fatalf("thinking max = %d, want 201", th.ThinkingMax)52+ t.Fatalf("thinking max = %#v, want 201", th.ThinkingMax)
53 }53 }
54 }54 }
55 55
56 func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {56 func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
57- home := t.TempDir()57+ home := writeThemeFile(t, "spinner", `{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`)
58- if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
59- t.Fatal(err)
60- }
61- path := filepath.Join(home, "themes", "spinner.json")
62- if err := os.WriteFile(path, []byte(`{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`), 0o644); err != nil {
63- t.Fatal(err)
64- }
65 58
66 th, _, err := LoadThemeFromHome(home, "spinner", Dark)59 th, _, err := LoadThemeFromHome(home, "spinner", Dark)
67 if err != nil {60 if err != nil {
@@ -74,19 +67,12 @@ func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
74 t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS)67 t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS)
75 }68 }
76 if th.Accent != Dark.Accent {69 if th.Accent != Dark.Accent {
77- t.Fatalf("accent = %d, want inherited %d", th.Accent, Dark.Accent)70+ t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent)
78 }71 }
79 }72 }
80 73
81 func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {74 func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
82- home := t.TempDir()75+ home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`)
83- if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
84- t.Fatal(err)
85- }
86- path := filepath.Join(home, "themes", "darkonly.json")
87- if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`), 0o644); err != nil {
88- t.Fatal(err)
89- }
90 76
91 th, _, err := LoadThemeFromHome(home, "darkonly", Light)77 th, _, err := LoadThemeFromHome(home, "darkonly", Light)
92 if err != nil {78 if err != nil {
@@ -102,31 +88,63 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
102 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)88 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
103 }89 }
104 if th.FG != Light.FG {90 if th.FG != Light.FG {
105- t.Fatalf("fg = %d, want inherited light fg %d", th.FG, Light.FG)91+ t.Fatalf("fg = %#v, want inherited light fg %#v", th.FG, Light.FG)
106 }92 }
107 }93 }
108 94
109 func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) {95 func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) {
110- home := t.TempDir()96+ home := writeThemeFile(t, "shared", `{"colors":{"accent":204,"spinner_messages":["ship"]}}`)
111- if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
112- t.Fatal(err)
113- }
114- path := filepath.Join(home, "themes", "shared.json")
115- if err := os.WriteFile(path, []byte(`{"colors":{"accent":204,"spinner_messages":["ship"]}}`), 0o644); err != nil {
116- t.Fatal(err)
117- }
118 97
119 th, _, err := LoadThemeFromHome(home, "shared", Light)98 th, _, err := LoadThemeFromHome(home, "shared", Light)
120 if err != nil {99 if err != nil {
121 t.Fatal(err)100 t.Fatal(err)
122 }101 }
123- if th.Accent != 204 {102+ if th.Accent != Color256(204) {
124- t.Fatalf("accent = %d, want 204", th.Accent)103+ t.Fatalf("accent = %#v, want 204", th.Accent)
125 }104 }
126 if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" {105 if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" {
127 t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages)106 t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages)
128 }107 }
129 if th.FG != Light.FG {108 if th.FG != Light.FG {
130- t.Fatalf("fg = %d, want inherited %d", th.FG, Light.FG)109+ t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG)
110+ }
111+}
112+
113+func TestLoadThemeAcceptsRGBSemanticColors(t *testing.T) {
114+ home := writeThemeFile(t, "rgb", `{"colors":{"dark":{"fg":"#123456","muted":{"mode":"rgb","r":18,"g":52,"b":86},"accent":{"mode":"rgb","r":200,"g":100,"b":50},"user": {"mode":"ansi","index":31},"selection_bg":{"mode":"256","index":237},"selection_fg":250}}}`)
115+
116+ detected := Dark
117+ detected.Terminal.TrueColor = true
118+ th, _, err := LoadThemeFromHome(home, "rgb", detected)
119+ if err != nil {
120+ t.Fatal(err)
121+ }
122+ if !th.Terminal.TrueColor {
123+ t.Fatal("terminal color mode = 256, want truecolor")
124+ }
125+ if th.FG != ColorRGB(0x12, 0x34, 0x56) {
126+ t.Fatalf("foreground = %#v, want RGB", th.FG)
127+ }
128+ if th.Accent != ColorRGB(200, 100, 50) {
129+ t.Fatalf("accent = %#v, want RGB", th.Accent)
130+ }
131+ if th.SelectionBG != Color256(237) || th.SelectionFG != Color256(250) {
132+ t.Fatalf("indexed semantic colors were not preserved: bg=%#v fg=%#v", th.SelectionBG, th.SelectionFG)
133+ }
134+ if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;200;100;50m") {
135+ t.Fatalf("RGB accent was not rendered as truecolor: %q", got)
136+ }
137+}
138+
139+func TestLoadThemeQuantizesRGBSemanticColorsWithoutTrueColor(t *testing.T) {
140+ home := writeThemeFile(t, "rgb-fallback", `{"colors":{"dark":{"accent":"#c86432"}}}`)
141+
142+ th, _, err := LoadThemeFromHome(home, "rgb-fallback", Dark)
143+ if err != nil {
144+ t.Fatal(err)
145+ }
146+ want := nearestXtermColor(200, 100, 50)
147+ if got := th.FGColor(th.Accent, "x"); got != sgrFG(want)+"x\x1b[0m" {
148+ t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want)
131 }149 }
132 }150 }
M packages/tui/view.go +65 -63
@@ -325,7 +325,7 @@ func (v *View) renderErr(width int) []string {
325325 if idx > 0 {
326326 prefix = indent
327327 }
328- out = append(out, v.Theme.FG256(v.Theme.Error, prefix+line))
328+ out = append(out, v.Theme.FGColor(v.Theme.Error, prefix+line))
329329 }
330330 return out
331331 }
@@ -340,9 +340,9 @@ func (v *View) renderStartupResources(width int) []string {
340340 if len(items) == 0 {
341341 return
342342 }
343- out = append(out, v.Theme.FG256(v.Theme.Accent, "["+label+"]"))
343+ out = append(out, v.Theme.FGColor(v.Theme.Accent, "["+label+"]"))
344344 for _, line := range wrapLine(strings.Join(items, ", "), bodyWidth, "") {
345- out = append(out, v.Theme.FG256(v.Theme.Muted, " "+line))
345+ out = append(out, v.Theme.FGColor(v.Theme.Muted, " "+line))
346346 }
347347 out = append(out, "")
348348 }
@@ -691,10 +691,10 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
691691 if innerWidth < 1 {
692692 innerWidth = 1
693693 }
694+ bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FGColor(v.Theme.Accent, "▌ "))
694695 row := func(content string) string {
695696 inner := strings.Repeat(" ", leftGutter) + content
696697 padded := v.Theme.UserBubble(inner, width-2)
697- bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))
698698 return bar + padded
699699 }
700700 if v.CompactUser || v.CompactMode {
@@ -708,9 +708,9 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
708708 }
709709 row = func(content string) string {
710710 if v.CompactMode {
711- content = v.Theme.FG256(v.Theme.Muted, content)
711+ content = v.Theme.FGColor(v.Theme.Muted, content)
712712 }
713- return v.Theme.FG256(v.Theme.Accent, "▌ ") + content
713+ return v.Theme.FGColor(v.Theme.Accent, "▌ ") + content
714714 }
715715 }
716716 var bubble []string
@@ -796,7 +796,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
796796 }
797797 lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
798798 if tr.IsError {
799- lines = append(lines, toolBodyLine(v.Theme, v.Theme.FG256(color, " error"), width, v.CompactMode))
799+ lines = append(lines, toolBodyLine(v.Theme, v.Theme.FGColor(color, " error"), width, v.CompactMode))
800800 }
801801 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
802802 _, stripped := parseImageFootprint(line)
@@ -810,7 +810,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
810810 lines = append(lines, toolBoxTop(v.Theme, label, width))
811811 lines = append(lines, toolBoxSide(v.Theme, "", width))
812812 if tr.IsError {
813- lines = append(lines, toolBoxSide(v.Theme, v.Theme.FG256(color, " error"), width))
813+ lines = append(lines, toolBoxSide(v.Theme, v.Theme.FGColor(color, " error"), width))
814814 }
815815 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
816816 // Image-footprint rows (the escape row, the blank
@@ -978,7 +978,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
978978 // renderLiveToolResult uses the transcript's text renderer so every rich
979979 // confirmation preview immediately gets the same colors, numbered gutters,
980980 // and syntax highlighting as its final tool result.
981-func (v *View) renderLiveToolResult(text string, width, color int, sourcePath string) []string {
981+func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string {
982982 return v.renderToolText(text, width, color, sourcePath, 1)
983983 }
984984
@@ -1010,7 +1010,7 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
10101010 // Header line hints which edit is streaming and, when more
10111011 // than one has landed, how many the model is doing.
10121012 hint := fmt.Sprintf("edit %d (streaming)", idx)
1013- body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""}
1013+ body := []string{" " + v.Theme.FGColor(v.Theme.Muted, hint), ""}
10141014 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
10151015 return v.wrapLiveBody(body, width)
10161016 case "bash", "Bash":
@@ -1028,7 +1028,7 @@ func (v *View) renderLiveBashCommand(command string, width int) []string {
10281028 if v.FlatTools || v.CompactMode {
10291029 inner = flatToolBodyRenderWidth(width)
10301030 }
1031- prompt := v.Theme.FG256(v.Theme.Muted, "$ ")
1031+ prompt := v.Theme.FGColor(v.Theme.Muted, "$ ")
10321032 var out []string
10331033 for i, line := range strings.Split(command, "\n") {
10341034 firstPrefix := " "
@@ -1042,7 +1042,7 @@ func (v *View) renderLiveBashCommand(command string, width int) []string {
10421042 if wrapWidth < 10 {
10431043 wrapWidth = 10
10441044 }
1045- for j, wrapped := range wrapANSILine(v.Theme.FG256(v.Theme.ToolOut, line), wrapWidth) {
1045+ for j, wrapped := range wrapANSILine(v.Theme.FGColor(v.Theme.ToolOut, line), wrapWidth) {
10461046 prefix := firstPrefix
10471047 if j > 0 {
10481048 prefix = contPrefix
@@ -1133,7 +1133,7 @@ func toolBoxTop(th Theme, label string, width int) string {
11331133 }
11341134 fillStr := strings.Repeat("─", fill)
11351135 name, rest := splitToolLabel(label)
1136- return margin + th.FG256(th.Muted, prefix) + th.FG256(th.FG, name) + th.FG256(th.Muted, rest+suffix+fillStr+"┐") + margin
1136+ return margin + th.FGColor(th.Muted, prefix) + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest+suffix+fillStr+"┐") + margin
11371137 }
11381138
11391139 func oneLineToolLabel(label string) string {
@@ -1172,7 +1172,7 @@ func flatToolHeader(th Theme, label string, width int) string {
11721172 label = oneLineToolLabel(label)
11731173 name, rest := splitToolLabel(label)
11741174 margin := strings.Repeat(" ", toolBoxOuterMargin)
1175- gutter := th.FG256(th.Assistant, "▌") + " "
1175+ gutter := th.FGColor(th.Assistant, "▌") + " "
11761176 // Budget the visible text to the content width so a very long
11771177 // argument summary doesn't run off the right edge; truncate with
11781178 // an ellipsis like the box header does.
@@ -1189,7 +1189,7 @@ func flatToolHeader(th Theme, label string, width int) string {
11891189 rest = ""
11901190 }
11911191 }
1192- return margin + gutter + th.FG256(th.FG, name) + th.FG256(th.Muted, rest)
1192+ return margin + gutter + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest)
11931193 }
11941194
11951195 func compactToolBlank(th Theme, width int) string {
@@ -1266,7 +1266,7 @@ func toolBoxBottom(th Theme, width int) string {
12661266 }
12671267 margin := strings.Repeat(" ", toolBoxOuterMargin)
12681268 line := "└" + strings.Repeat("─", w-2) + "┘"
1269- return margin + th.FG256(th.Muted, line) + margin
1269+ return margin + th.FGColor(th.Muted, line) + margin
12701270 }
12711271
12721272 // hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or
@@ -1368,8 +1368,9 @@ func toolBoxSide(th Theme, line string, width int) string {
13681368 w = 12
13691369 }
13701370 margin := strings.Repeat(" ", toolBoxOuterMargin)
1371- left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad)
1372- right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│")
1371+ mutedEdge := th.FGColor(th.Muted, "│")
1372+ left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad)
1373+ right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge
13731374 inner := w - 2 - 2*toolBoxInnerPad // available between the two pads
13741375 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
13751376
@@ -1404,8 +1405,9 @@ func toolBoxSideWithImage(th Theme, line string, imgCells, width int) string {
14041405 w = 12
14051406 }
14061407 margin := strings.Repeat(" ", toolBoxOuterMargin)
1407- left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad)
1408- right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│")
1408+ mutedEdge := th.FGColor(th.Muted, "│")
1409+ left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad)
1410+ right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge
14091411 inner := w - 2 - 2*toolBoxInnerPad
14101412
14111413 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
@@ -1467,7 +1469,7 @@ func trimLeadingSpaces(s string, n int) string {
14671469 // like a unified diff gets +/- coloring. Image blocks are rendered
14681470 // inline when the terminal supports a protocol, else as a text
14691471 // placeholder with dimensions.
1470-func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string {
1472+func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string {
14711473 var body []string
14721474 hasImage := false
14731475 for _, b := range blocks {
@@ -1501,7 +1503,7 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
15011503 hidden := len(lines) - ToolCollapsePreview
15021504 total := len(lines)
15031505 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
1504- footer = v.Theme.FG256(v.Theme.Muted, footer)
1506+ footer = v.Theme.FGColor(v.Theme.Muted, footer)
15051507 out := append([]string(nil), kept...)
15061508 out = append(out, "")
15071509 return append(out, footer)
@@ -1511,7 +1513,7 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
15111513 // text contains a unified-diff section (lines starting with "--- " /
15121514 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove
15131515 // colors matching git diff conventions.
1514-func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string {
1516+func (v *View) renderToolText(text string, width int, defaultColor TerminalColor, sourcePath string, startLine int) []string {
15151517 // Legacy path: transcripts saved before we dropped line numbers
15161518 // from the read tool still carry " 1\t..." prefixes. Detect and
15171519 // strip them, then fall through to the highlighter.
@@ -1558,7 +1560,7 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
15581560 // Detect diff header: "--- name" followed somewhere by "+++ name".
15591561 if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") {
15601562 inDiff = true
1561- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
1563+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, l))
15621564 continue
15631565 }
15641566 // Hunk header "@@ -a,b +c,d @@" resets the counters so patches
@@ -1567,12 +1569,12 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
15671569 if o, n, ok := parseHunkHeader(l); ok {
15681570 oldLine, newLine = o, n
15691571 }
1570- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
1572+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, l))
15711573 continue
15721574 }
15731575 if inDiff && strings.TrimSpace(l) == "..." {
15741576 out = append(out, "")
1575- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "..."))
1577+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, "..."))
15761578 continue
15771579 }
15781580 if inDiff && len(l) > 0 {
@@ -1594,7 +1596,7 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
15941596 }
15951597 // Regular line.
15961598 for _, w := range wrapLine(l, width-4, " ") {
1597- out = append(out, " "+v.Theme.FG256(defaultColor, w))
1599+ out = append(out, " "+v.Theme.FGColor(defaultColor, w))
15981600 }
15991601 }
16001602 return out
@@ -1647,7 +1649,7 @@ func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
16471649 // rows use a blank gutter so wrapped content cannot be mistaken for another
16481650 // source line. Code is syntax-highlighted if sourcePath hints at a known
16491651 // language and otherwise uses the diff color.
1650-func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) []string {
1652+func (v *View) renderDiffRow(line string, width int, color TerminalColor, lineNo int, mark byte, sourcePath string) []string {
16511653 if len(line) == 0 {
16521654 return []string{""}
16531655 }
@@ -1695,9 +1697,9 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt
16951697 if mark == ' ' {
16961698 codeColor = v.Theme.Muted
16971699 }
1698- codeRendered = v.Theme.FG256(codeColor, chunk)
1700+ codeRendered = v.Theme.FGColor(codeColor, chunk)
16991701 }
1700- out = append(out, " "+v.Theme.FG256(gutterColor, rowGutter)+codeRendered)
1702+ out = append(out, " "+v.Theme.FGColor(gutterColor, rowGutter)+codeRendered)
17011703 }
17021704 return out
17031705 }
@@ -1803,11 +1805,11 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
18031805 out = append(out, imageFootprintSentinel)
18041806 }
18051807 out = append(out, imageFootprintSentinel)
1806- out = append(out, v.Theme.FG256(v.Theme.Muted, info))
1808+ out = append(out, v.Theme.FGColor(v.Theme.Muted, info))
18071809 return out
18081810 }
18091811 }
1810- return []string{v.Theme.FG256(v.Theme.Muted, info)}
1812+ return []string{v.Theme.FGColor(v.Theme.Muted, info)}
18111813 }
18121814
18131815 // looksLikeNumberedFile returns true when text matches the `read`
@@ -1875,17 +1877,17 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
18751877 // code is visually distinct from the muted gutter.
18761878 highlighted = make([]string, len(codes))
18771879 for i, c := range codes {
1878- highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
1880+ highlighted[i] = v.Theme.FGColor(v.Theme.ToolOut, c)
18791881 }
18801882 }
18811883 out := make([]string, 0, len(codes))
18821884 for i, code := range highlighted {
18831885 g := gutters[i]
18841886 if g == "" {
1885- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, code))
1887+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, code))
18861888 continue
18871889 }
1888- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, g)+code)
1890+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, g)+code)
18891891 }
18901892 return out
18911893 }
@@ -1901,7 +1903,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
19011903 // line in the accent color, the trailing "[exit N] Took X.Ys" line
19021904 // in muted type, everything else on the default tool-output color.
19031905 // Called from renderToolText when the first line starts with "$ ".
1904-func (v *View) renderBashResult(lines []string, width, defaultColor int) []string {
1906+func (v *View) renderBashResult(lines []string, width int, defaultColor TerminalColor) []string {
19051907 lines = normalizeBashOutputLines(lines)
19061908 // Identify the footer line (exit + timing). The bash tool writes
19071909 // it as the last non-empty line of the result.
@@ -1925,15 +1927,15 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin
19251927 // further processing / wrapping so the shell-style
19261928 // prompt reads at a glance.
19271929 for _, w := range wrapLine(l, width-4, " ") {
1928- out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w))
1930+ out = append(out, " "+v.Theme.FGColor(v.Theme.Accent, w))
19291931 }
19301932 case i == footerIdx:
19311933 for _, w := range wrapLine(l, width-4, " ") {
1932- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w))
1934+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, w))
19331935 }
19341936 default:
19351937 for _, w := range wrapLine(l, width-4, " ") {
1936- out = append(out, " "+v.Theme.FG256(defaultColor, w))
1938+ out = append(out, " "+v.Theme.FGColor(defaultColor, w))
19371939 }
19381940 }
19391941 }
@@ -2095,7 +2097,7 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st
20952097 continue
20962098 }
20972099 if l == "..." {
2098- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "..."))
2100+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, "..."))
20992101 continue
21002102 }
21012103 switch l[0] {
@@ -2111,7 +2113,7 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st
21112113 newLine++
21122114 default:
21132115 for _, w := range wrapLine(l, width-4, " ") {
2114- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w))
2116+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, w))
21152117 }
21162118 }
21172119 }
@@ -2174,17 +2176,17 @@ func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
21742176 } else {
21752177 highlighted = make([]string, len(code))
21762178 for i, c := range code {
2177- highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
2179+ highlighted[i] = v.Theme.FGColor(v.Theme.ToolOut, c)
21782180 }
21792181 }
21802182
21812183 out := make([]string, 0, len(lines))
21822184 for i, c := range highlighted {
21832185 gutter := fmt.Sprintf("%4d ", startLine+i)
2184- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, gutter)+c)
2186+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, gutter)+c)
21852187 }
21862188 for _, f := range footer {
2187- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, f))
2189+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, f))
21882190 }
21892191 return out
21902192 }
@@ -2325,7 +2327,7 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
23252327
23262328 if v.ExpandAll {
23272329 var lines []string
2328- header := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens))
2330+ header := th.FGColor(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens))
23292331 lines = append(lines, indent+header)
23302332 lines = append(lines, "")
23312333 for _, c := range m.Content {
@@ -2347,7 +2349,7 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
23472349 }
23482350
23492351 // Collapsed: single line, no banner.
2350- line := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens))
2352+ line := th.FGColor(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens))
23512353 return []string{indent + line}
23522354 }
23532355
@@ -2445,7 +2447,7 @@ func StatusBar(p StatusBarParams) []string {
24452447 if p.AutoCompacting {
24462448 ctx += " (auto)"
24472449 }
2448- stats = append(stats, th.FG256(ctxColor, ctx))
2450+ stats = append(stats, th.FGColor(ctxColor, ctx))
24492451 }
24502452
24512453 // Layout uses exactly 2 spaces of horizontal padding everywhere:
@@ -2495,11 +2497,11 @@ func StatusBar(p StatusBarParams) []string {
24952497 // prefix there's no trailing separator to double-pad.
24962498 leftBuilder.WriteString(pad)
24972499 }
2498- leftBuilder.WriteString(th.FG256(th.Muted, left))
2500+ leftBuilder.WriteString(th.FGColor(th.Muted, left))
24992501 if middle != "" {
25002502 leftBuilder.WriteString(pad)
25012503 // Highlight the opt-in max tier; other status information stays muted.
2502- leftBuilder.WriteString(th.FG256(reasoningStatusColor(th, reasoningText), middle))
2504+ leftBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle))
25032505 }
25042506
25052507 cwd := shortenHome(p.CWD)
@@ -2525,21 +2527,21 @@ func StatusBar(p StatusBarParams) []string {
25252527 // stats on their own rows. This mirrors the idle split below.
25262528 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
25272529 busyLine := pad + p.BusyPrefix
2528- modelLine := pad + th.FG256(th.Muted, left)
2530+ modelLine := pad + th.FGColor(th.Muted, left)
25292531 lines := []string{busyLine}
2530- if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {
2532+ if middle != "" && visibleWidth(modelLine+pad+th.FGColor(th.Muted, middle)) > p.Cols {
25312533 lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)
25322534 } else {
25332535 var infoBuilder strings.Builder
25342536 infoBuilder.WriteString(modelLine)
25352537 if middle != "" {
25362538 infoBuilder.WriteString(pad)
2537- infoBuilder.WriteString(th.FG256(reasoningStatusColor(th, reasoningText), middle))
2539+ infoBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle))
25382540 }
25392541 lines = append(lines, infoBuilder.String())
25402542 }
25412543 if cwd != "" {
2542- lines = append(lines, pad+th.FG256(th.Muted, cwd))
2544+ lines = append(lines, pad+th.FGColor(th.Muted, cwd))
25432545 }
25442546 return lines
25452547 }
@@ -2552,7 +2554,7 @@ func StatusBar(p StatusBarParams) []string {
25522554 var lines []string
25532555 lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)
25542556 if cwd != "" {
2555- lines = append(lines, pad+th.FG256(th.Muted, cwd))
2557+ lines = append(lines, pad+th.FGColor(th.Muted, cwd))
25562558 }
25572559 return lines
25582560 }
@@ -2563,12 +2565,12 @@ func StatusBar(p StatusBarParams) []string {
25632565
25642566 // Second line: indent with the same 2-space pad so the cwd lines
25652567 // up under the "(provider)" column on line 1.
2566- cwdRendered := pad + th.FG256(th.Muted, cwd)
2568+ cwdRendered := pad + th.FGColor(th.Muted, cwd)
25672569 return []string{primary, cwdRendered}
25682570 }
25692571
25702572 func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText, statsText string, cols int) []string {
2571- modelLine := pad + th.FG256(th.Muted, modelText)
2573+ modelLine := pad + th.FGColor(th.Muted, modelText)
25722574 infoParts := make([]string, 0, 2)
25732575 if fastText != "" {
25742576 infoParts = append(infoParts, fastText)
@@ -2580,7 +2582,7 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText
25802582 if infoText == "" {
25812583 lines = append(lines, modelLine)
25822584 if statsText != "" {
2583- lines = append(lines, pad+th.FG256(th.Muted, statsText))
2585+ lines = append(lines, pad+th.FGColor(th.Muted, statsText))
25842586 }
25852587 return lines
25862588 }
@@ -2591,23 +2593,23 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText
25912593 infoColor = reasoningStatusColor(th, reasoningText)
25922594 }
25932595 if cols <= 0 || visibleWidth(modelInfoPlain) <= cols {
2594- lines = append(lines, pad+th.FG256(infoColor, modelText+pad+infoText))
2596+ lines = append(lines, pad+th.FGColor(infoColor, modelText+pad+infoText))
25952597 } else {
25962598 lines = append(lines, modelLine)
25972599 if fastText != "" {
2598- lines = append(lines, pad+th.FG256(th.Muted, fastText))
2600+ lines = append(lines, pad+th.FGColor(th.Muted, fastText))
25992601 }
26002602 if reasoningText != "" {
2601- lines = append(lines, pad+th.FG256(reasoningStatusColor(th, reasoningText), reasoningText))
2603+ lines = append(lines, pad+th.FGColor(reasoningStatusColor(th, reasoningText), reasoningText))
26022604 }
26032605 }
26042606 if statsText != "" {
2605- lines = append(lines, pad+th.FG256(th.Muted, statsText))
2607+ lines = append(lines, pad+th.FGColor(th.Muted, statsText))
26062608 }
26072609 return lines
26082610 }
26092611
2610-func reasoningStatusColor(th Theme, reasoningText string) int {
2612+func reasoningStatusColor(th Theme, reasoningText string) TerminalColor {
26112613 if strings.HasSuffix(reasoningText, ": max") {
26122614 return th.ThinkingMax
26132615 }
@@ -2631,7 +2633,7 @@ func reasoningLevelLabel(level string) string {
26312633
26322634 // contextUsage renders the "N%/ctxMax" fragment, returning the
26332635 // rendered string plus the colour to wrap it in.
2634-func contextUsage(th Theme, used, max int) (string, int) {
2636+func contextUsage(th Theme, used, max int) (string, TerminalColor) {
26352637 if max <= 0 {
26362638 if used <= 0 {
26372639 return "", th.Muted
@@ -325,7 +325,7 @@ func (v *View) renderErr(width int) []string {
325 if idx > 0 {325 if idx > 0 {
326 prefix = indent326 prefix = indent
327 }327 }
328- out = append(out, v.Theme.FG256(v.Theme.Error, prefix+line))328+ out = append(out, v.Theme.FGColor(v.Theme.Error, prefix+line))
329 }329 }
330 return out330 return out
331 }331 }
@@ -340,9 +340,9 @@ func (v *View) renderStartupResources(width int) []string {
340 if len(items) == 0 {340 if len(items) == 0 {
341 return341 return
342 }342 }
343- out = append(out, v.Theme.FG256(v.Theme.Accent, "["+label+"]"))343+ out = append(out, v.Theme.FGColor(v.Theme.Accent, "["+label+"]"))
344 for _, line := range wrapLine(strings.Join(items, ", "), bodyWidth, "") {344 for _, line := range wrapLine(strings.Join(items, ", "), bodyWidth, "") {
345- out = append(out, v.Theme.FG256(v.Theme.Muted, " "+line))345+ out = append(out, v.Theme.FGColor(v.Theme.Muted, " "+line))
346 }346 }
347 out = append(out, "")347 out = append(out, "")
348 }348 }
@@ -691,10 +691,10 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
691 if innerWidth < 1 {691 if innerWidth < 1 {
692 innerWidth = 1692 innerWidth = 1
693 }693 }
694+ bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FGColor(v.Theme.Accent, "▌ "))
694 row := func(content string) string {695 row := func(content string) string {
695 inner := strings.Repeat(" ", leftGutter) + content696 inner := strings.Repeat(" ", leftGutter) + content
696 padded := v.Theme.UserBubble(inner, width-2)697 padded := v.Theme.UserBubble(inner, width-2)
697- bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))
698 return bar + padded698 return bar + padded
699 }699 }
700 if v.CompactUser || v.CompactMode {700 if v.CompactUser || v.CompactMode {
@@ -708,9 +708,9 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
708 }708 }
709 row = func(content string) string {709 row = func(content string) string {
710 if v.CompactMode {710 if v.CompactMode {
711- content = v.Theme.FG256(v.Theme.Muted, content)711+ content = v.Theme.FGColor(v.Theme.Muted, content)
712 }712 }
713- return v.Theme.FG256(v.Theme.Accent, "▌ ") + content713+ return v.Theme.FGColor(v.Theme.Accent, "▌ ") + content
714 }714 }
715 }715 }
716 var bubble []string716 var bubble []string
@@ -796,7 +796,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
796 }796 }
797 lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))797 lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
798 if tr.IsError {798 if tr.IsError {
799- lines = append(lines, toolBodyLine(v.Theme, v.Theme.FG256(color, " error"), width, v.CompactMode))799+ lines = append(lines, toolBodyLine(v.Theme, v.Theme.FGColor(color, " error"), width, v.CompactMode))
800 }800 }
801 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {801 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
802 _, stripped := parseImageFootprint(line)802 _, stripped := parseImageFootprint(line)
@@ -810,7 +810,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
810 lines = append(lines, toolBoxTop(v.Theme, label, width))810 lines = append(lines, toolBoxTop(v.Theme, label, width))
811 lines = append(lines, toolBoxSide(v.Theme, "", width))811 lines = append(lines, toolBoxSide(v.Theme, "", width))
812 if tr.IsError {812 if tr.IsError {
813- lines = append(lines, toolBoxSide(v.Theme, v.Theme.FG256(color, " error"), width))813+ lines = append(lines, toolBoxSide(v.Theme, v.Theme.FGColor(color, " error"), width))
814 }814 }
815 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {815 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
816 // Image-footprint rows (the escape row, the blank816 // Image-footprint rows (the escape row, the blank
@@ -978,7 +978,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
978 // renderLiveToolResult uses the transcript's text renderer so every rich978 // renderLiveToolResult uses the transcript's text renderer so every rich
979 // confirmation preview immediately gets the same colors, numbered gutters,979 // confirmation preview immediately gets the same colors, numbered gutters,
980 // and syntax highlighting as its final tool result.980 // and syntax highlighting as its final tool result.
981-func (v *View) renderLiveToolResult(text string, width, color int, sourcePath string) []string {981+func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string {
982 return v.renderToolText(text, width, color, sourcePath, 1)982 return v.renderToolText(text, width, color, sourcePath, 1)
983 }983 }
984 984
@@ -1010,7 +1010,7 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
1010 // Header line hints which edit is streaming and, when more1010 // Header line hints which edit is streaming and, when more
1011 // than one has landed, how many the model is doing.1011 // than one has landed, how many the model is doing.
1012 hint := fmt.Sprintf("edit %d (streaming)", idx)1012 hint := fmt.Sprintf("edit %d (streaming)", idx)
1013- body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""}1013+ body := []string{" " + v.Theme.FGColor(v.Theme.Muted, hint), ""}
1014 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)1014 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
1015 return v.wrapLiveBody(body, width)1015 return v.wrapLiveBody(body, width)
1016 case "bash", "Bash":1016 case "bash", "Bash":
@@ -1028,7 +1028,7 @@ func (v *View) renderLiveBashCommand(command string, width int) []string {
1028 if v.FlatTools || v.CompactMode {1028 if v.FlatTools || v.CompactMode {
1029 inner = flatToolBodyRenderWidth(width)1029 inner = flatToolBodyRenderWidth(width)
1030 }1030 }
1031- prompt := v.Theme.FG256(v.Theme.Muted, "$ ")1031+ prompt := v.Theme.FGColor(v.Theme.Muted, "$ ")
1032 var out []string1032 var out []string
1033 for i, line := range strings.Split(command, "\n") {1033 for i, line := range strings.Split(command, "\n") {
1034 firstPrefix := " "1034 firstPrefix := " "
@@ -1042,7 +1042,7 @@ func (v *View) renderLiveBashCommand(command string, width int) []string {
1042 if wrapWidth < 10 {1042 if wrapWidth < 10 {
1043 wrapWidth = 101043 wrapWidth = 10
1044 }1044 }
1045- for j, wrapped := range wrapANSILine(v.Theme.FG256(v.Theme.ToolOut, line), wrapWidth) {1045+ for j, wrapped := range wrapANSILine(v.Theme.FGColor(v.Theme.ToolOut, line), wrapWidth) {
1046 prefix := firstPrefix1046 prefix := firstPrefix
1047 if j > 0 {1047 if j > 0 {
1048 prefix = contPrefix1048 prefix = contPrefix
@@ -1133,7 +1133,7 @@ func toolBoxTop(th Theme, label string, width int) string {
1133 }1133 }
1134 fillStr := strings.Repeat("─", fill)1134 fillStr := strings.Repeat("─", fill)
1135 name, rest := splitToolLabel(label)1135 name, rest := splitToolLabel(label)
1136- return margin + th.FG256(th.Muted, prefix) + th.FG256(th.FG, name) + th.FG256(th.Muted, rest+suffix+fillStr+"┐") + margin1136+ return margin + th.FGColor(th.Muted, prefix) + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest+suffix+fillStr+"┐") + margin
1137 }1137 }
1138 1138
1139 func oneLineToolLabel(label string) string {1139 func oneLineToolLabel(label string) string {
@@ -1172,7 +1172,7 @@ func flatToolHeader(th Theme, label string, width int) string {
1172 label = oneLineToolLabel(label)1172 label = oneLineToolLabel(label)
1173 name, rest := splitToolLabel(label)1173 name, rest := splitToolLabel(label)
1174 margin := strings.Repeat(" ", toolBoxOuterMargin)1174 margin := strings.Repeat(" ", toolBoxOuterMargin)
1175- gutter := th.FG256(th.Assistant, "▌") + " "1175+ gutter := th.FGColor(th.Assistant, "▌") + " "
1176 // Budget the visible text to the content width so a very long1176 // Budget the visible text to the content width so a very long
1177 // argument summary doesn't run off the right edge; truncate with1177 // argument summary doesn't run off the right edge; truncate with
1178 // an ellipsis like the box header does.1178 // an ellipsis like the box header does.
@@ -1189,7 +1189,7 @@ func flatToolHeader(th Theme, label string, width int) string {
1189 rest = ""1189 rest = ""
1190 }1190 }
1191 }1191 }
1192- return margin + gutter + th.FG256(th.FG, name) + th.FG256(th.Muted, rest)1192+ return margin + gutter + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest)
1193 }1193 }
1194 1194
1195 func compactToolBlank(th Theme, width int) string {1195 func compactToolBlank(th Theme, width int) string {
@@ -1266,7 +1266,7 @@ func toolBoxBottom(th Theme, width int) string {
1266 }1266 }
1267 margin := strings.Repeat(" ", toolBoxOuterMargin)1267 margin := strings.Repeat(" ", toolBoxOuterMargin)
1268 line := "└" + strings.Repeat("─", w-2) + "┘"1268 line := "└" + strings.Repeat("─", w-2) + "┘"
1269- return margin + th.FG256(th.Muted, line) + margin1269+ return margin + th.FGColor(th.Muted, line) + margin
1270 }1270 }
1271 1271
1272 // hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or1272 // hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or
@@ -1368,8 +1368,9 @@ func toolBoxSide(th Theme, line string, width int) string {
1368 w = 121368 w = 12
1369 }1369 }
1370 margin := strings.Repeat(" ", toolBoxOuterMargin)1370 margin := strings.Repeat(" ", toolBoxOuterMargin)
1371- left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad)1371+ mutedEdge := th.FGColor(th.Muted, "│")
1372- right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│")1372+ left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad)
1373+ right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge
1373 inner := w - 2 - 2*toolBoxInnerPad // available between the two pads1374 inner := w - 2 - 2*toolBoxInnerPad // available between the two pads
1374 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)1375 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
1375 1376
@@ -1404,8 +1405,9 @@ func toolBoxSideWithImage(th Theme, line string, imgCells, width int) string {
1404 w = 121405 w = 12
1405 }1406 }
1406 margin := strings.Repeat(" ", toolBoxOuterMargin)1407 margin := strings.Repeat(" ", toolBoxOuterMargin)
1407- left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad)1408+ mutedEdge := th.FGColor(th.Muted, "│")
1408- right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│")1409+ left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad)
1410+ right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge
1409 inner := w - 2 - 2*toolBoxInnerPad1411 inner := w - 2 - 2*toolBoxInnerPad
1410 1412
1411 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)1413 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
@@ -1467,7 +1469,7 @@ func trimLeadingSpaces(s string, n int) string {
1467 // like a unified diff gets +/- coloring. Image blocks are rendered1469 // like a unified diff gets +/- coloring. Image blocks are rendered
1468 // inline when the terminal supports a protocol, else as a text1470 // inline when the terminal supports a protocol, else as a text
1469 // placeholder with dimensions.1471 // placeholder with dimensions.
1470-func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string {1472+func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string {
1471 var body []string1473 var body []string
1472 hasImage := false1474 hasImage := false
1473 for _, b := range blocks {1475 for _, b := range blocks {
@@ -1501,7 +1503,7 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
1501 hidden := len(lines) - ToolCollapsePreview1503 hidden := len(lines) - ToolCollapsePreview
1502 total := len(lines)1504 total := len(lines)
1503 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)1505 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
1504- footer = v.Theme.FG256(v.Theme.Muted, footer)1506+ footer = v.Theme.FGColor(v.Theme.Muted, footer)
1505 out := append([]string(nil), kept...)1507 out := append([]string(nil), kept...)
1506 out = append(out, "")1508 out = append(out, "")
1507 return append(out, footer)1509 return append(out, footer)
@@ -1511,7 +1513,7 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
1511 // text contains a unified-diff section (lines starting with "--- " /1513 // text contains a unified-diff section (lines starting with "--- " /
1512 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove1514 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove
1513 // colors matching git diff conventions.1515 // colors matching git diff conventions.
1514-func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string {1516+func (v *View) renderToolText(text string, width int, defaultColor TerminalColor, sourcePath string, startLine int) []string {
1515 // Legacy path: transcripts saved before we dropped line numbers1517 // Legacy path: transcripts saved before we dropped line numbers
1516 // from the read tool still carry " 1\t..." prefixes. Detect and1518 // from the read tool still carry " 1\t..." prefixes. Detect and
1517 // strip them, then fall through to the highlighter.1519 // strip them, then fall through to the highlighter.
@@ -1558,7 +1560,7 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
1558 // Detect diff header: "--- name" followed somewhere by "+++ name".1560 // Detect diff header: "--- name" followed somewhere by "+++ name".
1559 if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") {1561 if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") {
1560 inDiff = true1562 inDiff = true
1561- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))1563+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, l))
1562 continue1564 continue
1563 }1565 }
1564 // Hunk header "@@ -a,b +c,d @@" resets the counters so patches1566 // Hunk header "@@ -a,b +c,d @@" resets the counters so patches
@@ -1567,12 +1569,12 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
1567 if o, n, ok := parseHunkHeader(l); ok {1569 if o, n, ok := parseHunkHeader(l); ok {
1568 oldLine, newLine = o, n1570 oldLine, newLine = o, n
1569 }1571 }
1570- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))1572+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, l))
1571 continue1573 continue
1572 }1574 }
1573 if inDiff && strings.TrimSpace(l) == "..." {1575 if inDiff && strings.TrimSpace(l) == "..." {
1574 out = append(out, "")1576 out = append(out, "")
1575- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "..."))1577+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, "..."))
1576 continue1578 continue
1577 }1579 }
1578 if inDiff && len(l) > 0 {1580 if inDiff && len(l) > 0 {
@@ -1594,7 +1596,7 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
1594 }1596 }
1595 // Regular line.1597 // Regular line.
1596 for _, w := range wrapLine(l, width-4, " ") {1598 for _, w := range wrapLine(l, width-4, " ") {
1597- out = append(out, " "+v.Theme.FG256(defaultColor, w))1599+ out = append(out, " "+v.Theme.FGColor(defaultColor, w))
1598 }1600 }
1599 }1601 }
1600 return out1602 return out
@@ -1647,7 +1649,7 @@ func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
1647 // rows use a blank gutter so wrapped content cannot be mistaken for another1649 // rows use a blank gutter so wrapped content cannot be mistaken for another
1648 // source line. Code is syntax-highlighted if sourcePath hints at a known1650 // source line. Code is syntax-highlighted if sourcePath hints at a known
1649 // language and otherwise uses the diff color.1651 // language and otherwise uses the diff color.
1650-func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) []string {1652+func (v *View) renderDiffRow(line string, width int, color TerminalColor, lineNo int, mark byte, sourcePath string) []string {
1651 if len(line) == 0 {1653 if len(line) == 0 {
1652 return []string{""}1654 return []string{""}
1653 }1655 }
@@ -1695,9 +1697,9 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt
1695 if mark == ' ' {1697 if mark == ' ' {
1696 codeColor = v.Theme.Muted1698 codeColor = v.Theme.Muted
1697 }1699 }
1698- codeRendered = v.Theme.FG256(codeColor, chunk)1700+ codeRendered = v.Theme.FGColor(codeColor, chunk)
1699 }1701 }
1700- out = append(out, " "+v.Theme.FG256(gutterColor, rowGutter)+codeRendered)1702+ out = append(out, " "+v.Theme.FGColor(gutterColor, rowGutter)+codeRendered)
1701 }1703 }
1702 return out1704 return out
1703 }1705 }
@@ -1803,11 +1805,11 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
1803 out = append(out, imageFootprintSentinel)1805 out = append(out, imageFootprintSentinel)
1804 }1806 }
1805 out = append(out, imageFootprintSentinel)1807 out = append(out, imageFootprintSentinel)
1806- out = append(out, v.Theme.FG256(v.Theme.Muted, info))1808+ out = append(out, v.Theme.FGColor(v.Theme.Muted, info))
1807 return out1809 return out
1808 }1810 }
1809 }1811 }
1810- return []string{v.Theme.FG256(v.Theme.Muted, info)}1812+ return []string{v.Theme.FGColor(v.Theme.Muted, info)}
1811 }1813 }
1812 1814
1813 // looksLikeNumberedFile returns true when text matches the `read`1815 // looksLikeNumberedFile returns true when text matches the `read`
@@ -1875,17 +1877,17 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
1875 // code is visually distinct from the muted gutter.1877 // code is visually distinct from the muted gutter.
1876 highlighted = make([]string, len(codes))1878 highlighted = make([]string, len(codes))
1877 for i, c := range codes {1879 for i, c := range codes {
1878- highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)1880+ highlighted[i] = v.Theme.FGColor(v.Theme.ToolOut, c)
1879 }1881 }
1880 }1882 }
1881 out := make([]string, 0, len(codes))1883 out := make([]string, 0, len(codes))
1882 for i, code := range highlighted {1884 for i, code := range highlighted {
1883 g := gutters[i]1885 g := gutters[i]
1884 if g == "" {1886 if g == "" {
1885- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, code))1887+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, code))
1886 continue1888 continue
1887 }1889 }
1888- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, g)+code)1890+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, g)+code)
1889 }1891 }
1890 return out1892 return out
1891 }1893 }
@@ -1901,7 +1903,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
1901 // line in the accent color, the trailing "[exit N] Took X.Ys" line1903 // line in the accent color, the trailing "[exit N] Took X.Ys" line
1902 // in muted type, everything else on the default tool-output color.1904 // in muted type, everything else on the default tool-output color.
1903 // Called from renderToolText when the first line starts with "$ ".1905 // Called from renderToolText when the first line starts with "$ ".
1904-func (v *View) renderBashResult(lines []string, width, defaultColor int) []string {1906+func (v *View) renderBashResult(lines []string, width int, defaultColor TerminalColor) []string {
1905 lines = normalizeBashOutputLines(lines)1907 lines = normalizeBashOutputLines(lines)
1906 // Identify the footer line (exit + timing). The bash tool writes1908 // Identify the footer line (exit + timing). The bash tool writes
1907 // it as the last non-empty line of the result.1909 // it as the last non-empty line of the result.
@@ -1925,15 +1927,15 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin
1925 // further processing / wrapping so the shell-style1927 // further processing / wrapping so the shell-style
1926 // prompt reads at a glance.1928 // prompt reads at a glance.
1927 for _, w := range wrapLine(l, width-4, " ") {1929 for _, w := range wrapLine(l, width-4, " ") {
1928- out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w))1930+ out = append(out, " "+v.Theme.FGColor(v.Theme.Accent, w))
1929 }1931 }
1930 case i == footerIdx:1932 case i == footerIdx:
1931 for _, w := range wrapLine(l, width-4, " ") {1933 for _, w := range wrapLine(l, width-4, " ") {
1932- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w))1934+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, w))
1933 }1935 }
1934 default:1936 default:
1935 for _, w := range wrapLine(l, width-4, " ") {1937 for _, w := range wrapLine(l, width-4, " ") {
1936- out = append(out, " "+v.Theme.FG256(defaultColor, w))1938+ out = append(out, " "+v.Theme.FGColor(defaultColor, w))
1937 }1939 }
1938 }1940 }
1939 }1941 }
@@ -2095,7 +2097,7 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st
2095 continue2097 continue
2096 }2098 }
2097 if l == "..." {2099 if l == "..." {
2098- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "..."))2100+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, "..."))
2099 continue2101 continue
2100 }2102 }
2101 switch l[0] {2103 switch l[0] {
@@ -2111,7 +2113,7 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st
2111 newLine++2113 newLine++
2112 default:2114 default:
2113 for _, w := range wrapLine(l, width-4, " ") {2115 for _, w := range wrapLine(l, width-4, " ") {
2114- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w))2116+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, w))
2115 }2117 }
2116 }2118 }
2117 }2119 }
@@ -2174,17 +2176,17 @@ func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
2174 } else {2176 } else {
2175 highlighted = make([]string, len(code))2177 highlighted = make([]string, len(code))
2176 for i, c := range code {2178 for i, c := range code {
2177- highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)2179+ highlighted[i] = v.Theme.FGColor(v.Theme.ToolOut, c)
2178 }2180 }
2179 }2181 }
2180 2182
2181 out := make([]string, 0, len(lines))2183 out := make([]string, 0, len(lines))
2182 for i, c := range highlighted {2184 for i, c := range highlighted {
2183 gutter := fmt.Sprintf("%4d ", startLine+i)2185 gutter := fmt.Sprintf("%4d ", startLine+i)
2184- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, gutter)+c)2186+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, gutter)+c)
2185 }2187 }
2186 for _, f := range footer {2188 for _, f := range footer {
2187- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, f))2189+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, f))
2188 }2190 }
2189 return out2191 return out
2190 }2192 }
@@ -2325,7 +2327,7 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
2325 2327
2326 if v.ExpandAll {2328 if v.ExpandAll {
2327 var lines []string2329 var lines []string
2328- header := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens))2330+ header := th.FGColor(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens))
2329 lines = append(lines, indent+header)2331 lines = append(lines, indent+header)
2330 lines = append(lines, "")2332 lines = append(lines, "")
2331 for _, c := range m.Content {2333 for _, c := range m.Content {
@@ -2347,7 +2349,7 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
2347 }2349 }
2348 2350
2349 // Collapsed: single line, no banner.2351 // Collapsed: single line, no banner.
2350- line := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens))2352+ line := th.FGColor(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens))
2351 return []string{indent + line}2353 return []string{indent + line}
2352 }2354 }
2353 2355
@@ -2445,7 +2447,7 @@ func StatusBar(p StatusBarParams) []string {
2445 if p.AutoCompacting {2447 if p.AutoCompacting {
2446 ctx += " (auto)"2448 ctx += " (auto)"
2447 }2449 }
2448- stats = append(stats, th.FG256(ctxColor, ctx))2450+ stats = append(stats, th.FGColor(ctxColor, ctx))
2449 }2451 }
2450 2452
2451 // Layout uses exactly 2 spaces of horizontal padding everywhere:2453 // Layout uses exactly 2 spaces of horizontal padding everywhere:
@@ -2495,11 +2497,11 @@ func StatusBar(p StatusBarParams) []string {
2495 // prefix there's no trailing separator to double-pad.2497 // prefix there's no trailing separator to double-pad.
2496 leftBuilder.WriteString(pad)2498 leftBuilder.WriteString(pad)
2497 }2499 }
2498- leftBuilder.WriteString(th.FG256(th.Muted, left))2500+ leftBuilder.WriteString(th.FGColor(th.Muted, left))
2499 if middle != "" {2501 if middle != "" {
2500 leftBuilder.WriteString(pad)2502 leftBuilder.WriteString(pad)
2501 // Highlight the opt-in max tier; other status information stays muted.2503 // Highlight the opt-in max tier; other status information stays muted.
2502- leftBuilder.WriteString(th.FG256(reasoningStatusColor(th, reasoningText), middle))2504+ leftBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle))
2503 }2505 }
2504 2506
2505 cwd := shortenHome(p.CWD)2507 cwd := shortenHome(p.CWD)
@@ -2525,21 +2527,21 @@ func StatusBar(p StatusBarParams) []string {
2525 // stats on their own rows. This mirrors the idle split below.2527 // stats on their own rows. This mirrors the idle split below.
2526 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {2528 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
2527 busyLine := pad + p.BusyPrefix2529 busyLine := pad + p.BusyPrefix
2528- modelLine := pad + th.FG256(th.Muted, left)2530+ modelLine := pad + th.FGColor(th.Muted, left)
2529 lines := []string{busyLine}2531 lines := []string{busyLine}
2530- if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {2532+ if middle != "" && visibleWidth(modelLine+pad+th.FGColor(th.Muted, middle)) > p.Cols {
2531 lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)2533 lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)
2532 } else {2534 } else {
2533 var infoBuilder strings.Builder2535 var infoBuilder strings.Builder
2534 infoBuilder.WriteString(modelLine)2536 infoBuilder.WriteString(modelLine)
2535 if middle != "" {2537 if middle != "" {
2536 infoBuilder.WriteString(pad)2538 infoBuilder.WriteString(pad)
2537- infoBuilder.WriteString(th.FG256(reasoningStatusColor(th, reasoningText), middle))2539+ infoBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle))
2538 }2540 }
2539 lines = append(lines, infoBuilder.String())2541 lines = append(lines, infoBuilder.String())
2540 }2542 }
2541 if cwd != "" {2543 if cwd != "" {
2542- lines = append(lines, pad+th.FG256(th.Muted, cwd))2544+ lines = append(lines, pad+th.FGColor(th.Muted, cwd))
2543 }2545 }
2544 return lines2546 return lines
2545 }2547 }
@@ -2552,7 +2554,7 @@ func StatusBar(p StatusBarParams) []string {
2552 var lines []string2554 var lines []string
2553 lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)2555 lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)
2554 if cwd != "" {2556 if cwd != "" {
2555- lines = append(lines, pad+th.FG256(th.Muted, cwd))2557+ lines = append(lines, pad+th.FGColor(th.Muted, cwd))
2556 }2558 }
2557 return lines2559 return lines
2558 }2560 }
@@ -2563,12 +2565,12 @@ func StatusBar(p StatusBarParams) []string {
2563 2565
2564 // Second line: indent with the same 2-space pad so the cwd lines2566 // Second line: indent with the same 2-space pad so the cwd lines
2565 // up under the "(provider)" column on line 1.2567 // up under the "(provider)" column on line 1.
2566- cwdRendered := pad + th.FG256(th.Muted, cwd)2568+ cwdRendered := pad + th.FGColor(th.Muted, cwd)
2567 return []string{primary, cwdRendered}2569 return []string{primary, cwdRendered}
2568 }2570 }
2569 2571
2570 func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText, statsText string, cols int) []string {2572 func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText, statsText string, cols int) []string {
2571- modelLine := pad + th.FG256(th.Muted, modelText)2573+ modelLine := pad + th.FGColor(th.Muted, modelText)
2572 infoParts := make([]string, 0, 2)2574 infoParts := make([]string, 0, 2)
2573 if fastText != "" {2575 if fastText != "" {
2574 infoParts = append(infoParts, fastText)2576 infoParts = append(infoParts, fastText)
@@ -2580,7 +2582,7 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText
2580 if infoText == "" {2582 if infoText == "" {
2581 lines = append(lines, modelLine)2583 lines = append(lines, modelLine)
2582 if statsText != "" {2584 if statsText != "" {
2583- lines = append(lines, pad+th.FG256(th.Muted, statsText))2585+ lines = append(lines, pad+th.FGColor(th.Muted, statsText))
2584 }2586 }
2585 return lines2587 return lines
2586 }2588 }
@@ -2591,23 +2593,23 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText
2591 infoColor = reasoningStatusColor(th, reasoningText)2593 infoColor = reasoningStatusColor(th, reasoningText)
2592 }2594 }
2593 if cols <= 0 || visibleWidth(modelInfoPlain) <= cols {2595 if cols <= 0 || visibleWidth(modelInfoPlain) <= cols {
2594- lines = append(lines, pad+th.FG256(infoColor, modelText+pad+infoText))2596+ lines = append(lines, pad+th.FGColor(infoColor, modelText+pad+infoText))
2595 } else {2597 } else {
2596 lines = append(lines, modelLine)2598 lines = append(lines, modelLine)
2597 if fastText != "" {2599 if fastText != "" {
2598- lines = append(lines, pad+th.FG256(th.Muted, fastText))2600+ lines = append(lines, pad+th.FGColor(th.Muted, fastText))
2599 }2601 }
2600 if reasoningText != "" {2602 if reasoningText != "" {
2601- lines = append(lines, pad+th.FG256(reasoningStatusColor(th, reasoningText), reasoningText))2603+ lines = append(lines, pad+th.FGColor(reasoningStatusColor(th, reasoningText), reasoningText))
2602 }2604 }
2603 }2605 }
2604 if statsText != "" {2606 if statsText != "" {
2605- lines = append(lines, pad+th.FG256(th.Muted, statsText))2607+ lines = append(lines, pad+th.FGColor(th.Muted, statsText))
2606 }2608 }
2607 return lines2609 return lines
2608 }2610 }
2609 2611
2610-func reasoningStatusColor(th Theme, reasoningText string) int {2612+func reasoningStatusColor(th Theme, reasoningText string) TerminalColor {
2611 if strings.HasSuffix(reasoningText, ": max") {2613 if strings.HasSuffix(reasoningText, ": max") {
2612 return th.ThinkingMax2614 return th.ThinkingMax
2613 }2615 }
@@ -2631,7 +2633,7 @@ func reasoningLevelLabel(level string) string {
2631 2633
2632 // contextUsage renders the "N%/ctxMax" fragment, returning the2634 // contextUsage renders the "N%/ctxMax" fragment, returning the
2633 // rendered string plus the colour to wrap it in.2635 // rendered string plus the colour to wrap it in.
2634-func contextUsage(th Theme, used, max int) (string, int) {2636+func contextUsage(th Theme, used, max int) (string, TerminalColor) {
2635 if max <= 0 {2637 if max <= 0 {
2636 if used <= 0 {2638 if used <= 0 {
2637 return "", th.Muted2639 return "", th.Muted
M packages/tui/view_tool_box_test.go +1 -1
@@ -8,7 +8,7 @@ import (
88 func TestToolBoxSideTruncationPreservesANSIColor(t *testing.T) {
99 const width = 32
1010 const contentColor = 196
11- line := " " + Dark.FG256(contentColor, strings.Repeat("x", 80))
11+ line := " " + Dark.FGColor(Color256(contentColor), strings.Repeat("x", 80))
1212
1313 got := toolBoxSide(Dark, line, width)
1414 if !strings.Contains(got, sgrFG(contentColor)) {
@@ -8,7 +8,7 @@ import (
8 func TestToolBoxSideTruncationPreservesANSIColor(t *testing.T) {8 func TestToolBoxSideTruncationPreservesANSIColor(t *testing.T) {
9 const width = 329 const width = 32
10 const contentColor = 19610 const contentColor = 196
11- line := " " + Dark.FG256(contentColor, strings.Repeat("x", 80))11+ line := " " + Dark.FGColor(Color256(contentColor), strings.Repeat("x", 80))
12 12
13 got := toolBoxSide(Dark, line, width)13 got := toolBoxSide(Dark, line, width)
14 if !strings.Contains(got, sgrFG(contentColor)) {14 if !strings.Contains(got, sgrFG(contentColor)) {
M packages/tui/view_tool_overlay_test.go +2 -2
@@ -126,8 +126,8 @@ func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
126126 }
127127 }
128128 for _, want := range []string{
129- Dark.FG256(Dark.Error, "- 1 "),
130- Dark.FG256(Dark.Tool, "+ 1 "),
129+ Dark.FGColor(Dark.Error, "- 1 "),
130+ Dark.FGColor(Dark.Tool, "+ 1 "),
131131 } {
132132 if !strings.Contains(rendered, want) {
133133 t.Fatalf("confirmation preview missing diff color %q:\n%s", want, rendered)
@@ -126,8 +126,8 @@ func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
126 }126 }
127 }127 }
128 for _, want := range []string{128 for _, want := range []string{
129- Dark.FG256(Dark.Error, "- 1 "),129+ Dark.FGColor(Dark.Error, "- 1 "),
130- Dark.FG256(Dark.Tool, "+ 1 "),130+ Dark.FGColor(Dark.Tool, "+ 1 "),
131 } {131 } {
132 if !strings.Contains(rendered, want) {132 if !strings.Contains(rendered, want) {
133 t.Fatalf("confirmation preview missing diff color %q:\n%s", want, rendered)133 t.Fatalf("confirmation preview missing diff color %q:\n%s", want, rendered)

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