Merge pull request #33 from bnema/feat/theme-shades-gray
feat(tui): promote semantic theme colors to truecolor
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 | |||
| 419 | 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 | 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 | 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 | 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 | 425 | ### `/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: | |||
| 34 | 34 | The inherited terminal snapshot is captured at startup and reused when the | |
| 35 | 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 | 51 | Theme files bundled with extensions are discovered in-place from loaded | |
| 38 | 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 | 206 | ## Color fields | |
| 193 | 207 | ||
| 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. | |
| 199 | 224 | ||
| 200 | 225 | - `fg` — default foreground text. | |
| 201 | 226 | - `muted` — secondary text, dividers, gutters, inactive hints. | |
| @@ -214,15 +239,8 @@ capability. | |||
| 214 | 239 | - `selection_bg` — highlighted row background. | |
| 215 | 240 | - `selection_fg` — highlighted row foreground. | |
| 216 | 241 | ||
| 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. | |
| 226 | 244 | ||
| 227 | 245 | ## Spinner fields | |
| 228 | 246 | ||
| @@ -34,6 +34,20 @@ The built-in choices are: | |||||
| 34 | The inherited terminal snapshot is captured at startup and reused when the | 34 | 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 loaded | 51 | 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 fields | 206 | ## 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's | 209 | + | |
| 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 terminal | 211 | + | 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 fields | 245 | ## Spinner fields | ||
| 228 | 246 | ||||
M packages/agent/args.go +5 -2
| @@ -370,11 +370,14 @@ func PrintHelp(version string) { | |||
| 370 | 370 | th := tui.Dark | |
| 371 | 371 | fd := int(os.Stderr.Fd()) | |
| 372 | 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 | 377 | if !useColor { | |
| 375 | 378 | return s | |
| 376 | 379 | } | |
| 377 | - | return th.FG256(c, s) | |
| 380 | + | return th.FGColor(c, s) | |
| 378 | 381 | } | |
| 379 | 382 | assistant := func(s string) string { return style(th.Assistant, s) } | |
| 380 | 383 | muted := func(s string) string { return style(th.Muted, s) } | |
| @@ -370,11 +370,14 @@ func PrintHelp(version string) { | |||||
| 370 | th := tui.Dark | 370 | 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 s | 378 | 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 { | |||
| 383 | 383 | out = append(out, frameHeaderColor(th, "btw - side chat (esc closes; nothing is added to the main thread)", width, th.Accent)) | |
| 384 | 384 | ||
| 385 | 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 | 389 | for _, t := range d.turns { | |
| @@ -402,7 +402,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string { | |||
| 402 | 402 | out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...) | |
| 403 | 403 | } | |
| 404 | 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 | 412 | // rotating funny-line, elapsed seconds, then a muted hint | |
| 413 | 413 | // that esc cancels. | |
| 414 | 414 | 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()), | |
| 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 | 422 | out = append(out, "") | |
| @@ -477,7 +477,7 @@ func (d *btwDialog) CursorPos(width int) (row, col int) { | |||
| 477 | 477 | editorOffset += len(renderDialogMarkdownRows(t.Assistant, d.theme, width)) | |
| 478 | 478 | } | |
| 479 | 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 | 483 | if d.loading { | |
| @@ -505,7 +505,7 @@ func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int, compactMode b | |||
| 505 | 505 | if innerWidth < 1 { | |
| 506 | 506 | 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 | 509 | row := func(content string) string { | |
| 510 | 510 | inner := strings.Repeat(" ", leftGutter) + content | |
| 511 | 511 | return " " + bar + th.UserBubble(inner, bubbleWidth-2) | |
| @@ -516,7 +516,7 @@ func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int, compactMode b | |||
| 516 | 516 | innerWidth = 1 | |
| 517 | 517 | } | |
| 518 | 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 | 522 | 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 hint | 412 | // 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 = 1 | 506 | 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) + content | 510 | 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 = 1 | 516 | 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 []string | 522 | 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 { | |||
| 79 | 79 | title := fmt.Sprintf("zot %s \u2014 release notes (any key to dismiss)", d.version) | |
| 80 | 80 | out := []string{frameHeaderColor(th, title, width, th.Accent)} | |
| 81 | 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 | 83 | out = append(out, "") | |
| 84 | 84 | } | |
| 85 | 85 | ||
| @@ -88,7 +88,7 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string { | |||
| 88 | 88 | if strings.HasPrefix(l, "\x00H:") { | |
| 89 | 89 | // Heading: render in accent color, bold. | |
| 90 | 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 | 92 | bodyLines = append(bodyLines, wrapDialogTextRows(styled, width)...) | |
| 93 | 93 | } else { | |
| 94 | 94 | // 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 | 111 | out = append(out, bodyLines[d.scroll:end]...) | |
| 112 | 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 | 115 | out = append(out, frameRuleColor(th, width, th.Accent)) | |
| 116 | 116 | 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 dialog | 94 | // 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 out | 116 | 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 { | |||
| 251 | 251 | } | |
| 252 | 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 | 255 | if req.preview != "" { | |
| 256 | - | toolLine += " " + th.FG256(th.Muted, req.preview) | |
| 256 | + | toolLine += " " + th.FGColor(th.Muted, req.preview) | |
| 257 | 257 | } | |
| 258 | 258 | lines = append(lines, toolLine) | |
| 259 | 259 | lines = append(lines, "") | |
| @@ -261,7 +261,7 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string { | |||
| 261 | 261 | if !d.focused { | |
| 262 | 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 | 266 | for i, opt := range confirmOptions { | |
| 267 | 267 | label := opt.label | |
| @@ -279,7 +279,7 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string { | |||
| 279 | 279 | if i == cursor { | |
| 280 | 280 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 281 | 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 | 285 | 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.label | 267 | 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 ( | |||
| 16 | 16 | func frameHeader(th tui.Theme, title string, width int) string { | |
| 17 | 17 | label := "── " + title + " " | |
| 18 | 18 | if width <= 0 { | |
| 19 | - | return th.FG256(th.Muted, label) | |
| 19 | + | return th.FGColor(th.Muted, label) | |
| 20 | 20 | } | |
| 21 | 21 | padLen := width - runewidth.StringWidth(label) | |
| 22 | 22 | if padLen < 0 { | |
| 23 | 23 | 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 | 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 | 30 | if width <= 0 { | |
| 31 | 31 | 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 | 36 | // padDialogFrame inserts breathing room between the shared dialog frame | |
| @@ -71,24 +71,24 @@ func isFrameRuleLine(line string) bool { | |||
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | 73 | // 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 { | |
| 77 | 77 | label := "── " + title + " " | |
| 78 | 78 | if width <= 0 { | |
| 79 | - | return th.FG256(color, label) | |
| 79 | + | return th.FGColor(color, label) | |
| 80 | 80 | } | |
| 81 | 81 | padLen := width - runewidth.StringWidth(label) | |
| 82 | 82 | if padLen < 0 { | |
| 83 | 83 | 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 | 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 | 90 | if width <= 0 { | |
| 91 | 91 | width = 1 | |
| 92 | 92 | } | |
| 93 | - | return th.FG256(color, strings.Repeat("─", width)) | |
| 93 | + | return th.FGColor(color, strings.Repeat("─", width)) | |
| 94 | 94 | } | |
| @@ -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 = 0 | 23 | 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 = 1 | 31 | 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 frame | 36 | // 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-supplied | 73 | // frameHeaderColor is like frameHeader but renders in a caller-supplied | ||
| 74 | - | // 256-color code. Used by the update-available banner which wants a | 74 | + | // 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 = 0 | 83 | 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 = 1 | 91 | 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 ( | |||
| 11 | 11 | ||
| 12 | 12 | func newNotesTestInteractive() *Interactive { | |
| 13 | 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 | 18 | return i | |
| 16 | 19 | } | |
| 17 | 20 | ||
| @@ -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 i | 18 | 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 { | |||
| 74 | 74 | } | |
| 75 | 75 | if strings.TrimSpace(d.footer) != "" { | |
| 76 | 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 | 79 | out = append(out, frameRuleColor(th, width, th.Accent)) | |
| 80 | 80 | return out | |
| @@ -92,7 +92,7 @@ func styleExtPanelLine(th tui.Theme, raw string, plain string, width int, select | |||
| 92 | 92 | if raw != plain { | |
| 93 | 93 | return raw + "\x1b[0m" | |
| 94 | 94 | } | |
| 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, "✓")) | |
| 97 | 97 | return styled | |
| 98 | 98 | } | |
| @@ -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 out | 80 | 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 styled | 97 | 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 { | |||
| 480 | 480 | ||
| 481 | 481 | // Show breadcrumb when browsing a subdirectory. | |
| 482 | 482 | if s.browseRel != "" { | |
| 483 | - | crumb := th.FG256(th.Accent, " "+s.browseRel+"/") | |
| 483 | + | crumb := th.FGColor(th.Accent, " "+s.browseRel+"/") | |
| 484 | 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 | 494 | if idx == s.cursor { | |
| 495 | 495 | out = append(out, th.PadHighlight(plain, width)) | |
| 496 | 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 | 508 | default: | |
| 509 | 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 | 512 | out = append(out, "") | |
| 513 | 513 | return out | |
| 514 | 514 | } | |
| @@ -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 out | 513 | 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 { | |||
| 82 | 82 | continue | |
| 83 | 83 | } | |
| 84 | 84 | 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))) | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | 89 | // keys section | |
| 90 | 90 | out = append(out, "", tui.Bold("keys:")) | |
| 91 | 91 | for _, k := range helpKeyRows { | |
| 92 | 92 | 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]))) | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | 97 | out = append(out, "", frameRule(th, width), "") | |
| @@ -82,16 +82,16 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string { | |||||
| 82 | continue | 82 | 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 section | 89 | // 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 | |||
| 1274 | 1274 | } | |
| 1275 | 1275 | limit := maxNarrowExtensionChromeRows | |
| 1276 | 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 | 1278 | return trimmed | |
| 1279 | 1279 | } | |
| 1280 | 1280 | ||
| @@ -1310,7 +1310,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool | |||
| 1310 | 1310 | if widget.Title != "" { | |
| 1311 | 1311 | 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 | 1314 | stopWidgets = true | |
| 1315 | 1315 | break | |
| 1316 | 1316 | } | |
| @@ -1326,7 +1326,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool | |||
| 1326 | 1326 | } | |
| 1327 | 1327 | } | |
| 1328 | 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 | 1332 | statusRows := 0 | |
| @@ -1348,7 +1348,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool | |||
| 1348 | 1348 | color = i.cfg.Theme.Tool | |
| 1349 | 1349 | } | |
| 1350 | 1350 | 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 | 1352 | statusRows++ | |
| 1353 | 1353 | } | |
| 1354 | 1354 | if statusTruncated { | |
| @@ -1356,7 +1356,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool | |||
| 1356 | 1356 | } | |
| 1357 | 1357 | } | |
| 1358 | 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 | 1361 | return out | |
| 1362 | 1362 | } | |
| @@ -1467,7 +1467,7 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 1467 | 1467 | if cols > 4 && len(line) > cols { | |
| 1468 | 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 | 1473 | // Live shell-escape output (!command / entry.pre) streams into the | |
| @@ -1772,10 +1772,10 @@ func (i *Interactive) redraw() { | |||
| 1772 | 1772 | var busyPrefix string | |
| 1773 | 1773 | if i.busy { | |
| 1774 | 1774 | 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()), | |
| 1779 | 1779 | ) | |
| 1780 | 1780 | } | |
| 1781 | 1781 | ||
| @@ -1843,16 +1843,16 @@ func (i *Interactive) redraw() { | |||
| 1843 | 1843 | if len(queued) > 0 { | |
| 1844 | 1844 | queue = append(queue, "") | |
| 1845 | 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 | 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 | 1850 | // Hint row, rendered in the same muted tone as the model | |
| 1851 | 1851 | // info on the status bar so it reads as ambient metadata | |
| 1852 | 1852 | // rather than a chip. Tells the user how to recover the | |
| 1853 | 1853 | // most recent queued message back into the editor. | |
| 1854 | 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 | 1858 | extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback) | |
| @@ -2060,7 +2060,7 @@ func (i *Interactive) redraw() { | |||
| 2060 | 2060 | } else { | |
| 2061 | 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 | 2064 | visibleChat = append([]string{note}, visibleChat...) | |
| 2065 | 2065 | if len(visibleChat) > chatRows { | |
| 2066 | 2066 | visibleChat = visibleChat[:chatRows] | |
| @@ -3442,11 +3442,11 @@ func (i *Interactive) appendExtensionNote(extName, msg, level string) { | |||
| 3442 | 3442 | case "success": | |
| 3443 | 3443 | 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 | 3446 | for _, line := range strings.Split(msg, "\n") { | |
| 3447 | 3447 | i.statusOK = "" // clear any stale ok | |
| 3448 | 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 | 3451 | // Extension slash commands complete asynchronously. If their result is | |
| 3452 | 3452 | // displayed while confirmation is waiting, keep Esc assigned to the note | |
| @@ -6988,7 +6988,7 @@ func stripAutoCompactNotes(notes []string) []string { | |||
| 6988 | 6988 | // inline auto-compact heads-up. Lives in extNotes so it survives | |
| 6989 | 6989 | // the busy-spinner overwrite of the status row. | |
| 6990 | 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 | 6994 | // isContextOverflowError matches provider responses that reject the | |
| @@ -1274,7 +1274,7 @@ func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActi | |||||
| 1274 | } | 1274 | } | ||
| 1275 | limit := maxNarrowExtensionChromeRows | 1275 | 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 trimmed | 1278 | 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.Title | 1311 | 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 = true | 1314 | stopWidgets = true | ||
| 1315 | break | 1315 | 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 := 0 | 1332 | statusRows := 0 | ||
| @@ -1348,7 +1348,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool | |||||
| 1348 | color = i.cfg.Theme.Tool | 1348 | color = i.cfg.Theme.Tool | ||
| 1349 | } | 1349 | } | ||
| 1350 | label := " [" + extName + "] " + status.Text | 1350 | 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 out | 1361 | 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 the | 1473 | // Live shell-escape output (!command / entry.pre) streams into the | ||
| @@ -1772,10 +1772,10 @@ func (i *Interactive) redraw() { | |||||
| 1772 | var busyPrefix string | 1772 | 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 model | 1850 | // Hint row, rendered in the same muted tone as the model | ||
| 1851 | // info on the status bar so it reads as ambient metadata | 1851 | // info on the status bar so it reads as ambient metadata | ||
| 1852 | // rather than a chip. Tells the user how to recover the | 1852 | // 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.Tool | 3443 | 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 ok | 3447 | 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 is | 3451 | // Extension slash commands complete asynchronously. If their result is | ||
| 3452 | // displayed while confirmation is waiting, keep Esc assigned to the note | 3452 | // 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 survives | 6988 | // 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 the | 6994 | // 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 { | |||
| 104 | 104 | var lines []string | |
| 105 | 105 | lines = append(lines, frameHeader(th, "jump to turn", width)) | |
| 106 | 106 | 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")) | |
| 109 | 109 | lines = append(lines, frameRule(th, width)) | |
| 110 | 110 | return lines | |
| 111 | 111 | } | |
| @@ -115,10 +115,10 @@ func (d *jumpDialog) Render(th tui.Theme, width int) []string { | |||
| 115 | 115 | if d.filter != "" { | |
| 116 | 116 | 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 | 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 | 122 | lines = append(lines, frameRule(th, width)) | |
| 123 | 123 | return lines | |
| 124 | 124 | } | |
| @@ -140,7 +140,7 @@ func (d *jumpDialog) Render(th tui.Theme, width int) []string { | |||
| 140 | 140 | } | |
| 141 | 141 | } | |
| 142 | 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 | 145 | for i := start; i < end; i++ { | |
| 146 | 146 | t := d.visible[i] | |
| @@ -148,11 +148,11 @@ func (d *jumpDialog) Render(th tui.Theme, width int) []string { | |||
| 148 | 148 | if i == d.cursor { | |
| 149 | 149 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 150 | 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 | 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 | 158 | lines = append(lines, frameRule(th, width)) | |
| @@ -104,8 +104,8 @@ func (d *jumpDialog) Render(th tui.Theme, width int) []string { | |||||
| 104 | var lines []string | 104 | 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 lines | 110 | 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)) + hint | 116 | 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 lines | 123 | 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 { | |||
| 497 | 497 | } | |
| 498 | 498 | lines := []string{frameHeader(th, "llama.cpp models", width)} | |
| 499 | 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 | 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 | 506 | switch d.step { | |
| 507 | 507 | case llamaLoading: | |
| 508 | - | lines = append(lines, th.FG256(th.Muted, " working...")) | |
| 508 | + | lines = append(lines, th.FGColor(th.Muted, " working...")) | |
| 509 | 509 | case llamaModels: | |
| 510 | 510 | const visible = 12 | |
| 511 | 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 | 523 | if index == d.cursor { | |
| 524 | 524 | lines = append(lines, th.PadHighlight(line, width)) | |
| 525 | 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 | 530 | case llamaConfirmUnload: | |
| 531 | 531 | lines = append(lines, "", " Unload "+d.selected+"?") | |
| 532 | 532 | for index, label := range []string{"Yes", "No"} { | |
| @@ -534,7 +534,7 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string { | |||
| 534 | 534 | if index == d.cursor { | |
| 535 | 535 | lines = append(lines, th.PadHighlight(line, width)) | |
| 536 | 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 | 540 | case llamaConfirmRemove: | |
| @@ -544,11 +544,11 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string { | |||
| 544 | 544 | if index == d.cursor { | |
| 545 | 545 | lines = append(lines, th.PadHighlight(line, width)) | |
| 546 | 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 | 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 | 552 | start, end := dialogWindow(d.cursor, len(d.results), 10) | |
| 553 | 553 | for index := start; index < end; index++ { | |
| 554 | 554 | result := d.results[index] | |
| @@ -556,20 +556,20 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string { | |||
| 556 | 556 | if index == d.cursor { | |
| 557 | 557 | lines = append(lines, th.PadHighlight(line, width)) | |
| 558 | 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 | 563 | case llamaModelDetails: | |
| 564 | 564 | lines = append(lines, "", " "+d.details.ID) | |
| 565 | 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 | 569 | case llamaQuantizations: | |
| 570 | 570 | lines = append(lines, "", " Select quantization for "+d.details.ID) | |
| 571 | 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 | 574 | start, end := dialogWindow(d.cursor, len(d.details.Quantizations), 10) | |
| 575 | 575 | for index := start; index < end; index++ { | |
| @@ -585,24 +585,24 @@ func (d *llamaDialog) Render(th tui.Theme, width int) []string { | |||
| 585 | 585 | if index == d.cursor { | |
| 586 | 586 | lines = append(lines, th.PadHighlight(line, width)) | |
| 587 | 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 | 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 | 593 | if d.progress.HasRatio { | |
| 594 | 594 | const cells = 32 | |
| 595 | 595 | filled := int(d.progress.Ratio*float64(cells) + .5) | |
| 596 | 596 | filled = max(0, min(cells, filled)) | |
| 597 | 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 | 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 | 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 | 607 | return append(lines, frameRule(th, width)) | |
| 608 | 608 | } | |
| @@ -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 = 12 | 510 | 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 = 32 | 594 | 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 { | |||
| 135 | 135 | } | |
| 136 | 136 | lines = append(lines, frameHeader(th, "login", width)) | |
| 137 | 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 | 139 | for i, o := range opts { | |
| 140 | 140 | plain := " " + o | |
| 141 | 141 | if i == d.cursor { | |
| 142 | 142 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 143 | 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 | 147 | lines = append(lines, frameRule(th, width)) | |
| @@ -156,7 +156,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 156 | 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 | 160 | start, end := d.providerPage(len(opts)) | |
| 161 | 161 | for i := start; i < end; i++ { | |
| 162 | 162 | o := opts[i] | |
| @@ -170,81 +170,81 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 170 | 170 | if i == d.cursor { | |
| 171 | 171 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 172 | 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 | 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 | 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 | 181 | lines = append(lines, frameRule(th, width)) | |
| 182 | 182 | case loginStepLlamaURL: | |
| 183 | 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 | 185 | if d.llamaEd == nil { | |
| 186 | 186 | d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| 187 | 187 | } | |
| 188 | 188 | edLines, _, _ := d.llamaEd.Render(width - 2) | |
| 189 | 189 | lines = append(lines, edLines...) | |
| 190 | 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 | 194 | case loginStepLlamaKey: | |
| 195 | 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 | 197 | if d.llamaEd == nil { | |
| 198 | 198 | d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| 199 | 199 | } | |
| 200 | 200 | d.llamaEd.Mask = true | |
| 201 | 201 | edLines, _, _ := d.llamaEd.Render(width - 2) | |
| 202 | 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 | 204 | case loginStepWaiting: | |
| 205 | 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 | 207 | wrapW := width - 2 | |
| 208 | 208 | if wrapW < 20 { | |
| 209 | 209 | wrapW = 20 | |
| 210 | 210 | } | |
| 211 | 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 | 214 | lines = append(lines, "") | |
| 215 | 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 | 217 | lines = append(lines, frameRule(th, width)) | |
| 218 | 218 | 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 | 221 | if d.codeEd == nil { | |
| 222 | 222 | d.codeEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| 223 | 223 | } | |
| 224 | 224 | edLines, _, _ := d.codeEd.Render(width - 2) | |
| 225 | 225 | lines = append(lines, edLines...) | |
| 226 | 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 | 228 | lines = append(lines, frameRule(th, width)) | |
| 229 | 229 | case loginStepPasteCode: | |
| 230 | 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 | 232 | wrapW := width - 2 | |
| 233 | 233 | if wrapW < 20 { | |
| 234 | 234 | wrapW = 20 | |
| 235 | 235 | } | |
| 236 | 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 | 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 | 241 | if d.codeEd == nil { | |
| 242 | 242 | d.codeEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| 243 | 243 | } | |
| 244 | 244 | edLines, _, _ := d.codeEd.Render(width - 2) | |
| 245 | 245 | lines = append(lines, edLines...) | |
| 246 | 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 | 248 | lines = append(lines, frameRule(th, width)) | |
| 249 | 249 | case loginStepInfo: | |
| 250 | 250 | title := d.infoTitle | |
| @@ -254,18 +254,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 254 | 254 | lines = append(lines, frameHeader(th, title, width)) | |
| 255 | 255 | lines = append(lines, d.infoLines...) | |
| 256 | 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 | 258 | lines = append(lines, frameRule(th, width)) | |
| 259 | 259 | case loginStepDone: | |
| 260 | 260 | title := "login - failed" | |
| 261 | - | body := th.FG256(th.Error, d.message) | |
| 261 | + | body := th.FGColor(th.Error, d.message) | |
| 262 | 262 | if d.success { | |
| 263 | 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 | 266 | lines = append(lines, frameHeader(th, title, width)) | |
| 267 | 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 | 269 | lines = append(lines, frameRule(th, width)) | |
| 270 | 270 | } | |
| 271 | 271 | return lines | |
| @@ -384,14 +384,14 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||
| 384 | 384 | var mark, body string | |
| 385 | 385 | switch method { | |
| 386 | 386 | 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") | |
| 389 | 389 | 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") | |
| 392 | 392 | 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") | |
| 395 | 395 | } | |
| 396 | 396 | return " " + mark + " " + body | |
| 397 | 397 | } | |
| @@ -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 := " " + o | 140 | 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 = true | 200 | 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 - 2 | 207 | wrapW := width - 2 | ||
| 208 | if wrapW < 20 { | 208 | if wrapW < 20 { | ||
| 209 | wrapW = 20 | 209 | 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 | break | 218 | 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 - 2 | 232 | wrapW := width - 2 | ||
| 233 | if wrapW < 20 { | 233 | if wrapW < 20 { | ||
| 234 | wrapW = 20 | 234 | 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.infoTitle | 250 | 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 lines | 271 | return lines | ||
| @@ -384,14 +384,14 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||||
| 384 | var mark, body string | 384 | 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 + " " + body | 396 | 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 { | |||
| 86 | 86 | } | |
| 87 | 87 | var lines []string | |
| 88 | 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 | 90 | for i, it := range d.items { | |
| 91 | 91 | plain := " " + it.label | |
| 92 | 92 | if it.method != "" { | |
| @@ -95,7 +95,7 @@ func (d *logoutDialog) Render(th tui.Theme, width int) []string { | |||
| 95 | 95 | if i == d.cursor { | |
| 96 | 96 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 97 | 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 | 101 | 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 []string | 87 | 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.label | 91 | 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) { | |||
| 12 | 12 | d := newLogoutDialog() | |
| 13 | 13 | d.Open([]logoutItem{{label: "Google", target: "google", method: "api key"}}) | |
| 14 | 14 | 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 | 18 | selected := lines[2] | |
| 17 | 19 | if got := runewidth.StringWidth(stripANSIBytes(selected)); got != width { | |
| 18 | 20 | 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 = 60 | 14 | 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 { | |||
| 153 | 153 | } else { | |
| 154 | 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 | 157 | if d.showReasoning { | |
| 158 | 158 | reasoning := d.reasoning | |
| 159 | 159 | if reasoning == "" { | |
| 160 | 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 | 165 | if len(d.view) == 0 { | |
| @@ -167,7 +167,7 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||
| 167 | 167 | if len(d.all) == 0 { | |
| 168 | 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 | 171 | lines = append(lines, frameRule(th, width)) | |
| 172 | 172 | return lines | |
| 173 | 173 | } | |
| @@ -217,18 +217,18 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||
| 217 | 217 | if i == d.cursor { | |
| 218 | 218 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 219 | 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 | 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 | 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 | 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 | 234 | 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.reasoning | 158 | 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 lines | 172 | 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 { | |||
| 64 | 64 | if idx > 0 { | |
| 65 | 65 | 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 | 70 | 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 = indent | 65 | 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 out | 70 | return out | ||
M packages/agent/modes/reload_ext_test.go +2 -2
| @@ -67,7 +67,7 @@ func TestStartupExtensionFailureAppearsInChatWithoutEnteringContext(t *testing.T | |||
| 67 | 67 | agent := core.NewAgent(nil, "", "", nil) | |
| 68 | 68 | i := NewInteractive(InteractiveConfig{ | |
| 69 | 69 | Agent: agent, | |
| 70 | - | Theme: tui.Theme{Error: 1}, | |
| 70 | + | Theme: tui.Theme{Error: tui.Color256(1)}, | |
| 71 | 71 | StartupExtensionErrors: []string{"startup failed: stderr log: /tmp/startup.log"}, | |
| 72 | 72 | }) | |
| 73 | 73 | ||
| @@ -84,7 +84,7 @@ func TestReloadFailureRemainsInChatWithoutEnteringContext(t *testing.T) { | |||
| 84 | 84 | agent := core.NewAgent(nil, "", "", nil) | |
| 85 | 85 | i := NewInteractive(InteractiveConfig{ | |
| 86 | 86 | Agent: agent, | |
| 87 | - | Theme: tui.Theme{Error: 1}, | |
| 87 | + | Theme: tui.Theme{Error: tui.Color256(1)}, | |
| 88 | 88 | }) | |
| 89 | 89 | seq := i.setReloadStatus("broken extension: stderr log: /tmp/ext.log", true) | |
| 90 | 90 | 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 { | |||
| 107 | 107 | lines = append(lines, frameHeader(th, header, width)) | |
| 108 | 108 | ||
| 109 | 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 | 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 | 119 | } else { | |
| 120 | 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 | 124 | if len(d.view) == 0 { | |
| 125 | 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 | 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 | 130 | lines = append(lines, frameRule(th, width)) | |
| 131 | 131 | return lines | |
| @@ -161,14 +161,14 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string { | |||
| 161 | 161 | if i == d.cursor { | |
| 162 | 162 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 163 | 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 | 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 | 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 | 174 | 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 lines | 131 | 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 { | |||
| 357 | 357 | var lines []string | |
| 358 | 358 | lines = append(lines, frameHeader(th, "sessions", width)) | |
| 359 | 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 | 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 | 363 | lines = append(lines, frameRule(th, width)) | |
| 364 | 364 | return lines | |
| 365 | 365 | } | |
| 366 | 366 | } 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")) | |
| 369 | 369 | lines = append(lines, frameRule(th, width)) | |
| 370 | 370 | return lines | |
| 371 | 371 | } | |
| 372 | 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 | 374 | text := d.rename | |
| 375 | 375 | if text == "" { | |
| 376 | - | text = th.FG256(th.Muted, "session name") | |
| 376 | + | text = th.FGColor(th.Muted, "session name") | |
| 377 | 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 | 381 | lines = append(lines, frameRule(th, width)) | |
| 382 | 382 | return lines | |
| 383 | 383 | } | |
| @@ -385,7 +385,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 385 | 385 | if d.loading { | |
| 386 | 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 | 390 | // Viewport: windowed slice of d.sessions around d.cursor so a | |
| 391 | 391 | // list taller than the terminal still scrolls. Caller sets | |
| @@ -406,7 +406,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 406 | 406 | // Top indicator: how many rows are above the viewport. | |
| 407 | 407 | if d.viewTop > 0 { | |
| 408 | 408 | 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 | 411 | for i := d.viewTop; i < viewBot; i++ { | |
| 412 | 412 | s := d.sessions[i] | |
| @@ -414,13 +414,13 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 414 | 414 | if i == d.cursor { | |
| 415 | 415 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 416 | 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 | 420 | // Bottom indicator: how many rows are below the viewport. | |
| 421 | 421 | if viewBot < total { | |
| 422 | 422 | 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 | 425 | lines = append(lines, frameRule(th, width)) | |
| 426 | 426 | return lines | |
| @@ -518,7 +518,7 @@ func (d *sessionDialog) loadingMessage(th tui.Theme) string { | |||
| 518 | 518 | if d.loadingTotal > 0 { | |
| 519 | 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 | 524 | func formatRelative(t time.Time) string { | |
| @@ -357,27 +357,27 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 357 | var lines []string | 357 | 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 lines | 364 | 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 lines | 370 | 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.rename | 374 | 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 lines | 382 | 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 a | 390 | // Viewport: windowed slice of d.sessions around d.cursor so a | ||
| 391 | // list taller than the terminal still scrolls. Caller sets | 391 | // 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.viewTop | 408 | 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 - viewBot | 422 | 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 lines | 426 | 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 { | |||
| 84 | 84 | } | |
| 85 | 85 | var lines []string | |
| 86 | 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 | 88 | for i, it := range d.items { | |
| 89 | 89 | text := " " + it.label | |
| 90 | 90 | if it.hint != "" { | |
| @@ -93,7 +93,7 @@ func (d *sessionOpsDialog) Render(th tui.Theme, width int) []string { | |||
| 93 | 93 | if i == d.cursor { | |
| 94 | 94 | lines = append(lines, th.PadHighlight(text, width)) | |
| 95 | 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 | 99 | 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 []string | 85 | 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.label | 89 | 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 { | |||
| 292 | 292 | lines := []string{line(frameHeader(th, "session tree", renderWidth))} | |
| 293 | 293 | if len(d.items) == 0 { | |
| 294 | 294 | 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")), | |
| 297 | 297 | line(frameRule(th, renderWidth)), | |
| 298 | 298 | ) | |
| 299 | 299 | return lines | |
| 300 | 300 | } | |
| 301 | - | lines = append(lines, line(th.FG256(th.Muted, | |
| 301 | + | lines = append(lines, line(th.FGColor(th.Muted, | |
| 302 | 302 | "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):"))) | |
| 303 | 303 | ||
| 304 | 304 | d.followCursor() | |
| @@ -311,7 +311,7 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string { | |||
| 311 | 311 | start = end | |
| 312 | 312 | } | |
| 313 | 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 | 316 | for i := start; i < end; i++ { | |
| 317 | 317 | it := d.items[i] | |
| @@ -323,9 +323,9 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string { | |||
| 323 | 323 | } | |
| 324 | 324 | } | |
| 325 | 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 | 329 | return lines | |
| 330 | 330 | } | |
| 331 | 331 | ||
| @@ -821,13 +821,13 @@ func colorSessionTreeLine(th tui.Theme, line string) string { | |||
| 821 | 821 | plain := strings.TrimSpace(line) | |
| 822 | 822 | switch { | |
| 823 | 823 | case strings.HasPrefix(plain, "you:"): | |
| 824 | - | return th.FG256(th.FG, line) | |
| 824 | + | return th.FGColor(th.FG, line) | |
| 825 | 825 | case strings.HasPrefix(plain, "zot:"): | |
| 826 | - | return th.FG256(th.Muted, line) | |
| 826 | + | return th.FGColor(th.Muted, line) | |
| 827 | 827 | case strings.HasPrefix(plain, "tool:"): | |
| 828 | - | return th.FG256(th.ToolOut, line) | |
| 828 | + | return th.FGColor(th.ToolOut, line) | |
| 829 | 829 | default: | |
| 830 | - | return th.FG256(th.Muted, line) | |
| 830 | + | return th.FGColor(th.Muted, line) | |
| 831 | 831 | } | |
| 832 | 832 | } | |
| 833 | 833 | ||
| @@ -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 lines | 299 | 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 = end | 311 | 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 lines | 329 | 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 { | |||
| 238 | 238 | var lines []string | |
| 239 | 239 | lines = append(lines, frameHeader(th, d.title, width)) | |
| 240 | 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 | 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 | 245 | for i, it := range d.items { | |
| 246 | 246 | box := "[ ]" | |
| @@ -259,10 +259,10 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string { | |||
| 259 | 259 | plain = " " + box + " " + it.label + ": " + it.options[it.choice].label | |
| 260 | 260 | } | |
| 261 | 261 | if it.hint != "" { | |
| 262 | - | plain += " " + th.FG256(th.Muted, "("+it.hint+")") | |
| 262 | + | plain += " " + th.FGColor(th.Muted, "("+it.hint+")") | |
| 263 | 263 | } | |
| 264 | 264 | if it.disabled { | |
| 265 | - | lines = append(lines, th.FG256(th.Muted, plain)) | |
| 265 | + | lines = append(lines, th.FGColor(th.Muted, plain)) | |
| 266 | 266 | } else if i == d.cursor { | |
| 267 | 267 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 268 | 268 | } else { | |
| @@ -270,7 +270,7 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string { | |||
| 270 | 270 | } | |
| 271 | 271 | if it.desc != "" { | |
| 272 | 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 | 291 | lines := []string{frameHeader(th, title, width)} | |
| 292 | 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 | 296 | for idx, opt := range it.options { | |
| 297 | 297 | marker := " " | |
| 298 | 298 | if idx == it.choice { | |
| @@ -306,7 +306,7 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string { | |||
| 306 | 306 | } | |
| 307 | 307 | if opt.desc != "" { | |
| 308 | 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 | } | |
| @@ -238,9 +238,9 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string { | |||||
| 238 | var lines []string | 238 | 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].label | 259 | 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 { | |||
| 99 | 99 | ||
| 100 | 100 | out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)} | |
| 101 | 101 | 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")) | |
| 104 | 104 | out = append(out, frameRule(th, width)) | |
| 105 | 105 | return out | |
| 106 | 106 | } | |
| @@ -108,7 +108,7 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string { | |||
| 108 | 108 | const maxRows = 12 | |
| 109 | 109 | start, end := visibleWindow(d.cursor, len(d.skills), maxRows) | |
| 110 | 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 | 113 | for i := start; i < end; i++ { | |
| 114 | 114 | s := d.skills[i] | |
| @@ -116,13 +116,13 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string { | |||
| 116 | 116 | if i == d.cursor { | |
| 117 | 117 | out = append(out, th.PadHighlight(" "+row, width)) | |
| 118 | 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 | 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 | 126 | out = append(out, frameRule(th, width)) | |
| 127 | 127 | return out | |
| 128 | 128 | } | |
| @@ -131,8 +131,8 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string { | |||
| 131 | 131 | s := d.viewing | |
| 132 | 132 | out := []string{ | |
| 133 | 133 | 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+")"), | |
| 136 | 136 | "", | |
| 137 | 137 | } | |
| 138 | 138 | ||
| @@ -159,7 +159,7 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string { | |||
| 159 | 159 | out = append(out, " "+line) | |
| 160 | 160 | } | |
| 161 | 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 | 164 | out = append(out, frameRule(th, width)) | |
| 165 | 165 | 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 out | 105 | return out | ||
| 106 | } | 106 | } | ||
| @@ -108,7 +108,7 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string { | |||||
| 108 | const maxRows = 12 | 108 | 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 out | 127 | return out | ||
| 128 | } | 128 | } | ||
| @@ -131,8 +131,8 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string { | |||||
| 131 | s := d.viewing | 131 | 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 out | 165 | 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 | |||
| 492 | 492 | if lw := runewidth.StringWidth(label); lw < width { | |
| 493 | 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 | 496 | lines = append(lines, "") | |
| 497 | 497 | continue | |
| 498 | 498 | } | |
| @@ -504,13 +504,13 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string | |||
| 504 | 504 | if i == s.cursor { | |
| 505 | 505 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 506 | 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 | 510 | // Blank row before the hint visually detaches it from the | |
| 511 | 511 | // command list and groups it with its trailing blank. | |
| 512 | 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 | 514 | // Blank row after the hint separates the popup from the status | |
| 515 | 515 | // bar / editor below it. | |
| 516 | 516 | 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 | continue | 497 | 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 the | 510 | // 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 status | 514 | // 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 { | |||
| 1018 | 1018 | d.openModelPicker() | |
| 1019 | 1019 | } | |
| 1020 | 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 | 1022 | return out | |
| 1023 | 1023 | } | |
| 1024 | 1024 | if d.spawning { | |
| @@ -1036,7 +1036,7 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string { | |||
| 1036 | 1036 | if d.pendingProvider != "" { | |
| 1037 | 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 | 1041 | if d.fileSuggest != nil { | |
| 1042 | 1042 | d.fileSuggest.SetCWD(d.cwd) | |
| @@ -1056,26 +1056,26 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string { | |||
| 1056 | 1056 | // Matching blank row below before the hint, mirroring the | |
| 1057 | 1057 | // main input's editor breathing room. | |
| 1058 | 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 | 1060 | out = append(out, frameRule(th, width)) | |
| 1061 | 1061 | return out | |
| 1062 | 1062 | } | |
| 1063 | 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 | 1065 | out = append(out, frameRule(th, width)) | |
| 1066 | 1066 | return out | |
| 1067 | 1067 | } | |
| 1068 | 1068 | ||
| 1069 | 1069 | // Column header for readability. | |
| 1070 | 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 | 1073 | for i, r := range d.rows { | |
| 1074 | 1074 | row := formatSupervisorRow(r, width-2) | |
| 1075 | 1075 | if i == d.cursor { | |
| 1076 | 1076 | out = append(out, th.PadHighlight(" "+row, width)) | |
| 1077 | 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 | 1081 | out = append(out, frameRule(th, width)) | |
| @@ -1090,29 +1090,29 @@ func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string { | |||
| 1090 | 1090 | } | |
| 1091 | 1091 | header := []string{ | |
| 1092 | 1092 | 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)), | |
| 1096 | 1096 | } | |
| 1097 | 1097 | if a.Model != "" { | |
| 1098 | 1098 | modelLine := "model: " + a.Model | |
| 1099 | 1099 | if a.Provider != "" { | |
| 1100 | 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 | 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 | 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 | 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 | 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 | 1123 | raw = strings.Split(a.Tail, "\n") | |
| 1124 | 1124 | } | |
| 1125 | 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 | 1127 | header = d.appendTranscriptEditor(header, th, width, a) | |
| 1128 | 1128 | header = append(header, frameRule(th, width)) | |
| 1129 | 1129 | return header | |
| @@ -1167,7 +1167,7 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid | |||
| 1167 | 1167 | } | |
| 1168 | 1168 | if a.Status != subagents.StatusRunning || d.send == nil { | |
| 1169 | 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 | 1171 | return out | |
| 1172 | 1172 | } | |
| 1173 | 1173 | // 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 | 1202 | out = append(out, "") | |
| 1203 | 1203 | 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()), | |
| 1207 | 1207 | ) | |
| 1208 | 1208 | out = append(out, " "+prefix) | |
| 1209 | 1209 | } else { | |
| @@ -1218,7 +1218,7 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid | |||
| 1218 | 1218 | out = append(out, " "+l) | |
| 1219 | 1219 | } | |
| 1220 | 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 | 1222 | return out | |
| 1223 | 1223 | } | |
| 1224 | 1224 | ||
| @@ -1325,11 +1325,11 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c | |||
| 1325 | 1325 | out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...) | |
| 1326 | 1326 | case kindStderr: | |
| 1327 | 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 | 1330 | case kindError: | |
| 1331 | 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 | 1353 | if target == "" { | |
| 1354 | 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 | 1357 | if d.promptSuggest != nil { | |
| 1358 | 1358 | d.promptSuggest.SetCWD(d.cwd) | |
| 1359 | 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 | 1371 | // Matching blank row below before the hint. | |
| 1372 | 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 | 1374 | out = append(out, frameRule(th, width)) | |
| 1375 | 1375 | return out | |
| 1376 | 1376 | } | |
| @@ -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 out | 1022 | 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 the | 1056 | // 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 out | 1061 | 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 out | 1066 | 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.Model | 1098 | 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 header | 1129 | 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 out | 1171 | return out | ||
| 1172 | } | 1172 | } | ||
| 1173 | // Lazy-init the editor here too so the very first render of the | 1173 | // 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 out | 1222 | 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 out | 1375 | 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 { | |||
| 83 | 83 | } | |
| 84 | 84 | var lines []string | |
| 85 | 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 | 87 | for i, it := range d.items { | |
| 88 | 88 | plain := " " + it.label | |
| 89 | 89 | if it.hint != "" { | |
| 90 | - | plain += " " + th.FG256(th.Muted, "("+it.hint+")") | |
| 90 | + | plain += " " + th.FGColor(th.Muted, "("+it.hint+")") | |
| 91 | 91 | } | |
| 92 | 92 | if i == d.cursor { | |
| 93 | 93 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 94 | 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 | 98 | 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 []string | 84 | 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.label | 88 | 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) { | |||
| 43 | 43 | if !interactive.cfg.Theme.Inherited { | |
| 44 | 44 | t.Fatal("applying inherited theme did not switch the live theme") | |
| 45 | 45 | } | |
| 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") | |
| 48 | 48 | } | |
| 49 | 49 | if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground { | |
| 50 | 50 | 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 { | |||
| 33 | 33 | out = append(out, "") | |
| 34 | 34 | ||
| 35 | 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 | 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 | 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 | 44 | 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 { | |||
| 14 | 14 | if showVersion && version != "" { | |
| 15 | 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 | 18 | return []string{ | |
| 19 | 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 | } | |
| @@ -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. | |||
| 19 | 19 | ||
| 20 | 20 | The `/settings` picker also provides an **inherited (from terminal)** | |
| 21 | 21 | 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. | |
| 24 | 26 | ||
| 25 | 27 | User themes are discovered from: | |
| 26 | 28 | ||
| @@ -191,6 +193,8 @@ under `colors`, when they should apply to both modes: | |||
| 191 | 193 | - `fg` — default foreground text. | |
| 192 | 194 | - `muted` — secondary text, dividers, gutters, inactive hints. | |
| 193 | 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 | 198 | - `user` — user role label color; mostly compatibility. | |
| 195 | 199 | - `user_bubble_bg` — background behind user message rows. | |
| 196 | 200 | - `user_bubble_fg` — foreground inside user message rows. | |
| @@ -203,9 +207,7 @@ under `colors`, when they should apply to both modes: | |||
| 203 | 207 | - `selection_bg` — highlighted row background. | |
| 204 | 208 | - `selection_fg` — highlighted row foreground. | |
| 205 | 209 | ||
| 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: | |
| 209 | 211 | ||
| 210 | 212 | ```json | |
| 211 | 213 | 254 | |
| @@ -215,6 +217,12 @@ Most color fields are xterm-256 indexes (`0`–`255`). | |||
| 215 | 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 | 226 | ## Spinner fields | |
| 219 | 227 | ||
| 220 | 228 | 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 truecolor | 21 | 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 | 22 | + | 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 | ```json | 212 | ```json | ||
| 211 | 254 | 213 | 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 fields | 226 | ## Spinner fields | ||
| 219 | 227 | ||||
| 220 | Spinner settings can appear at top level, under `colors`, or under | 228 | 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 | |||
| 846 | 846 | var errZotfileConsentInterrupted = errors.New("interrupted") | |
| 847 | 847 | ||
| 848 | 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 | 850 | if !color { | |
| 851 | 851 | return text | |
| 852 | 852 | } | |
| 853 | - | return th.FG256(c, text) | |
| 853 | + | return th.FGColor(c, text) | |
| 854 | 854 | } | |
| 855 | 855 | ||
| 856 | 856 | var sb strings.Builder | |
| @@ -886,7 +886,7 @@ func readZotfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error) | |||
| 886 | 886 | fmt.Fprint(out, "\b \b") | |
| 887 | 887 | 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 | 891 | switch { | |
| 892 | 892 | case errors.Is(err, errZotfileConsentInterrupted): | |
| @@ -938,11 +938,11 @@ func permissionSummary(p tools.PermissionSet) string { | |||
| 938 | 938 | } | |
| 939 | 939 | ||
| 940 | 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 | 942 | if !color { | |
| 943 | 943 | return text | |
| 944 | 944 | } | |
| 945 | - | return th.FG256(c, text) | |
| 945 | + | return th.FGColor(c, text) | |
| 946 | 946 | } | |
| 947 | 947 | permissionValue := func(value string) string { return style(th.FG, value) } | |
| 948 | 948 | ||
| @@ -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 text | 851 | return text | ||
| 852 | } | 852 | } | ||
| 853 | - | return th.FG256(c, text) | 853 | + | return th.FGColor(c, text) |
| 854 | } | 854 | } | ||
| 855 | 855 | ||||
| 856 | var sb strings.Builder | 856 | 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 | return | 887 | 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 text | 943 | 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) { | |||
| 329 | 329 | } | |
| 330 | 330 | ||
| 331 | 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 | 336 | zf := zotfileLoaded{} | |
| 334 | 337 | zf.Manifest.Name = "reviewer" | |
| 335 | 338 | zf.Manifest.Version = "1.2.3" | |
| @@ -339,13 +342,13 @@ func TestFormatZotfileConsentUsesThemeColors(t *testing.T) { | |||
| 339 | 342 | ||
| 340 | 343 | got := formatZotfileConsent(zf, perms, th, true) | |
| 341 | 344 | 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]"), | |
| 349 | 352 | } { | |
| 350 | 353 | if !strings.Contains(got, want) { | |
| 351 | 354 | 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 { | |||
| 38 | 38 | } | |
| 39 | 39 | style := th.chromaStyle() | |
| 40 | 40 | formatterName := "terminal256" | |
| 41 | - | if th.ColorMode == ColorModeTrueColor { | |
| 41 | + | if th.Terminal.TrueColor { | |
| 42 | 42 | formatterName = "terminal16m" | |
| 43 | 43 | } | |
| 44 | 44 | formatter := formatters.Get(formatterName) | |
| @@ -243,7 +243,7 @@ func (th Theme) chromaStyle() *chroma.Style { | |||
| 243 | 243 | func (th Theme) syntaxKey() string { | |
| 244 | 244 | syn := th.Syntax | |
| 245 | 245 | outputMode := "256" | |
| 246 | - | if th.ColorMode == ColorModeTrueColor { | |
| 246 | + | if th.Terminal.TrueColor { | |
| 247 | 247 | outputMode = "truecolor" | |
| 248 | 248 | } | |
| 249 | 249 | 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.Syntax | 244 | 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 { | |||
| 47 | 47 | if width < 1 { | |
| 48 | 48 | width = 1 | |
| 49 | 49 | } | |
| 50 | - | rule := th.FG256(th.Muted, strings.Repeat("─", width)) | |
| 50 | + | rule := th.FGColor(th.Muted, strings.Repeat("─", width)) | |
| 51 | 51 | out := make([]string, 0, len(lines)+2) | |
| 52 | 52 | out = append(out, rule) | |
| 53 | 53 | out = append(out, lines...) | |
| @@ -63,13 +63,13 @@ func InputBlock(th Theme, lines []string, width int) []string { | |||
| 63 | 63 | if bubbleW < 1 { | |
| 64 | 64 | bubbleW = 1 | |
| 65 | 65 | } | |
| 66 | + | bg := th.sgrBGColor(th.UserBubbleBG) | |
| 67 | + | bar := bg + th.FGColor(th.Accent, "▌ ") | |
| 66 | 68 | row := func(content string) string { | |
| 67 | 69 | visible := visibleWidth(content) | |
| 68 | 70 | if visible < bubbleW { | |
| 69 | 71 | content += strings.Repeat(" ", bubbleW-visible) | |
| 70 | 72 | } | |
| 71 | - | bg := th.sgrBGColor(th.UserBubbleBG) | |
| 72 | - | bar := bg + th.FG256(th.Accent, "▌ ") | |
| 73 | 73 | body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset | |
| 74 | 74 | return bar + body | |
| 75 | 75 | } | |
| @@ -47,7 +47,7 @@ func InputLines(th Theme, lines []string, width int) []string { | |||||
| 47 | if width < 1 { | 47 | if width < 1 { | ||
| 48 | width = 1 | 48 | 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 = 1 | 64 | 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) + reset | 73 | body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset | ||
| 74 | return bar + body | 74 | 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 { | |||
| 52 | 52 | } | |
| 53 | 53 | } else { | |
| 54 | 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 | 56 | out.WriteString("\n") | |
| 57 | 57 | } | |
| 58 | 58 | } | |
| @@ -106,25 +106,25 @@ func RenderMarkdown(src string, th Theme, width int) string { | |||
| 106 | 106 | level := len(m[1]) | |
| 107 | 107 | body := strings.TrimSpace(m[2]) | |
| 108 | 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 | 110 | continue | |
| 111 | 111 | } | |
| 112 | 112 | // Blockquote. | |
| 113 | 113 | if strings.HasPrefix(trim, "> ") { | |
| 114 | 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 | 116 | continue | |
| 117 | 117 | } | |
| 118 | 118 | // Bullet list. | |
| 119 | 119 | if m := bulletRE.FindStringSubmatch(line); m != nil { | |
| 120 | 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 | 122 | continue | |
| 123 | 123 | } | |
| 124 | 124 | // Numbered list. | |
| 125 | 125 | if m := numberRE.FindStringSubmatch(line); m != nil { | |
| 126 | 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 | 128 | continue | |
| 129 | 129 | } | |
| 130 | 130 | out.WriteString(renderInline(line, th) + "\n") | |
| @@ -354,7 +354,7 @@ func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, h | |||
| 354 | 354 | out := make([]string, 0, height) | |
| 355 | 355 | for r := 0; r < height; r++ { | |
| 356 | 356 | var b strings.Builder | |
| 357 | - | b.WriteString(th.FG256(th.Muted, "|")) | |
| 357 | + | b.WriteString(th.FGColor(th.Muted, "|")) | |
| 358 | 358 | for c := range widths { | |
| 359 | 359 | cell := "" | |
| 360 | 360 | if r < len(wrapped[c]) { | |
| @@ -363,7 +363,7 @@ func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, h | |||
| 363 | 363 | b.WriteByte(' ') | |
| 364 | 364 | b.WriteString(alignCell(cell, widths[c], aligns[c])) | |
| 365 | 365 | b.WriteByte(' ') | |
| 366 | - | b.WriteString(th.FG256(th.Muted, "|")) | |
| 366 | + | b.WriteString(th.FGColor(th.Muted, "|")) | |
| 367 | 367 | } | |
| 368 | 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 | 373 | func renderTableSeparator(widths []int, aligns []tableAlign, th Theme) string { | |
| 374 | 374 | var b strings.Builder | |
| 375 | - | b.WriteString(th.FG256(th.Muted, "|")) | |
| 375 | + | b.WriteString(th.FGColor(th.Muted, "|")) | |
| 376 | 376 | for c, w := range widths { | |
| 377 | 377 | dashes := strings.Repeat("-", w) | |
| 378 | 378 | switch aligns[c] { | |
| @@ -382,9 +382,9 @@ func renderTableSeparator(widths []int, aligns []tableAlign, th Theme) string { | |||
| 382 | 382 | dashes = strings.Repeat("-", maxInt(1, w-1)) + ":" | |
| 383 | 383 | } | |
| 384 | 384 | b.WriteByte(' ') | |
| 385 | - | b.WriteString(th.FG256(th.Muted, dashes)) | |
| 385 | + | b.WriteString(th.FGColor(th.Muted, dashes)) | |
| 386 | 386 | b.WriteByte(' ') | |
| 387 | - | b.WriteString(th.FG256(th.Muted, "|")) | |
| 387 | + | b.WriteString(th.FGColor(th.Muted, "|")) | |
| 388 | 388 | } | |
| 389 | 389 | return b.String() | |
| 390 | 390 | } | |
| @@ -416,7 +416,7 @@ func maxInt(a, b int) int { | |||
| 416 | 416 | func renderInline(s string, th Theme) string { | |
| 417 | 417 | s = codeRE.ReplaceAllStringFunc(s, func(m string) string { | |
| 418 | 418 | inner := m[1 : len(m)-1] | |
| 419 | - | return th.FG256(th.Accent, inner) | |
| 419 | + | return th.FGColor(th.Accent, inner) | |
| 420 | 420 | }) | |
| 421 | 421 | s = boldRE.ReplaceAllStringFunc(s, func(m string) string { | |
| 422 | 422 | 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 | continue | 110 | 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 | continue | 116 | 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 | continue | 122 | 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 | continue | 128 | 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.Builder | 356 | 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.Builder | 374 | 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 { | |||
| 73 | 73 | // row-writing boundary. In particular, Theme.Background is painted | |
| 74 | 74 | // as a full-width row background without making every View renderer | |
| 75 | 75 | // know about terminal padding and reset semantics. | |
| 76 | - | theme Theme | |
| 76 | + | theme Theme | |
| 77 | + | backgroundStyle string | |
| 77 | 78 | } | |
| 78 | 79 | ||
| 79 | 80 | // NewRenderer returns a renderer that writes to out. | |
| @@ -94,6 +95,7 @@ func NewRenderer(out io.Writer) *Renderer { | |||
| 94 | 95 | // background affects every row, so cached frame state is invalidated. | |
| 95 | 96 | func (r *Renderer) SetTheme(th Theme) { | |
| 96 | 97 | r.theme = th | |
| 98 | + | r.backgroundStyle = th.BackgroundStyle() | |
| 97 | 99 | r.Invalidate() | |
| 98 | 100 | } | |
| 99 | 101 | ||
| @@ -224,16 +226,15 @@ func imageFootprintBlank(s string) bool { | |||
| 224 | 226 | // background reaches the right edge, and re-applies the background | |
| 225 | 227 | // after full SGR resets inside the row so local styling does not punch | |
| 226 | 228 | // 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) { | |
| 230 | 231 | return line | |
| 231 | 232 | } | |
| 232 | - | line = strings.ReplaceAll(line, reset, reset+bg) | |
| 233 | + | line = strings.ReplaceAll(line, reset, reset+background) | |
| 233 | 234 | if w := visibleWidth(line); w < cols { | |
| 234 | 235 | line += strings.Repeat(" ", cols-w) | |
| 235 | 236 | } | |
| 236 | - | return bg + line + reset | |
| 237 | + | return background + line + reset | |
| 237 | 238 | } | |
| 238 | 239 | ||
| 239 | 240 | // 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 | 323 | frame[i] = "" | |
| 323 | 324 | } | |
| 324 | 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 | 328 | if r.theme.Background != nil { | |
| 328 | 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 | 372 | // overwrites, leaving ghost highlights behind. | |
| 372 | 373 | hasSelection := false | |
| 373 | 374 | if r.theme.Background == nil { | |
| 374 | - | selectionBG := r.theme.bgPrefix(Color256(r.theme.SelectionBG)) | |
| 375 | + | selectionBG := r.theme.bgPrefix(r.theme.SelectionBG) | |
| 375 | 376 | for _, l := range frame { | |
| 376 | 377 | if selectionBG != "" && strings.Contains(l, selectionBG) { | |
| 377 | 378 | hasSelection = true | |
| @@ -431,11 +432,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 431 | 432 | } | |
| 432 | 433 | chatFrame := make([]string, len(chat)) | |
| 433 | 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 | 437 | bottomFrame := make([]string, len(bottom)) | |
| 437 | 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 | 442 | // 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 | 448 | lines = append(lines, chatFrame...) | |
| 448 | 449 | lines = append(lines, bottomFrame...) | |
| 449 | 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 | 453 | // In main-screen flow mode zot normally emits only its logical | |
| 453 | 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 | 458 | // default transparent case. | |
| 458 | 459 | if r.theme.Background != nil { | |
| 459 | 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 | 464 | if len(lines) == 0 { | |
| @@ -73,7 +73,8 @@ type Renderer struct { | |||||
| 73 | // row-writing boundary. In particular, Theme.Background is painted | 73 | // row-writing boundary. In particular, Theme.Background is painted | ||
| 74 | // as a full-width row background without making every View renderer | 74 | // 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 Theme | 76 | + | 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 = th | 97 | 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 background | 226 | // background reaches the right edge, and re-applies the background | ||
| 225 | // after full SGR resets inside the row so local styling does not punch | 227 | // 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 line | 231 | 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 + reset | 237 | + | return background + line + reset |
| 237 | } | 238 | } | ||
| 238 | 239 | ||||
| 239 | // truncateToWidth clips s so its on-screen width doesn't exceed cols | 240 | // 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 := false | 373 | 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 = true | 378 | 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 is | 442 | // 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 logical | 453 | // 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 | |||
| 88 | 88 | } | |
| 89 | 89 | 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 | 92 | text = padLine(text, phase) | |
| 93 | - | if color < 0 { | |
| 93 | + | if color == nil { | |
| 94 | 94 | return text | |
| 95 | 95 | } | |
| 96 | - | paint := func(value string, valueColor int) string { | |
| 96 | + | paint := func(value string, valueColor TerminalColor) string { | |
| 97 | 97 | if dim { | |
| 98 | 98 | value = Dim(value) | |
| 99 | 99 | } | |
| 100 | - | return th.FG256(valueColor, value) | |
| 100 | + | return th.FGColor(valueColor, value) | |
| 101 | 101 | } | |
| 102 | 102 | if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok { | |
| 103 | 103 | if phase { | |
| 104 | 104 | if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok { | |
| 105 | - | return paint(text[:markerStart], color) + | |
| 105 | + | return paint(text[:markerStart], *color) + | |
| 106 | 106 | 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) | |
| 110 | 110 | } | |
| 111 | 111 | } | |
| 112 | - | return paint(text[:markerStart], color) + | |
| 112 | + | return paint(text[:markerStart], *color) + | |
| 113 | 113 | paint(text[markerStart:markerEnd], th.Muted) + | |
| 114 | - | paint(text[markerEnd:], color) | |
| 114 | + | paint(text[markerEnd:], *color) | |
| 115 | 115 | } | |
| 116 | 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 | 122 | text = strings.ReplaceAll(text, "\r", "") | |
| 123 | 123 | for _, line := range strings.Split(text, "\n") { | |
| 124 | 124 | if len(content) >= height { | |
| @@ -131,17 +131,17 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | 133 | for idx, widget := range ordered { | |
| 134 | - | if idx > 0 && !appendContent("", -1, false, false) { | |
| 134 | + | if idx > 0 && !appendContent("", nil, false, false) { | |
| 135 | 135 | break | |
| 136 | 136 | } | |
| 137 | 137 | label := "[" + widget.Extension + "]" | |
| 138 | 138 | if widget.Title != "" { | |
| 139 | 139 | label += " " + widget.Title | |
| 140 | 140 | } | |
| 141 | - | if !appendContent(label, th.Accent, false, false) { | |
| 141 | + | if !appendContent(label, &th.Accent, false, false) { | |
| 142 | 142 | break | |
| 143 | 143 | } | |
| 144 | - | if !appendContent("", -1, false, false) { | |
| 144 | + | if !appendContent("", nil, false, false) { | |
| 145 | 145 | break | |
| 146 | 146 | } | |
| 147 | 147 | activePhaseIndent := rightBarActivePhaseIndent(widget.Lines) | |
| @@ -157,7 +157,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||
| 157 | 157 | line = " " + line | |
| 158 | 158 | dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase) | |
| 159 | 159 | phase := rightBarChecklistPhaseLine(line, phaseIndent) | |
| 160 | - | if !appendContent(line, th.FG, dim, phase) { | |
| 160 | + | if !appendContent(line, &th.FG, dim, phase) { | |
| 161 | 161 | break | |
| 162 | 162 | } | |
| 163 | 163 | } | |
| @@ -166,7 +166,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||
| 166 | 166 | } | |
| 167 | 167 | } | |
| 168 | 168 | if truncated { | |
| 169 | - | marker := paintLine("... right bar ...", th.Muted, false, false) | |
| 169 | + | marker := paintLine("... right bar ...", &th.Muted, false, false) | |
| 170 | 170 | if len(content) == height { | |
| 171 | 171 | content[len(content)-1] = marker | |
| 172 | 172 | } else { | |
| @@ -350,7 +350,7 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) | |||
| 350 | 350 | if visible := visibleWidth(rightBar); visible < rightBarWidth { | |
| 351 | 351 | rightBar += strings.Repeat(" ", rightBarWidth-visible) | |
| 352 | 352 | } | |
| 353 | - | return main + th.FG256(th.Muted, "│") + rightBar | |
| 353 | + | return main + th.FGColor(th.Muted, "│") + rightBar | |
| 354 | 354 | } | |
| 355 | 355 | ||
| 356 | 356 | // 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 text | 89 | 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 text | 94 | 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 | break | 135 | break | ||
| 136 | } | 136 | } | ||
| 137 | label := "[" + widget.Extension + "]" | 137 | label := "[" + widget.Extension + "]" | ||
| 138 | if widget.Title != "" { | 138 | if widget.Title != "" { | ||
| 139 | label += " " + widget.Title | 139 | label += " " + widget.Title | ||
| 140 | } | 140 | } | ||
| 141 | - | if !appendContent(label, th.Accent, false, false) { | 141 | + | if !appendContent(label, &th.Accent, false, false) { |
| 142 | break | 142 | break | ||
| 143 | } | 143 | } | ||
| 144 | - | if !appendContent("", -1, false, false) { | 144 | + | if !appendContent("", nil, false, false) { |
| 145 | break | 145 | 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 = " " + line | 157 | 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 | break | 161 | 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] = marker | 171 | 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, "│") + rightBar | 353 | + | return main + th.FGColor(th.Muted, "│") + rightBar |
| 354 | } | 354 | } | ||
| 355 | 355 | ||||
| 356 | // DrawRightBar renders an already-laid-out visible chat slice and sticky | 356 | // 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) { | |||
| 127 | 127 | }}, 24, 8) | |
| 128 | 128 | rendered := strings.Join(lines, "\n") | |
| 129 | 129 | 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))) { | |
| 132 | 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 | 170 | func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) { | |
| 171 | 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 | 173 | t.Fatalf("separator is not muted: %q", line) | |
| 174 | 174 | } | |
| 175 | 175 | 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) { | |||
| 105 | 105 | ||
| 106 | 106 | func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) { | |
| 107 | 107 | th := Dark | |
| 108 | - | th.ThinkingMax = 201 | |
| 108 | + | th.ThinkingMax = Color256(201) | |
| 109 | 109 | lines := StatusBar(StatusBarParams{ | |
| 110 | 110 | Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200, | |
| 111 | 111 | }) | |
| @@ -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 := Dark | 107 | th := Dark | ||
| 108 | - | th.ThinkingMax = 201 | 108 | + | 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 @@ | |||
| 3 | 3 | // framework; just ANSI escape codes. | |
| 4 | 4 | package tui | |
| 5 | 5 | ||
| 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. | |
| 14 | 14 | type TerminalColor struct { | |
| 15 | 15 | Mode terminalColorMode | |
| 16 | 16 | Index int | |
| @@ -33,14 +33,6 @@ func ColorRGB(r, g, b int) TerminalColor { | |||
| 33 | 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 | 36 | // TerminalProfile is the best-effort color snapshot reported by the | |
| 45 | 37 | // controlling terminal. The ANSI palette is optional; unknown slots are | |
| 46 | 38 | // resolved through the classic xterm-256 palette. | |
| @@ -66,31 +58,31 @@ func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) { | |||
| 66 | 58 | return p.Palette[index], true | |
| 67 | 59 | } | |
| 68 | 60 | ||
| 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. | |
| 71 | 63 | type Theme struct { | |
| 72 | - | FG int | |
| 73 | - | Muted int | |
| 74 | - | Accent int | |
| 64 | + | FG TerminalColor | |
| 65 | + | Muted TerminalColor | |
| 66 | + | Accent TerminalColor | |
| 75 | 67 | 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 | |
| 77 | 69 | 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. | |
| 92 | 85 | Inherited bool | |
| 93 | - | ColorMode ColorMode | |
| 94 | 86 | Terminal TerminalProfile | |
| 95 | 87 | ||
| 96 | 88 | SpinnerFrames []string | |
| @@ -188,21 +180,21 @@ var defaultSpinnerMessages = []string{ | |||
| 188 | 180 | } | |
| 189 | 181 | ||
| 190 | 182 | 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) | |
| 195 | 187 | 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 | |
| 206 | 198 | SpinnerFrames: defaultSpinnerFrames, | |
| 207 | 199 | SpinnerMessages: defaultSpinnerMessages, | |
| 208 | 200 | SpinnerIntervalMS: 80, | |
| @@ -211,21 +203,21 @@ var Dark = Theme{ | |||
| 211 | 203 | } | |
| 212 | 204 | ||
| 213 | 205 | 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), | |
| 218 | 210 | 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 | |
| 229 | 221 | SpinnerFrames: defaultSpinnerFrames, | |
| 230 | 222 | SpinnerMessages: defaultSpinnerMessages, | |
| 231 | 223 | SpinnerIntervalMS: 80, | |
| @@ -233,43 +225,48 @@ var Light = Theme{ | |||
| 233 | 225 | Syntax: nordSyntax, | |
| 234 | 226 | } | |
| 235 | 227 | ||
| 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 { | |
| 240 | 231 | return t.fgPrefix(c) + s + reset | |
| 241 | 232 | } | |
| 242 | 233 | ||
| 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) | |
| 248 | 240 | } | |
| 249 | 241 | ||
| 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. | |
| 252 | 252 | func (t Theme) BG(c TerminalColor, s string) string { | |
| 253 | 253 | return t.bgPrefix(c) + s + reset | |
| 254 | 254 | } | |
| 255 | 255 | ||
| 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)) | |
| 261 | 258 | } | |
| 262 | 259 | ||
| 263 | 260 | func (t Theme) bgPrefix(color TerminalColor) string { | |
| 264 | 261 | return sgrBGColor(t.resolveColor(color)) | |
| 265 | 262 | } | |
| 266 | 263 | ||
| 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 { | |
| 271 | 268 | percent = clampPercent(percent) | |
| 272 | - | foreground := t.resolveForeground(index) | |
| 269 | + | foreground := t.resolveColor(color) | |
| 273 | 270 | background := t.Terminal.Background | |
| 274 | 271 | if !t.Terminal.HasBackground { | |
| 275 | 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 | 283 | func (t Theme) resolveColor(color TerminalColor) TerminalColor { | |
| 294 | 284 | switch color.Mode { | |
| 295 | 285 | case terminalColor256: | |
| 296 | 286 | 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 { | |
| 300 | 290 | 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 | 293 | r, g, b := xterm256RGB(index) | |
| 311 | 294 | return ColorRGB(r, g, b) | |
| 312 | 295 | } | |
| 313 | 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 | 307 | case terminalColorRGB: | |
| 315 | - | if t.Inherited && t.ColorMode == ColorMode256 { | |
| 308 | + | if !t.Terminal.TrueColor { | |
| 316 | 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 | 315 | func sgrFGColor(color TerminalColor) string { | |
| 323 | 316 | switch color.Mode { | |
| 324 | 317 | case terminalColorANSI: | |
| 325 | - | return "\x1b[" + itoa(color.Index) + "m" | |
| 318 | + | return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m" | |
| 326 | 319 | case terminalColorRGB: | |
| 327 | 320 | return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m" | |
| 328 | 321 | default: | |
| @@ -338,8 +331,8 @@ func (t Theme) sgrBGColor(color TerminalColor) string { | |||
| 338 | 331 | // glyph followed by a plain space gutter. Used as the speaker-label | |
| 339 | 332 | // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so | |
| 340 | 333 | // 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, "▌ ") | |
| 343 | 336 | } | |
| 344 | 337 | ||
| 345 | 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 | 345 | // SelectionStyle returns the SGR prefix for the theme's selected row. | |
| 353 | 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 | 350 | // BackgroundStyle returns the SGR prefix for the optional full-row | |
| @@ -367,8 +360,8 @@ func (t Theme) BackgroundStyle() string { | |||
| 367 | 360 | // SelectionStyleFG returns the SGR prefix for selected-row text with | |
| 368 | 361 | // a custom foreground. Useful for preserving semantic marks inside a | |
| 369 | 362 | // 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) | |
| 372 | 365 | } | |
| 373 | 366 | ||
| 374 | 367 | // PadHighlight styles s and extends the selection background to the | |
| @@ -404,7 +397,7 @@ func (t Theme) UserBubbleRow(content string, width int) string { | |||
| 404 | 397 | // Bar plus a single space gutter, in the assistant accent colour | |
| 405 | 398 | // so it matches the tool-box / app accent and reads as zot's voice | |
| 406 | 399 | // marker. Two cells wide. | |
| 407 | - | bar := t.FG256(t.Assistant, "▌ ") | |
| 400 | + | bar := t.FGColor(t.Assistant, "▌ ") | |
| 408 | 401 | bubbleW := width - 2 | |
| 409 | 402 | if bubbleW < 1 { | |
| 410 | 403 | bubbleW = 1 | |
| @@ -428,7 +421,7 @@ func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" | |||
| 428 | 421 | func sgrBGColor(c TerminalColor) string { | |
| 429 | 422 | switch c.Mode { | |
| 430 | 423 | case terminalColorANSI: | |
| 431 | - | return "\x1b[" + itoa(c.Index) + "m" | |
| 424 | + | return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m" | |
| 432 | 425 | case terminalColorRGB: | |
| 433 | 426 | return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m" | |
| 434 | 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 | 515 | func blendChannel(from, to, percent int) int { | |
| 501 | 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 | 527 | func nearestXtermColor(r, g, b int) int { | |
| 505 | 528 | r = clampByte(r) | |
| 506 | 529 | g = clampByte(g) | |
| 507 | 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 | 539 | best := 0 | |
| 509 | 540 | bestDistance := int(^uint(0) >> 1) | |
| 510 | 541 | for index := 0; index < 256; index++ { | |
| @@ -518,6 +549,7 @@ func nearestXtermColor(r, g, b int) int { | |||
| 518 | 549 | bestDistance = distance | |
| 519 | 550 | } | |
| 520 | 551 | } | |
| 552 | + | entry.value.Store(keyPrefix | uint64(best)) | |
| 521 | 553 | return best | |
| 522 | 554 | } | |
| 523 | 555 | ||
| @@ -3,14 +3,14 @@ | |||||
| 3 | // framework; just ANSI escape codes. | 3 | // framework; just ANSI escape codes. | ||
| 4 | package tui | 4 | package tui | ||
| 5 | 5 | ||||
| 6 | - | import "strings" | 6 | + | import ( |
| 7 | - | 7 | + | "strings" | |
| 8 | - | // TerminalColor describes a terminal colour in one of the colour | 8 | + | "sync/atomic" |
| 9 | - | // spaces terminals commonly support. Most of zot's theme still uses | 9 | + | ) |
| 10 | - | // xterm-256 indexes, but user-bubble backgrounds can also use ANSI | 10 | + | |
| 11 | - | // theme slots (for example SGR 100 / bright-black background) so they | 11 | + | // TerminalColor describes a theme colour in one of the colour spaces |
| 12 | - | // match the user's terminal theme rather than the fixed 256-colour | 12 | + | // 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 terminalColorMode | 15 | Mode terminalColorMode | ||
| 16 | Index int | 16 | 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 the | 36 | // TerminalProfile is the best-effort color snapshot reported by the | ||
| 45 | // controlling terminal. The ANSI palette is optional; unknown slots are | 37 | // 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], true | 58 | return p.Palette[index], true | ||
| 67 | } | 59 | } | ||
| 68 | 60 | ||||
| 69 | - | // ANSI 256-color palette used by zot. Defined as numeric codes so we | 61 | + | // 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 int | 64 | + | FG TerminalColor |
| 73 | - | Muted int | 65 | + | Muted TerminalColor |
| 74 | - | Accent int | 66 | + | Accent TerminalColor |
| 75 | Background *TerminalColor // optional full-row TUI background; nil keeps terminal default | 67 | 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 |
| 77 | UserBubbleBG TerminalColor // background tint behind user message rows | 69 | UserBubbleBG TerminalColor // background tint behind user message rows | ||
| 78 | - | UserBubbleFG int // foreground colour for user message rows | 70 | + | UserBubbleFG TerminalColor // foreground colour for user message rows |
| 79 | - | Assistant int // label color for the zot role | 71 | + | Assistant TerminalColor // label color for the zot role |
| 80 | - | Tool int | 72 | + | Tool TerminalColor |
| 81 | - | ToolOut int | 73 | + | ToolOut TerminalColor |
| 82 | - | Error int | 74 | + | Error TerminalColor |
| 83 | - | Warning int | 75 | + | Warning TerminalColor |
| 84 | - | Spinner int // spinner + funny working line | 76 | + | Spinner TerminalColor // spinner + funny working line |
| 85 | - | ThinkingMax int // status color for the opt-in max reasoning level | 77 | + | ThinkingMax TerminalColor // status color for the opt-in max reasoning level |
| 86 | - | SelectionBG int // background for highlighted rows | 78 | + | SelectionBG TerminalColor // background for highlighted rows |
| 87 | - | SelectionFG int // foreground for highlighted rows | 79 | + | SelectionFG TerminalColor // foreground for highlighted rows |
| 88 | - | 80 | + | ||
| 89 | - | // Inherited selects terminal-owned colors and uses ColorMode to choose | 81 | + | // Inherited selects terminal-owned colors. Terminal.TrueColor controls |
| 90 | - | // truecolor or a best-effort xterm-256 fallback. Explicit zot themes keep | 82 | + | // 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 bool | 85 | Inherited bool | ||
| 93 | - | ColorMode ColorMode | |||
| 94 | Terminal TerminalProfile | 86 | Terminal TerminalProfile | ||
| 95 | 87 | ||||
| 96 | SpinnerFrames []string | 88 | 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 blue | 185 | + | 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), // #42454B | 187 | UserBubbleBG: ColorRGB(66, 69, 75), // #42454B | ||
| 196 | - | UserBubbleFG: 248, // slightly lighter grey for readability on #42454B | 188 | + | UserBubbleFG: Color256(248), // slightly lighter grey for readability on #42454B |
| 197 | - | Assistant: 117, // bright cyan — the zot label color | 189 | + | Assistant: Color256(117), // bright cyan — the zot label color |
| 198 | - | Tool: 114, // green | 190 | + | 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 purple | 194 | + | Spinner: Color256(183), // soft purple |
| 203 | - | ThinkingMax: 207, // vivid magenta | 195 | + | ThinkingMax: Color256(207), // vivid magenta |
| 204 | - | SelectionBG: 24, // deep blue background | 196 | + | SelectionBG: Color256(24), // deep blue background |
| 205 | - | SelectionFG: 231, // near-white foreground | 197 | + | 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 theme | 210 | UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme | ||
| 219 | - | UserBubbleFG: 240, // dark grey text, legible on the pale panel | 211 | + | UserBubbleFG: Color256(240), // dark grey text, legible on the pale panel |
| 220 | - | Assistant: 31, // deep cyan | 212 | + | 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, // purple | 217 | + | Spinner: Color256(91), // purple |
| 226 | - | ThinkingMax: 127, // deep magenta | 218 | + | ThinkingMax: Color256(127), // deep magenta |
| 227 | - | SelectionBG: 153, // light blue | 219 | + | SelectionBG: Color256(153), // light blue |
| 228 | - | SelectionFG: 232, // near-black | 220 | + | 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 the | 228 | + | // FGColor wraps s in a foreground color. The name is intentionally explicit |
| 237 | - | // historical ANSI 256-color sequence; inherited themes resolve terminal | 229 | + | // 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 + reset | 231 | 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 underlying | 235 | + | // 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 + reset | 238 | + | 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 RGB | 242 | + | // 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 + reset | 253 | 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 used | 264 | + | // DimColor fades a theme color toward the terminal background. It is used |
| 268 | - | // by inherited theme construction for muted surfaces and is also available to | 265 | + | // 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.Background | 270 | 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 palette | 290 | 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-label | 331 | // glyph followed by a plain space gutter. Used as the speaker-label | ||
| 339 | // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so | 332 | // 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-row | 350 | // 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 with | 360 | // SelectionStyleFG returns the SGR prefix for selected-row text with | ||
| 368 | // a custom foreground. Useful for preserving semantic marks inside a | 361 | // 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 the | 367 | // 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 colour | 397 | // 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 voice | 398 | // 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 - 2 | 401 | bubbleW := width - 2 | ||
| 409 | if bubbleW < 1 { | 402 | if bubbleW < 1 { | ||
| 410 | bubbleW = 1 | 403 | 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 := 0 | 539 | 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 = distance | 549 | bestDistance = distance | ||
| 519 | } | 550 | } | ||
| 520 | } | 551 | } | ||
| 552 | + | entry.value.Store(keyPrefix | uint64(best)) | |||
| 521 | return best | 553 | return best | ||
| 522 | } | 554 | } | ||
| 523 | 555 | ||||
M packages/tui/theme_inherited_test.go +47 -11
| @@ -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 | 101 | func inheritedTestTheme(trueColor bool) Theme { | |
| 74 | 102 | detected := Dark | |
| 75 | 103 | detected.Terminal = TerminalProfile{ | |
| @@ -88,13 +116,13 @@ func inheritedTestTheme(trueColor bool) Theme { | |||
| 88 | 116 | ||
| 89 | 117 | func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) { | |
| 90 | 118 | 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) | |
| 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 | 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 | 126 | t.Fatalf("foreground did not inherit the terminal default: %q", got) | |
| 99 | 127 | } | |
| 100 | 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 | 139 | func TestInheritedThemeFallsBackTo256Colors(t *testing.T) { | |
| 112 | 140 | 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) | |
| 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 | 145 | t.Fatalf("accent did not use 256-color fallback: %q", got) | |
| 118 | 146 | } | |
| 119 | 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) { | |
| 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 { | |
| 132 | 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") { | |
| 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) | |
| 136 | 172 | } | |
| 137 | 173 | } | |
| 138 | 174 | ||
| @@ -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 := Dark | 102 | 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 { | |||
| 79 | 79 | // override only the colors it cares about and inherit the built-in | |
| 80 | 80 | // dark/light defaults for everything else. | |
| 81 | 81 | 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"` | |
| 85 | 85 | Background *TerminalColorValue `json:"background,omitempty"` | |
| 86 | - | User *int `json:"user,omitempty"` | |
| 86 | + | User *TerminalColorValue `json:"user,omitempty"` | |
| 87 | 87 | 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"` | |
| 99 | 99 | SpinnerFrames []string `json:"spinner_frames,omitempty"` | |
| 100 | 100 | SpinnerMessages []string `json:"spinner_messages,omitempty"` | |
| 101 | 101 | SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"` | |
| @@ -187,51 +187,34 @@ func InheritedTheme(detected Theme) Theme { | |||
| 187 | 187 | } | |
| 188 | 188 | t := withTerminalProfile(base, detected) | |
| 189 | 189 | t.Inherited = true | |
| 190 | - | t.ColorMode = ColorMode256 | |
| 191 | - | if t.Terminal.TrueColor { | |
| 192 | - | t.ColorMode = ColorModeTrueColor | |
| 193 | - | } | |
| 194 | 190 | ||
| 195 | 191 | if t.Terminal.HasForeground { | |
| 196 | - | t.FG = terminalColorIndex(t.Terminal.Foreground) | |
| 192 | + | t.FG = t.Terminal.Foreground | |
| 197 | 193 | } | |
| 198 | 194 | ||
| 199 | 195 | accentFallback := t.Accent | |
| 200 | 196 | if IsLightTheme(t) { | |
| 201 | 197 | 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 | 201 | 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) | |
| 211 | 208 | ||
| 212 | 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 | 211 | t.ToolOut = t.Muted | |
| 215 | 212 | background := t.Terminal.Background | |
| 216 | 213 | foreground := t.Terminal.Foreground | |
| 217 | 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 | 218 | t.UserBubbleFG = t.FG | |
| 236 | 219 | t.SelectionFG = t.FG | |
| 237 | 220 | ||
| @@ -247,28 +230,35 @@ func withTerminalProfile(base, detected Theme) Theme { | |||
| 247 | 230 | 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 | 250 | for _, index := range preferred { | |
| 252 | 251 | 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) | |
| 254 | 257 | } | |
| 255 | 258 | } | |
| 256 | 259 | 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 | 262 | func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | |
| 273 | 263 | fromRGB, _ := rgbForTerminalColor(from) | |
| 274 | 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 | 273 | // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json | |
| 305 | 274 | // to detected. Empty/auto/default keeps the built-in detected theme. | |
| 306 | 275 | // 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 | 450 | func applyThemeOverrides(th Theme, o ThemeOverrides) Theme { | |
| 482 | 451 | if o.FG != nil { | |
| 483 | - | th.FG = *o.FG | |
| 452 | + | th.FG = o.FG.TerminalColor | |
| 484 | 453 | } | |
| 485 | 454 | if o.Muted != nil { | |
| 486 | - | th.Muted = *o.Muted | |
| 455 | + | th.Muted = o.Muted.TerminalColor | |
| 487 | 456 | } | |
| 488 | 457 | if o.Accent != nil { | |
| 489 | - | th.Accent = *o.Accent | |
| 458 | + | th.Accent = o.Accent.TerminalColor | |
| 490 | 459 | } | |
| 491 | 460 | if o.Background != nil { | |
| 492 | 461 | bg := o.Background.TerminalColor | |
| 493 | 462 | th.Background = &bg | |
| 494 | 463 | } | |
| 495 | 464 | if o.User != nil { | |
| 496 | - | th.User = *o.User | |
| 465 | + | th.User = o.User.TerminalColor | |
| 497 | 466 | } | |
| 498 | 467 | if o.UserBubbleBG != nil { | |
| 499 | 468 | th.UserBubbleBG = o.UserBubbleBG.TerminalColor | |
| 500 | 469 | } | |
| 501 | 470 | if o.UserBubbleFG != nil { | |
| 502 | - | th.UserBubbleFG = *o.UserBubbleFG | |
| 471 | + | th.UserBubbleFG = o.UserBubbleFG.TerminalColor | |
| 503 | 472 | } | |
| 504 | 473 | if o.Assistant != nil { | |
| 505 | - | th.Assistant = *o.Assistant | |
| 474 | + | th.Assistant = o.Assistant.TerminalColor | |
| 506 | 475 | } | |
| 507 | 476 | if o.Tool != nil { | |
| 508 | - | th.Tool = *o.Tool | |
| 477 | + | th.Tool = o.Tool.TerminalColor | |
| 509 | 478 | } | |
| 510 | 479 | if o.ToolOut != nil { | |
| 511 | - | th.ToolOut = *o.ToolOut | |
| 480 | + | th.ToolOut = o.ToolOut.TerminalColor | |
| 512 | 481 | } | |
| 513 | 482 | if o.Error != nil { | |
| 514 | - | th.Error = *o.Error | |
| 483 | + | th.Error = o.Error.TerminalColor | |
| 515 | 484 | } | |
| 516 | 485 | if o.Warning != nil { | |
| 517 | - | th.Warning = *o.Warning | |
| 486 | + | th.Warning = o.Warning.TerminalColor | |
| 518 | 487 | } | |
| 519 | 488 | if o.Spinner != nil { | |
| 520 | - | th.Spinner = *o.Spinner | |
| 489 | + | th.Spinner = o.Spinner.TerminalColor | |
| 521 | 490 | } | |
| 522 | 491 | if o.ThinkingMax != nil { | |
| 523 | - | th.ThinkingMax = *o.ThinkingMax | |
| 492 | + | th.ThinkingMax = o.ThinkingMax.TerminalColor | |
| 524 | 493 | } else if o.ThinkingMaxCamel != nil { | |
| 525 | - | th.ThinkingMax = *o.ThinkingMaxCamel | |
| 494 | + | th.ThinkingMax = o.ThinkingMaxCamel.TerminalColor | |
| 526 | 495 | } | |
| 527 | 496 | if o.SelectionBG != nil { | |
| 528 | - | th.SelectionBG = *o.SelectionBG | |
| 497 | + | th.SelectionBG = o.SelectionBG.TerminalColor | |
| 529 | 498 | } | |
| 530 | 499 | if o.SelectionFG != nil { | |
| 531 | - | th.SelectionFG = *o.SelectionFG | |
| 500 | + | th.SelectionFG = o.SelectionFG.TerminalColor | |
| 532 | 501 | } | |
| 533 | 502 | if len(o.SpinnerFrames) > 0 { | |
| 534 | 503 | 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-in | 79 | // 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 = true | 189 | 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.Accent | 195 | accentFallback := t.Accent | ||
| 200 | if IsLightTheme(t) { | 196 | if IsLightTheme(t) { | ||
| 201 | accentFallback = Light.Accent | 197 | 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.Accent | 201 | 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.Muted | 211 | t.ToolOut = t.Muted | ||
| 215 | background := t.Terminal.Background | 212 | background := t.Terminal.Background | ||
| 216 | foreground := t.Terminal.Foreground | 213 | 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.FG | 218 | t.UserBubbleFG = t.FG | ||
| 236 | t.SelectionFG = t.FG | 219 | t.SelectionFG = t.FG | ||
| 237 | 220 | ||||
| @@ -247,28 +230,35 @@ func withTerminalProfile(base, detected Theme) Theme { | |||||
| 247 | return base | 230 | 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 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) | |||
| 254 | } | 257 | } | ||
| 255 | } | 258 | } | ||
| 256 | return fallback | 259 | 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/*.json | 273 | // 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 without | 275 | // 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.FG | 452 | + | th.FG = o.FG.TerminalColor |
| 484 | } | 453 | } | ||
| 485 | if o.Muted != nil { | 454 | if o.Muted != nil { | ||
| 486 | - | th.Muted = *o.Muted | 455 | + | th.Muted = o.Muted.TerminalColor |
| 487 | } | 456 | } | ||
| 488 | if o.Accent != nil { | 457 | if o.Accent != nil { | ||
| 489 | - | th.Accent = *o.Accent | 458 | + | th.Accent = o.Accent.TerminalColor |
| 490 | } | 459 | } | ||
| 491 | if o.Background != nil { | 460 | if o.Background != nil { | ||
| 492 | bg := o.Background.TerminalColor | 461 | bg := o.Background.TerminalColor | ||
| 493 | th.Background = &bg | 462 | th.Background = &bg | ||
| 494 | } | 463 | } | ||
| 495 | if o.User != nil { | 464 | if o.User != nil { | ||
| 496 | - | th.User = *o.User | 465 | + | th.User = o.User.TerminalColor |
| 497 | } | 466 | } | ||
| 498 | if o.UserBubbleBG != nil { | 467 | if o.UserBubbleBG != nil { | ||
| 499 | th.UserBubbleBG = o.UserBubbleBG.TerminalColor | 468 | th.UserBubbleBG = o.UserBubbleBG.TerminalColor | ||
| 500 | } | 469 | } | ||
| 501 | if o.UserBubbleFG != nil { | 470 | if o.UserBubbleFG != nil { | ||
| 502 | - | th.UserBubbleFG = *o.UserBubbleFG | 471 | + | th.UserBubbleFG = o.UserBubbleFG.TerminalColor |
| 503 | } | 472 | } | ||
| 504 | if o.Assistant != nil { | 473 | if o.Assistant != nil { | ||
| 505 | - | th.Assistant = *o.Assistant | 474 | + | th.Assistant = o.Assistant.TerminalColor |
| 506 | } | 475 | } | ||
| 507 | if o.Tool != nil { | 476 | if o.Tool != nil { | ||
| 508 | - | th.Tool = *o.Tool | 477 | + | th.Tool = o.Tool.TerminalColor |
| 509 | } | 478 | } | ||
| 510 | if o.ToolOut != nil { | 479 | if o.ToolOut != nil { | ||
| 511 | - | th.ToolOut = *o.ToolOut | 480 | + | th.ToolOut = o.ToolOut.TerminalColor |
| 512 | } | 481 | } | ||
| 513 | if o.Error != nil { | 482 | if o.Error != nil { | ||
| 514 | - | th.Error = *o.Error | 483 | + | th.Error = o.Error.TerminalColor |
| 515 | } | 484 | } | ||
| 516 | if o.Warning != nil { | 485 | if o.Warning != nil { | ||
| 517 | - | th.Warning = *o.Warning | 486 | + | th.Warning = o.Warning.TerminalColor |
| 518 | } | 487 | } | ||
| 519 | if o.Spinner != nil { | 488 | if o.Spinner != nil { | ||
| 520 | - | th.Spinner = *o.Spinner | 489 | + | th.Spinner = o.Spinner.TerminalColor |
| 521 | } | 490 | } | ||
| 522 | if o.ThinkingMax != nil { | 491 | if o.ThinkingMax != nil { | ||
| 523 | - | th.ThinkingMax = *o.ThinkingMax | 492 | + | th.ThinkingMax = o.ThinkingMax.TerminalColor |
| 524 | } else if o.ThinkingMaxCamel != nil { | 493 | } else if o.ThinkingMaxCamel != nil { | ||
| 525 | - | th.ThinkingMax = *o.ThinkingMaxCamel | 494 | + | th.ThinkingMax = o.ThinkingMaxCamel.TerminalColor |
| 526 | } | 495 | } | ||
| 527 | if o.SelectionBG != nil { | 496 | if o.SelectionBG != nil { | ||
| 528 | - | th.SelectionBG = *o.SelectionBG | 497 | + | th.SelectionBG = o.SelectionBG.TerminalColor |
| 529 | } | 498 | } | ||
| 530 | if o.SelectionFG != nil { | 499 | if o.SelectionFG != nil { | ||
| 531 | - | th.SelectionFG = *o.SelectionFG | 500 | + | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | + | "strings" | |
| 6 | 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 | 12 | home := t.TempDir() | |
| 11 | 13 | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |
| 12 | 14 | t.Fatal(err) | |
| 13 | 15 | } | |
| 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 { | |
| 16 | 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 | 26 | th, name, err := LoadThemeFromHome(home, "partial", Dark) | |
| 20 | 27 | if err != nil { | |
| @@ -23,11 +30,11 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||
| 23 | 30 | if name != "partial" { | |
| 24 | 31 | t.Fatalf("name = %q, want partial", name) | |
| 25 | 32 | } | |
| 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) | |
| 28 | 35 | } | |
| 29 | 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 | 39 | if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 { | |
| 33 | 40 | t.Fatal("spinner defaults should be inherited") | |
| @@ -35,33 +42,19 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||
| 35 | 42 | } | |
| 36 | 43 | ||
| 37 | 44 | 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}}}`) | |
| 46 | 46 | ||
| 47 | 47 | th, _, err := LoadThemeFromHome(home, "thinking", Dark) | |
| 48 | 48 | if err != nil { | |
| 49 | 49 | t.Fatal(err) | |
| 50 | 50 | } | |
| 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) | |
| 53 | 53 | } | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 56 | 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}`) | |
| 65 | 58 | ||
| 66 | 59 | th, _, err := LoadThemeFromHome(home, "spinner", Dark) | |
| 67 | 60 | if err != nil { | |
| @@ -74,19 +67,12 @@ func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | |||
| 74 | 67 | t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS) | |
| 75 | 68 | } | |
| 76 | 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 | 74 | 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}}}`) | |
| 90 | 76 | ||
| 91 | 77 | th, _, err := LoadThemeFromHome(home, "darkonly", Light) | |
| 92 | 78 | if err != nil { | |
| @@ -102,31 +88,63 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | |||
| 102 | 88 | t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS) | |
| 103 | 89 | } | |
| 104 | 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 | 95 | 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"]}}`) | |
| 118 | 97 | ||
| 119 | 98 | th, _, err := LoadThemeFromHome(home, "shared", Light) | |
| 120 | 99 | if err != nil { | |
| 121 | 100 | t.Fatal(err) | |
| 122 | 101 | } | |
| 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) | |
| 125 | 104 | } | |
| 126 | 105 | if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" { | |
| 127 | 106 | t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages) | |
| 128 | 107 | } | |
| 129 | 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 | } | |
| @@ -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 { | |||
| 325 | 325 | if idx > 0 { | |
| 326 | 326 | 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 | 330 | return out | |
| 331 | 331 | } | |
| @@ -340,9 +340,9 @@ func (v *View) renderStartupResources(width int) []string { | |||
| 340 | 340 | if len(items) == 0 { | |
| 341 | 341 | 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 | 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 | 347 | out = append(out, "") | |
| 348 | 348 | } | |
| @@ -691,10 +691,10 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 691 | 691 | if innerWidth < 1 { | |
| 692 | 692 | innerWidth = 1 | |
| 693 | 693 | } | |
| 694 | + | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FGColor(v.Theme.Accent, "▌ ")) | |
| 694 | 695 | row := func(content string) string { | |
| 695 | 696 | inner := strings.Repeat(" ", leftGutter) + content | |
| 696 | 697 | padded := v.Theme.UserBubble(inner, width-2) | |
| 697 | - | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | |
| 698 | 698 | return bar + padded | |
| 699 | 699 | } | |
| 700 | 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 | 709 | row = func(content string) string { | |
| 710 | 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, "▌ ") + content | |
| 713 | + | return v.Theme.FGColor(v.Theme.Accent, "▌ ") + content | |
| 714 | 714 | } | |
| 715 | 715 | } | |
| 716 | 716 | var bubble []string | |
| @@ -796,7 +796,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 796 | 796 | } | |
| 797 | 797 | lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode)) | |
| 798 | 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 | 801 | for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) { | |
| 802 | 802 | _, stripped := parseImageFootprint(line) | |
| @@ -810,7 +810,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 810 | 810 | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |
| 811 | 811 | lines = append(lines, toolBoxSide(v.Theme, "", width)) | |
| 812 | 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 | 815 | for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) { | |
| 816 | 816 | // Image-footprint rows (the escape row, the blank | |
| @@ -978,7 +978,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 978 | 978 | // renderLiveToolResult uses the transcript's text renderer so every rich | |
| 979 | 979 | // confirmation preview immediately gets the same colors, numbered gutters, | |
| 980 | 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 | 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 | 1010 | // Header line hints which edit is streaming and, when more | |
| 1011 | 1011 | // than one has landed, how many the model is doing. | |
| 1012 | 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 | 1014 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | |
| 1015 | 1015 | return v.wrapLiveBody(body, width) | |
| 1016 | 1016 | case "bash", "Bash": | |
| @@ -1028,7 +1028,7 @@ func (v *View) renderLiveBashCommand(command string, width int) []string { | |||
| 1028 | 1028 | if v.FlatTools || v.CompactMode { | |
| 1029 | 1029 | inner = flatToolBodyRenderWidth(width) | |
| 1030 | 1030 | } | |
| 1031 | - | prompt := v.Theme.FG256(v.Theme.Muted, "$ ") | |
| 1031 | + | prompt := v.Theme.FGColor(v.Theme.Muted, "$ ") | |
| 1032 | 1032 | var out []string | |
| 1033 | 1033 | for i, line := range strings.Split(command, "\n") { | |
| 1034 | 1034 | firstPrefix := " " | |
| @@ -1042,7 +1042,7 @@ func (v *View) renderLiveBashCommand(command string, width int) []string { | |||
| 1042 | 1042 | if wrapWidth < 10 { | |
| 1043 | 1043 | 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 | 1046 | prefix := firstPrefix | |
| 1047 | 1047 | if j > 0 { | |
| 1048 | 1048 | prefix = contPrefix | |
| @@ -1133,7 +1133,7 @@ func toolBoxTop(th Theme, label string, width int) string { | |||
| 1133 | 1133 | } | |
| 1134 | 1134 | fillStr := strings.Repeat("─", fill) | |
| 1135 | 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+"┐") + margin | |
| 1136 | + | 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 | 1139 | func oneLineToolLabel(label string) string { | |
| @@ -1172,7 +1172,7 @@ func flatToolHeader(th Theme, label string, width int) string { | |||
| 1172 | 1172 | label = oneLineToolLabel(label) | |
| 1173 | 1173 | name, rest := splitToolLabel(label) | |
| 1174 | 1174 | margin := strings.Repeat(" ", toolBoxOuterMargin) | |
| 1175 | - | gutter := th.FG256(th.Assistant, "▌") + " " | |
| 1175 | + | gutter := th.FGColor(th.Assistant, "▌") + " " | |
| 1176 | 1176 | // Budget the visible text to the content width so a very long | |
| 1177 | 1177 | // argument summary doesn't run off the right edge; truncate with | |
| 1178 | 1178 | // an ellipsis like the box header does. | |
| @@ -1189,7 +1189,7 @@ func flatToolHeader(th Theme, label string, width int) string { | |||
| 1189 | 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 | 1195 | func compactToolBlank(th Theme, width int) string { | |
| @@ -1266,7 +1266,7 @@ func toolBoxBottom(th Theme, width int) string { | |||
| 1266 | 1266 | } | |
| 1267 | 1267 | margin := strings.Repeat(" ", toolBoxOuterMargin) | |
| 1268 | 1268 | line := "└" + strings.Repeat("─", w-2) + "┘" | |
| 1269 | - | return margin + th.FG256(th.Muted, line) + margin | |
| 1269 | + | return margin + th.FGColor(th.Muted, line) + margin | |
| 1270 | 1270 | } | |
| 1271 | 1271 | ||
| 1272 | 1272 | // hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or | |
| @@ -1368,8 +1368,9 @@ func toolBoxSide(th Theme, line string, width int) string { | |||
| 1368 | 1368 | w = 12 | |
| 1369 | 1369 | } | |
| 1370 | 1370 | 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 | |
| 1373 | 1374 | inner := w - 2 - 2*toolBoxInnerPad // available between the two pads | |
| 1374 | 1375 | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | |
| 1375 | 1376 | ||
| @@ -1404,8 +1405,9 @@ func toolBoxSideWithImage(th Theme, line string, imgCells, width int) string { | |||
| 1404 | 1405 | w = 12 | |
| 1405 | 1406 | } | |
| 1406 | 1407 | 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 | |
| 1409 | 1411 | inner := w - 2 - 2*toolBoxInnerPad | |
| 1410 | 1412 | ||
| 1411 | 1413 | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | |
| @@ -1467,7 +1469,7 @@ func trimLeadingSpaces(s string, n int) string { | |||
| 1467 | 1469 | // like a unified diff gets +/- coloring. Image blocks are rendered | |
| 1468 | 1470 | // inline when the terminal supports a protocol, else as a text | |
| 1469 | 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 | 1473 | var body []string | |
| 1472 | 1474 | hasImage := false | |
| 1473 | 1475 | for _, b := range blocks { | |
| @@ -1501,7 +1503,7 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | |||
| 1501 | 1503 | hidden := len(lines) - ToolCollapsePreview | |
| 1502 | 1504 | total := len(lines) | |
| 1503 | 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 | 1507 | out := append([]string(nil), kept...) | |
| 1506 | 1508 | out = append(out, "") | |
| 1507 | 1509 | return append(out, footer) | |
| @@ -1511,7 +1513,7 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | |||
| 1511 | 1513 | // text contains a unified-diff section (lines starting with "--- " / | |
| 1512 | 1514 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | |
| 1513 | 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 | 1517 | // Legacy path: transcripts saved before we dropped line numbers | |
| 1516 | 1518 | // from the read tool still carry " 1\t..." prefixes. Detect and | |
| 1517 | 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 | 1560 | // Detect diff header: "--- name" followed somewhere by "+++ name". | |
| 1559 | 1561 | if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") { | |
| 1560 | 1562 | 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 | 1564 | continue | |
| 1563 | 1565 | } | |
| 1564 | 1566 | // 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 | 1569 | if o, n, ok := parseHunkHeader(l); ok { | |
| 1568 | 1570 | 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 | 1573 | continue | |
| 1572 | 1574 | } | |
| 1573 | 1575 | if inDiff && strings.TrimSpace(l) == "..." { | |
| 1574 | 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 | 1578 | continue | |
| 1577 | 1579 | } | |
| 1578 | 1580 | if inDiff && len(l) > 0 { | |
| @@ -1594,7 +1596,7 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s | |||
| 1594 | 1596 | } | |
| 1595 | 1597 | // Regular line. | |
| 1596 | 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 | 1602 | return out | |
| @@ -1647,7 +1649,7 @@ func parseHunkHeader(l string) (oldStart, newStart int, ok bool) { | |||
| 1647 | 1649 | // rows use a blank gutter so wrapped content cannot be mistaken for another | |
| 1648 | 1650 | // source line. Code is syntax-highlighted if sourcePath hints at a known | |
| 1649 | 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 | 1653 | if len(line) == 0 { | |
| 1652 | 1654 | return []string{""} | |
| 1653 | 1655 | } | |
| @@ -1695,9 +1697,9 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt | |||
| 1695 | 1697 | if mark == ' ' { | |
| 1696 | 1698 | 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 | 1704 | return out | |
| 1703 | 1705 | } | |
| @@ -1803,11 +1805,11 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||
| 1803 | 1805 | out = append(out, imageFootprintSentinel) | |
| 1804 | 1806 | } | |
| 1805 | 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 | 1809 | 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 | 1815 | // looksLikeNumberedFile returns true when text matches the `read` | |
| @@ -1875,17 +1877,17 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||
| 1875 | 1877 | // code is visually distinct from the muted gutter. | |
| 1876 | 1878 | highlighted = make([]string, len(codes)) | |
| 1877 | 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 | 1883 | out := make([]string, 0, len(codes)) | |
| 1882 | 1884 | for i, code := range highlighted { | |
| 1883 | 1885 | g := gutters[i] | |
| 1884 | 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 | 1888 | 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 | 1892 | return out | |
| 1891 | 1893 | } | |
| @@ -1901,7 +1903,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||
| 1901 | 1903 | // line in the accent color, the trailing "[exit N] Took X.Ys" line | |
| 1902 | 1904 | // in muted type, everything else on the default tool-output color. | |
| 1903 | 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 | 1907 | lines = normalizeBashOutputLines(lines) | |
| 1906 | 1908 | // Identify the footer line (exit + timing). The bash tool writes | |
| 1907 | 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 | 1927 | // further processing / wrapping so the shell-style | |
| 1926 | 1928 | // prompt reads at a glance. | |
| 1927 | 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 | 1932 | case i == footerIdx: | |
| 1931 | 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 | 1936 | default: | |
| 1935 | 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 | 2097 | continue | |
| 2096 | 2098 | } | |
| 2097 | 2099 | if l == "..." { | |
| 2098 | - | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "...")) | |
| 2100 | + | out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, "...")) | |
| 2099 | 2101 | continue | |
| 2100 | 2102 | } | |
| 2101 | 2103 | switch l[0] { | |
| @@ -2111,7 +2113,7 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st | |||
| 2111 | 2113 | newLine++ | |
| 2112 | 2114 | default: | |
| 2113 | 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 | 2176 | } else { | |
| 2175 | 2177 | highlighted = make([]string, len(code)) | |
| 2176 | 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 | 2183 | out := make([]string, 0, len(lines)) | |
| 2182 | 2184 | for i, c := range highlighted { | |
| 2183 | 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 | 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 | 2191 | return out | |
| 2190 | 2192 | } | |
| @@ -2325,7 +2327,7 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string { | |||
| 2325 | 2327 | ||
| 2326 | 2328 | if v.ExpandAll { | |
| 2327 | 2329 | 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 | 2331 | lines = append(lines, indent+header) | |
| 2330 | 2332 | lines = append(lines, "") | |
| 2331 | 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 | 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 | 2353 | return []string{indent + line} | |
| 2352 | 2354 | } | |
| 2353 | 2355 | ||
| @@ -2445,7 +2447,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2445 | 2447 | if p.AutoCompacting { | |
| 2446 | 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 | 2453 | // Layout uses exactly 2 spaces of horizontal padding everywhere: | |
| @@ -2495,11 +2497,11 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2495 | 2497 | // prefix there's no trailing separator to double-pad. | |
| 2496 | 2498 | leftBuilder.WriteString(pad) | |
| 2497 | 2499 | } | |
| 2498 | - | leftBuilder.WriteString(th.FG256(th.Muted, left)) | |
| 2500 | + | leftBuilder.WriteString(th.FGColor(th.Muted, left)) | |
| 2499 | 2501 | if middle != "" { | |
| 2500 | 2502 | leftBuilder.WriteString(pad) | |
| 2501 | 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 | 2507 | cwd := shortenHome(p.CWD) | |
| @@ -2525,21 +2527,21 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2525 | 2527 | // stats on their own rows. This mirrors the idle split below. | |
| 2526 | 2528 | if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols { | |
| 2527 | 2529 | busyLine := pad + p.BusyPrefix | |
| 2528 | - | modelLine := pad + th.FG256(th.Muted, left) | |
| 2530 | + | modelLine := pad + th.FGColor(th.Muted, left) | |
| 2529 | 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 | 2533 | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols) | |
| 2532 | 2534 | } else { | |
| 2533 | 2535 | var infoBuilder strings.Builder | |
| 2534 | 2536 | infoBuilder.WriteString(modelLine) | |
| 2535 | 2537 | if middle != "" { | |
| 2536 | 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 | 2541 | lines = append(lines, infoBuilder.String()) | |
| 2540 | 2542 | } | |
| 2541 | 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 | 2546 | return lines | |
| 2545 | 2547 | } | |
| @@ -2552,7 +2554,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2552 | 2554 | var lines []string | |
| 2553 | 2555 | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols) | |
| 2554 | 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 | 2559 | return lines | |
| 2558 | 2560 | } | |
| @@ -2563,12 +2565,12 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2563 | 2565 | ||
| 2564 | 2566 | // Second line: indent with the same 2-space pad so the cwd lines | |
| 2565 | 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 | 2569 | return []string{primary, cwdRendered} | |
| 2568 | 2570 | } | |
| 2569 | 2571 | ||
| 2570 | 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 | 2574 | infoParts := make([]string, 0, 2) | |
| 2573 | 2575 | if fastText != "" { | |
| 2574 | 2576 | infoParts = append(infoParts, fastText) | |
| @@ -2580,7 +2582,7 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText | |||
| 2580 | 2582 | if infoText == "" { | |
| 2581 | 2583 | lines = append(lines, modelLine) | |
| 2582 | 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 | 2587 | return lines | |
| 2586 | 2588 | } | |
| @@ -2591,23 +2593,23 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText | |||
| 2591 | 2593 | infoColor = reasoningStatusColor(th, reasoningText) | |
| 2592 | 2594 | } | |
| 2593 | 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 | 2597 | } else { | |
| 2596 | 2598 | lines = append(lines, modelLine) | |
| 2597 | 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 | 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 | 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 | 2609 | return lines | |
| 2608 | 2610 | } | |
| 2609 | 2611 | ||
| 2610 | - | func reasoningStatusColor(th Theme, reasoningText string) int { | |
| 2612 | + | func reasoningStatusColor(th Theme, reasoningText string) TerminalColor { | |
| 2611 | 2613 | if strings.HasSuffix(reasoningText, ": max") { | |
| 2612 | 2614 | return th.ThinkingMax | |
| 2613 | 2615 | } | |
| @@ -2631,7 +2633,7 @@ func reasoningLevelLabel(level string) string { | |||
| 2631 | 2633 | ||
| 2632 | 2634 | // contextUsage renders the "N%/ctxMax" fragment, returning the | |
| 2633 | 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 | 2637 | if max <= 0 { | |
| 2636 | 2638 | if used <= 0 { | |
| 2637 | 2639 | return "", th.Muted | |
| @@ -325,7 +325,7 @@ func (v *View) renderErr(width int) []string { | |||||
| 325 | if idx > 0 { | 325 | if idx > 0 { | ||
| 326 | prefix = indent | 326 | 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 out | 330 | 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 | return | 341 | 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 = 1 | 692 | 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) + content | 696 | 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 + padded | 698 | 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, "▌ ") + content | 713 | + | return v.Theme.FGColor(v.Theme.Accent, "▌ ") + content |
| 714 | } | 714 | } | ||
| 715 | } | 715 | } | ||
| 716 | var bubble []string | 716 | 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 blank | 816 | // 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 rich | 978 | // 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 more | 1010 | // 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 []string | 1032 | 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 = 10 | 1043 | 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 := firstPrefix | 1046 | prefix := firstPrefix | ||
| 1047 | if j > 0 { | 1047 | if j > 0 { | ||
| 1048 | prefix = contPrefix | 1048 | 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+"┐") + margin | 1136 | + | 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 long | 1176 | // Budget the visible text to the content width so a very long | ||
| 1177 | // argument summary doesn't run off the right edge; truncate with | 1177 | // 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) + margin | 1269 | + | return margin + th.FGColor(th.Muted, line) + margin |
| 1270 | } | 1270 | } | ||
| 1271 | 1271 | ||||
| 1272 | // hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or | 1272 | // 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 = 12 | 1368 | 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 pads | 1374 | 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 = 12 | 1405 | 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*toolBoxInnerPad | 1411 | 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 rendered | 1469 | // like a unified diff gets +/- coloring. Image blocks are rendered | ||
| 1468 | // inline when the terminal supports a protocol, else as a text | 1470 | // 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 []string | 1473 | var body []string | ||
| 1472 | hasImage := false | 1474 | 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) - ToolCollapsePreview | 1503 | 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/remove | 1514 | // "+++ " / "+" / "-"/" "), 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 numbers | 1517 | // Legacy path: transcripts saved before we dropped line numbers | ||
| 1516 | // from the read tool still carry " 1\t..." prefixes. Detect and | 1518 | // 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 = true | 1562 | 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 | continue | 1564 | continue | ||
| 1563 | } | 1565 | } | ||
| 1564 | // Hunk header "@@ -a,b +c,d @@" resets the counters so patches | 1566 | // 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, n | 1570 | 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 | continue | 1573 | 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 | continue | 1578 | 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 out | 1602 | 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 another | 1649 | // 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 known | 1650 | // 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.Muted | 1698 | 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 out | 1704 | 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 out | 1809 | 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 | continue | 1888 | 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 out | 1892 | 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" line | 1903 | // 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 writes | 1908 | // 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-style | 1927 | // 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 | continue | 2097 | 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 | continue | 2101 | 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 out | 2191 | 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 []string | 2329 | 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.BusyPrefix | 2529 | 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.Builder | 2535 | 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 lines | 2546 | return lines | ||
| 2545 | } | 2547 | } | ||
| @@ -2552,7 +2554,7 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 2552 | var lines []string | 2554 | 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 lines | 2559 | 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 lines | 2566 | // 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 lines | 2587 | 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 lines | 2609 | 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.ThinkingMax | 2614 | return th.ThinkingMax | ||
| 2613 | } | 2615 | } | ||
| @@ -2631,7 +2633,7 @@ func reasoningLevelLabel(level string) string { | |||||
| 2631 | 2633 | ||||
| 2632 | // contextUsage renders the "N%/ctxMax" fragment, returning the | 2634 | // 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.Muted | 2639 | return "", th.Muted | ||
M packages/tui/view_tool_box_test.go +1 -1
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | func TestToolBoxSideTruncationPreservesANSIColor(t *testing.T) { | |
| 9 | 9 | const width = 32 | |
| 10 | 10 | 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 | 13 | got := toolBoxSide(Dark, line, width) | |
| 14 | 14 | 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 = 32 | 9 | const width = 32 | ||
| 10 | const contentColor = 196 | 10 | 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) { | |||
| 126 | 126 | } | |
| 127 | 127 | } | |
| 128 | 128 | 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 "), | |
| 131 | 131 | } { | |
| 132 | 132 | if !strings.Contains(rendered, want) { | |
| 133 | 133 | 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) | ||