Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Accepted top-level keys:
| `show_edit_diff` | boolean | Show colorized diff output for `edit` tool results (`-` red, `+` green, `@@` cyan). Default: `true`. |
| `tool_result_max_chars` | integer | Maximum characters to show before truncating tool output with `[N more chars]`. Default: `500`. |
| `default_prompt` | string | Prompt name to activate on startup. Default: `code`. |
| `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. |
| `mcp_servers` | object | MCP server map when compiled with the `mcp` feature. When omitted, defaults to a single Exa Web Search server; see below. |
| `acp_servers` | object | ACP server config map when compiled with the `acp` feature. See the ACP section below. |
| `acp_host` | string | TCP bind host for ACP server mode (equivalent to `--acp-host`). |
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,16 @@ Session allowlists persist approvals for the session. Doom-loop detection trigge

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

### UI theme

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:

```json
{ "theme": "plain" }
```

Errors stay red and warnings stay yellow under every theme — those colors are part of the load-bearing semantic contract.

## Supported providers

- OpenRouter (default)
Expand Down
4 changes: 4 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ pub struct Config {
pub show_edit_diff: Option<bool>,
pub tool_result_max_chars: Option<usize>,
pub default_prompt: Option<String>,
/// UI color theme. Known values: `phosphor` (default, 80s CRT
/// green) and `plain` (the pre-theme white/cyan look). Unknown
/// values fall back to `phosphor` with a warning.
pub theme: Option<String>,
pub tools: Option<ToolsConfig>,
#[cfg(feature = "lsp")]
pub lsp: Option<LspConfig>,
Expand Down
4 changes: 4 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ async fn main() -> anyhow::Result<()> {

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

let default_prompt = cfg.default_prompt.as_deref().unwrap_or("code");
Expand Down
216 changes: 190 additions & 26 deletions src/ui/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::context::ContextFiles;
use crate::session::{MessageRole, Session};
use crate::ui::markdown;
use crate::ui::renderer::Renderer;
use crate::ui::theme;

pub fn format_time(rfc3339: &str) -> CompactString {
let dt = chrono::DateTime::parse_from_rfc3339(rfc3339).ok();
Expand Down Expand Up @@ -40,61 +41,181 @@ pub fn render_session(
} else {
cli.resolve_model(cfg)
};
let welcome = format!(
"dirge {} {} {}",
provider,
model,
env!("CARGO_PKG_VERSION")
);
renderer.write_line(&welcome, Color::Cyan)?;
renderer.write_line("", Color::White)?;
// Top padding rows. Without this, when the user scrolls all the
// way up, the banner's top border `╭───╮` sits pressed against
// the terminal's top edge, which reads as "cut off." Two blank
// rows give the eye breathing room above the banner.
renderer.write_line("", Color::Reset)?;
renderer.write_line("", Color::Reset)?;
render_banner(renderer, &provider, &model)?;
if context.agents.is_some() {
renderer.write_line("loaded AGENTS.md", Color::DarkGrey)?;
renderer.write_line("", Color::White)?;
renderer.write_line("loaded AGENTS.md", theme::dim())?;
renderer.write_line("", Color::Reset)?;
}
if !session.compactions.is_empty() {
renderer.write_line(
&format!(
"compacted {} times (saved ~{} tokens)",
"compacted {} times (saved ~{} tokens)",
session.compactions.len(),
session
.compactions
.last()
.map(|c| c.token_savings)
.unwrap_or(0),
),
Color::DarkGrey,
theme::dim(),
)?;
renderer.write_line("", Color::White)?;
renderer.write_line("", Color::Reset)?;
}
for msg in &session.messages {
let (prefix, _c) = match msg.role {
MessageRole::User => (">", Color::Green),
MessageRole::Assistant => ("<", Color::White),
MessageRole::System => ("#", Color::DarkGrey),
let total = session.messages.len();
for (idx, msg) in session.messages.iter().enumerate() {
// IRC-style angle-bracketed handle. All three handles padded
// to 8 columns so multi-role chats stay visually aligned.
// Continuation lines are indented to that same width so the
// handle isn't repeated on every wrap.
let (handle, line_color) = match msg.role {
MessageRole::User => ("<you> ", theme::user()),
MessageRole::Assistant => ("<dirge> ", theme::agent()),
MessageRole::System => ("<sys> ", theme::system()),
};
let cont_indent = " ".repeat(handle.chars().count());

if msg.role == MessageRole::Assistant {
let max_width = renderer.line_width();
// Wrap chat to the same width tool chambers use so chat
// and chamber blocks line up visually. The 8-col handle
// prefix is subtracted so wrapped continuation text fits
// beneath the handle position.
let max_width = renderer
.content_width()
.saturating_sub(handle.chars().count() + 1);
let mut styled = markdown::markdown_to_styled(&msg.content, max_width);
if !styled.is_empty() {
styled[0].text = CompactString::from(format!("{} {}", prefix, styled[0].text));
for (i, entry) in styled.iter_mut().enumerate() {
if i == 0 {
entry.text = CompactString::from(format!("{} {}", handle, entry.text));
} else {
entry.text = CompactString::from(format!("{}{}", cont_indent, entry.text));
}
}
for entry in styled {
renderer.write_line(&entry.text, entry.color)?;
}
} else {
for line in msg.content.lines() {
renderer.write_line(&format!("{} {}", prefix, line), _c)?;
for (i, line) in msg.content.lines().enumerate() {
let prefix = if i == 0 {
handle.to_string()
} else {
cont_indent.clone()
};
renderer.write_line(&format!("{} {}", prefix, line), line_color)?;
}
}
renderer.write_line("", Color::White)?;
// Thin chamber-bar divider between turns. Single character,
// not a full-width gradient — the bar runs flush against the
// left margin like an IRC log's timeline.
if idx + 1 < total {
renderer.write_line("·", theme::divider())?;
} else {
renderer.write_line("", Color::Reset)?;
}
}
Ok(())
}

/// Block-letter "DIRGE" in the ANSI Shadow figlet style. Period-correct
/// 80s BBS aesthetic. Six lines tall, 38 chars wide.
const DIRGE_BLOCK_ART: &[&str] = &[
"██████╗ ██╗██████╗ ██████╗ ███████╗",
"██╔══██╗██║██╔══██╗██╔════╝ ██╔════╝",
"██║ ██║██║██████╔╝██║ ███╗█████╗ ",
"██║ ██║██║██╔══██╗██║ ██║██╔══╝ ",
"██████╔╝██║██║ ██║╚██████╔╝███████╗",
"╚═════╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝",
];

/// Welcome banner — block-letter "DIRGE" wordmark inside a rounded
/// frame with the theme/version/provider/model on the bottom border.
/// Mirrors the btop / cool-retro-term reference: every UI region is a
/// rounded panel with its label sitting on the border, no heavy
/// gradient stripes. Falls back to a single-line text banner on
/// terminals narrower than 50 cols.
fn render_banner(renderer: &mut Renderer, provider: &str, model: &str) -> anyhow::Result<()> {
let label = theme::current().label;
let version = env!("CARGO_PKG_VERSION");
let term_w = renderer.line_width().max(20);

if term_w < 50 {
renderer.write_line(
&format!("╭─ DIRGE · {} · v{} ", label, version),
theme::banner_primary(),
)?;
renderer.write_line(
&format!("│ provider: {} · model: {}", provider, model),
theme::banner_secondary(),
)?;
renderer.write_line("╰─", theme::banner_secondary())?;
renderer.write_line("", Color::Reset)?;
return Ok(());
}

// Frame width: cap at 78 so the banner doesn't sprawl on wide
// monitors, and so it sits visually proportionate to the chat.
let frame_w = term_w.min(78);
let inner_w = frame_w.saturating_sub(2);

// Top border with the wordmark label sitting on it.
let top_label = format!(" DIRGE · {} ", label);
let top_label_len = top_label.chars().count();
let top_filler = inner_w.saturating_sub(top_label_len + 2);
let top_left = "─".repeat(2);
let top_right = "─".repeat(top_filler);
let top_border = format!("╭{}{}{}╮", top_left, top_label, top_right);

// Bottom border with the status label.
let bot_label = format!(" v{} · {} · {} ", version, provider, model);
let bot_label_len = bot_label.chars().count();
let bot_left = "─".repeat(inner_w.saturating_sub(bot_label_len + 2));
let bot_right = "─".repeat(2);
let bot_border = format!("╰{}{}{}╯", bot_left, bot_label, bot_right);

renderer.write_line(&top_border, theme::banner_secondary())?;
// Padding row above the art.
renderer.write_line(
&format!("│{}│", " ".repeat(inner_w)),
theme::banner_secondary(),
)?;
// Block-letter art, padded on both sides to fill the frame.
// The whole line renders in the bright banner_primary tone so the
// wordmark glows; the surrounding empty padding rows + borders
// stay dim, giving the eye a clear focal point.
for art_line in DIRGE_BLOCK_ART {
let art_len = art_line.chars().count();
let total_pad = inner_w.saturating_sub(art_len);
let left = total_pad / 2;
let right = total_pad - left;
let line = format!("│{}{}{}│", " ".repeat(left), art_line, " ".repeat(right),);
renderer.write_line(&line, theme::banner_primary())?;
}
// Padding row below the art.
renderer.write_line(
&format!("│{}│", " ".repeat(inner_w)),
theme::banner_secondary(),
)?;
renderer.write_line(&bot_border, theme::banner_secondary())?;
renderer.write_line("", Color::Reset)?;
Ok(())
}

pub fn sanitize_output(text: &str) -> CompactString {
let mut result = String::with_capacity(text.len());
let mut chars = text.chars();
// Two-pass: first strip orphan SGR mouse reports of the form
// `[<digits;digits;digits(M|m)` (no leading escape). These can
// leak into tool output when a shell command captures terminal
// input bytes, and without this guard they smear `[<65;79;32M…`
// through the chamber. Then run the regular ANSI/control-char
// sanitizer over the cleaned text.
let stripped = strip_orphan_mouse_reports(text);

let mut result = String::with_capacity(stripped.len());
let mut chars = stripped.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
match chars.next() {
Expand All @@ -116,3 +237,46 @@ pub fn sanitize_output(text: &str) -> CompactString {
}
CompactString::from(result)
}

/// Strip orphan SGR mouse-report sequences (e.g. `[<65;79;32M`) that
/// arrive without their leading `\x1b`. Walks the input scanning for
/// the literal pattern `[<` followed by digits and semicolons ending
/// in `M` or `m`; matched runs are dropped. Anything else passes
/// through unchanged.
fn strip_orphan_mouse_reports(text: &str) -> String {
let bytes: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == '[' && i + 1 < bytes.len() && bytes[i + 1] == '<' {
// Try to match `[<digits;digits;digits(M|m)`.
let mut j = i + 2;
let mut saw_digit_or_semi = false;
while j < bytes.len() {
let c = bytes[j];
if c.is_ascii_digit() || c == ';' {
saw_digit_or_semi = true;
j += 1;
} else if (c == 'M' || c == 'm') && saw_digit_or_semi {
i = j + 1;
break;
} else {
// Not a mouse report — pass `[` through and resume
// scanning at the next position.
out.push(bytes[i]);
i += 1;
break;
}
}
if j >= bytes.len() {
// Truncated input — pass through what we have.
out.push(bytes[i]);
i += 1;
}
} else {
out.push(bytes[i]);
i += 1;
}
}
out
}
Loading
Loading