Skip to content

Commit 366dff7

Browse files
author
Yogthos
committed
ui: phosphor theme (80s CRT green)
Adds a centralized color theme with two presets and a default-on phosphor-green palette. Errors stay red and warnings stay yellow across every preset — the semantic urgency channel doesn't get traded for aesthetics. ## New module `src/ui/theme.rs` — `Theme` struct with semantic roles (agent, user, system, tool, perm, result, error, warn, accent, dim, header, divider, banner_*) and two presets: - `phosphor` (default) — green accents on black for the assistant chat, dim green for results/tool output, bright green for headers/ banner. Permission prompts use yellow so they stand loud. - `plain` — the pre-theme white assistant / cyan accent look. Set `"theme": "plain"` in config.json if green-on-black doesn't suit your terminal background. The active theme is initialized once at boot from `cfg.theme` and read everywhere via helper fns (`theme::agent()`, `theme::error()`, …) so future palette swaps don't ripple across call sites. ## Welcome banner `render_session` now opens with a 4-line CRT-style box-drawn banner: ╔══════════════════════════════════════════╗ ║ ░ DIRGE ░ PHOSPHOR ░ v1.0.0 ║ ║ provider: openai · model: gpt-4 ║ ╚══════════════════════════════════════════╝ Truncates gracefully on narrow terminals (clamps inner width to `terminal_width - 4`). ## Call-site migration The 213 references to `C_AGENT` / `C_ERROR` / `C_TOOL` / `C_PERM` / `C_RESULT` constants in `src/ui/mod.rs` + `src/ui/slash.rs` are now `c_agent()` / etc. fn calls that delegate to the theme module. Spelling stayed similar so the diff is mechanical search-replace plus tiny shims at the top of each file. `renderer.rs` panel + status + input-prompt color literals (`Color::Cyan`, `Color::DarkGrey`, `Color::DarkYellow`) replaced with theme accessors (`accent()`, `dim()`, `warn()`). Picker (file picker + list picker) got the same treatment. `events.rs::render_session` now uses themed colors for user `>` / assistant `<` / system `#` prefixes (`theme::user/agent/system`). The leading `░` glyph on AGENTS.md/compaction status lines gives the banner aesthetic a little continuity into the chat scrollback. ## Config New `theme: Option<String>` field on `Config`. Documented in CONFIG.md + a short note in README.md. CLI flag intentionally omitted — palette is a long-lived per-user preference, not a per-invocation choice. ## Tests 3 new in `ui::theme::tests`: - `presets_are_distinct` - `error_and_warn_stay_loud` (locks in the red/yellow contract for every shipping preset) - `init_with_unknown_name_falls_back` Totals: - [x] `cargo test --features plugin` -> 599 pass, 0 fail (was 596 + 3). - [x] `cargo build --features plugin` -> 0 warnings. - [x] `cargo build` -> 0 warnings. - [x] `./target/debug/dirge --version` works.
1 parent 048d12b commit 366dff7

10 files changed

Lines changed: 608 additions & 256 deletions

File tree

CONFIG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ Accepted top-level keys:
8181
| `show_edit_diff` | boolean | Show colorized diff output for `edit` tool results (`-` red, `+` green, `@@` cyan). Default: `true`. |
8282
| `tool_result_max_chars` | integer | Maximum characters to show before truncating tool output with `[N more chars]`. Default: `500`. |
8383
| `default_prompt` | string | Prompt name to activate on startup. Default: `code`. |
84+
| `theme` | string | UI color theme. `phosphor` (default — 80s CRT green-on-black) or `plain` (pre-theme white/cyan). Unknown values fall back to `phosphor` with a warning. |
8485
| `mcp_servers` | object | MCP server map when compiled with the `mcp` feature. When omitted, defaults to a single Exa Web Search server; see below. |
8586
| `acp_servers` | object | ACP server config map when compiled with the `acp` feature. See the ACP section below. |
8687
| `acp_host` | string | TCP bind host for ACP server mode (equivalent to `--acp-host`). |

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,16 @@ Session allowlists persist approvals for the session. Doom-loop detection trigge
301301

302302
See [CONFIG.md](CONFIG.md) for config file location, accepted keys, provider aliases, permission rules, and MCP server configuration.
303303

304+
### UI theme
305+
306+
dirge ships with an 80s-CRT phosphor green palette by default. To opt out, set `"theme": "plain"` in `config.json` for the pre-theme white/cyan look:
307+
308+
```json
309+
{ "theme": "plain" }
310+
```
311+
312+
Errors stay red and warnings stay yellow under every theme — those colors are part of the load-bearing semantic contract.
313+
304314
## Supported providers
305315

306316
- OpenRouter (default)

src/config/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ pub struct Config {
104104
pub show_edit_diff: Option<bool>,
105105
pub tool_result_max_chars: Option<usize>,
106106
pub default_prompt: Option<String>,
107+
/// UI color theme. Known values: `phosphor` (default, 80s CRT
108+
/// green) and `plain` (the pre-theme white/cyan look). Unknown
109+
/// values fall back to `phosphor` with a warning.
110+
pub theme: Option<String>,
107111
pub tools: Option<ToolsConfig>,
108112
#[cfg(feature = "lsp")]
109113
pub lsp: Option<LspConfig>,

src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ async fn main() -> anyhow::Result<()> {
194194

195195
let cli = cli::Cli::parse();
196196
let cfg = config::load();
197+
// Initialize the global UI theme before any rendering happens. The
198+
// theme is global state; setting it once at boot keeps every
199+
// render site from having to thread it explicitly.
200+
ui::theme::init(cfg.theme.as_deref().unwrap_or("phosphor"));
197201
let mut context = context::load(cli.resolve_no_context_files(&cfg));
198202

199203
let default_prompt = cfg.default_prompt.as_deref().unwrap_or("code");

src/ui/events.rs

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::context::ContextFiles;
88
use crate::session::{MessageRole, Session};
99
use crate::ui::markdown;
1010
use crate::ui::renderer::Renderer;
11+
use crate::ui::theme;
1112

1213
pub fn format_time(rfc3339: &str) -> CompactString {
1314
let dt = chrono::DateTime::parse_from_rfc3339(rfc3339).ok();
@@ -40,38 +41,31 @@ pub fn render_session(
4041
} else {
4142
cli.resolve_model(cfg)
4243
};
43-
let welcome = format!(
44-
"dirge {} {} {}",
45-
provider,
46-
model,
47-
env!("CARGO_PKG_VERSION")
48-
);
49-
renderer.write_line(&welcome, Color::Cyan)?;
50-
renderer.write_line("", Color::White)?;
44+
render_banner(renderer, &provider, &model)?;
5145
if context.agents.is_some() {
52-
renderer.write_line("loaded AGENTS.md", Color::DarkGrey)?;
53-
renderer.write_line("", Color::White)?;
46+
renderer.write_line("loaded AGENTS.md", theme::dim())?;
47+
renderer.write_line("", Color::Reset)?;
5448
}
5549
if !session.compactions.is_empty() {
5650
renderer.write_line(
5751
&format!(
58-
"compacted {} times (saved ~{} tokens)",
52+
"compacted {} times (saved ~{} tokens)",
5953
session.compactions.len(),
6054
session
6155
.compactions
6256
.last()
6357
.map(|c| c.token_savings)
6458
.unwrap_or(0),
6559
),
66-
Color::DarkGrey,
60+
theme::dim(),
6761
)?;
68-
renderer.write_line("", Color::White)?;
62+
renderer.write_line("", Color::Reset)?;
6963
}
7064
for msg in &session.messages {
71-
let (prefix, _c) = match msg.role {
72-
MessageRole::User => (">", Color::Green),
73-
MessageRole::Assistant => ("<", Color::White),
74-
MessageRole::System => ("#", Color::DarkGrey),
65+
let (prefix, line_color) = match msg.role {
66+
MessageRole::User => (">", theme::user()),
67+
MessageRole::Assistant => ("<", theme::agent()),
68+
MessageRole::System => ("#", theme::system()),
7569
};
7670
if msg.role == MessageRole::Assistant {
7771
let max_width = renderer.line_width();
@@ -84,14 +78,63 @@ pub fn render_session(
8478
}
8579
} else {
8680
for line in msg.content.lines() {
87-
renderer.write_line(&format!("{} {}", prefix, line), _c)?;
81+
renderer.write_line(&format!("{} {}", prefix, line), line_color)?;
8882
}
8983
}
90-
renderer.write_line("", Color::White)?;
84+
renderer.write_line("", Color::Reset)?;
9185
}
9286
Ok(())
9387
}
9488

89+
/// 80s-CRT welcome banner. Four lines: top border, wordmark + theme +
90+
/// version, provider/model summary, bottom border. Width clamps to the
91+
/// terminal so narrow windows don't see overrunning box-drawing chars.
92+
fn render_banner(renderer: &mut Renderer, provider: &str, model: &str) -> anyhow::Result<()> {
93+
let label = theme::current().label;
94+
let version = env!("CARGO_PKG_VERSION");
95+
let title = format!("░ DIRGE ░ {} ░ v{}", label, version);
96+
let subtitle = format!("provider: {} · model: {}", provider, model);
97+
// Inner width = max content length + 2 padding on each side.
98+
// Clamp to terminal width − 4 (margin) so we don't push the banner
99+
// past the visible region.
100+
let term_w = renderer.line_width().max(20);
101+
let max_inner = term_w.saturating_sub(4);
102+
let inner = title
103+
.chars()
104+
.count()
105+
.max(subtitle.chars().count())
106+
.min(max_inner);
107+
let inner_width = inner + 2; // single-space padding each side
108+
109+
let border_top = format!("╔{}╗", "═".repeat(inner_width));
110+
let border_bot = format!("╚{}╝", "═".repeat(inner_width));
111+
let title_line = format!("║ {:width$} ║", truncate(&title, inner), width = inner);
112+
let sub_line = format!("║ {:width$} ║", truncate(&subtitle, inner), width = inner);
113+
114+
renderer.write_line(&border_top, theme::banner_secondary())?;
115+
renderer.write_line(&title_line, theme::banner_primary())?;
116+
renderer.write_line(&sub_line, theme::banner_secondary())?;
117+
renderer.write_line(&border_bot, theme::banner_secondary())?;
118+
renderer.write_line("", Color::Reset)?;
119+
Ok(())
120+
}
121+
122+
/// Truncate a string to `max` *characters* (not bytes), adding an
123+
/// ellipsis when shortened. Used by the banner so wordmarks stay
124+
/// inside the box drawing.
125+
fn truncate(s: &str, max: usize) -> String {
126+
let count = s.chars().count();
127+
if count <= max {
128+
s.to_string()
129+
} else if max <= 1 {
130+
s.chars().take(max).collect()
131+
} else {
132+
let mut out: String = s.chars().take(max - 1).collect();
133+
out.push('…');
134+
out
135+
}
136+
}
137+
95138
pub fn sanitize_output(text: &str) -> CompactString {
96139
let mut result = String::with_capacity(text.len());
97140
let mut chars = text.chars();

0 commit comments

Comments
 (0)