Skip to content

Commit 7a3af9a

Browse files
yogthosYogthos
andauthored
ui: phosphor theme (80s CRT green) (#49)
* 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. * ui: real 80s aesthetic — block banner, framed surfaces, pure phosphor Initial pass was a bare color swap; this rebuilds every UI surface with cohesive BBS-era design language. Each surface picks up the same visual vocabulary (gradient bars, framed wordmarks, chamber prefixes) so the whole app reads as one designed thing rather than "green text on a vanilla TUI." ## What changed ### Welcome banner Chunky 6-line block-letter "DIRGE" wordmark in the ANSI Shadow figlet style, sandwiched between centered gradient bars (`▓▒░░░▒▓`) with the theme/version/provider/model on a single status line below. Falls back to a one-line text banner on terminals narrower than 42 columns. ### Chat messages Role badges replace the bare `>` / `<` / `#` prefixes: - `▌▌ USR ▏` user - `▌▌ AI ▏` assistant - `▌▌ SYS ▏` system A thin `░▒░▒` scanline divider lands between turns (capped at 60 chars) so the eye finds turn boundaries without having to count prefixes. ### Tool invocations Tool-call line gets a BBS-style badge: `▒░ READ ░▒ /path/to/file` Tool output renders inside a "chamber": every line prefixed with `▏ ` so the output reads as visually attached to the badge above. Truncation marker is `▏ ░░ +N chars truncated ░░` instead of a generic `[truncated: N more chars]`. ### Permission prompts The single most important UX moment is now framed: ``` ╔══[ ⚠ ALERT · PERMISSION ]═══════════════════════════════════════╗ ║ tool : bash ║ args : rm -rf ./build ╠══════════════════════════════════════════════════════════════╣ ║ (y) allow once (a) allow always (n) deny (ESC) abort ╚══════════════════════════════════════════════════════════════╝ ``` Fixed 64-col width independent of terminal size; can't be missed. ### Right-hand info panel - Top: gradient bar + `▒░ DIRGE.SYS ░▒` wordmark + cwd. - Sections: `─[ MCP ]─────────` style framed headers, uppercase ("MCP", "LSP", "TODOS", "MODIFIED"). - Empty sections show `· (none)` in the dim phosphor tone instead of grey "(none)" — pure phosphor means no grey on the green axis. ### Input prompt Block-style indicator with a phosphor pulse while the agent runs: - idle: `▌▌ ` - running: alternates `░▌ ` / `▒▌ ` per tick (the spinner) Wrapped continuation rows show `▏ ` so multi-line input visually chambers under the prompt. ### Status bar Bracketed BBS-style chip segments instead of dim flowing text: `▒░ STATUS ░▒ ▒░ 3 lines ░▒ ▒░ 142 tk ░▒` ### Pure phosphor palette `theme::dim()` now resolves to `DarkGreen` (not `DarkGrey`) under the phosphor preset. Every previously-grey UI element (compaction notes, plugin notifications, edit-diff prose, ESC-rewind hint, tool-output chambers, etc.) now renders on the green axis. The `plain` preset still uses `DarkGrey` for compatibility. ## Notes - Errors stay red and warnings stay yellow. The semantic urgency channel is the single thing the theme contract never trades for aesthetics. - File contents passed back to the LLM are unchanged — the decorations live in the rendering layer only. - `cargo test --features plugin` -> 599 pass, 0 fail. - Both build profiles -> 0 warnings. * ui: fix cursor position to match 3-char prompt prefix The new 3-character prompt indicators (`▌▌ `, `░▌ `/`▒▌ ` spinner, `▏ ` continuation) caused the cursor and soft-wrap math to drift by one column — typing 'ls' rendered as 'lss' because the cursor pointed at column 2 (under the prompt's third char) instead of column 3 (the first input cell). Two spots had the prior 2-char prompt baked in as literal `2`: `wrap_width = cols.saturating_sub(2)` and `cursor_x = 2 + cursor_visual_col`. Both bumped to 3 with comments documenting the dependency, so a future prompt redesign won't repeat the bug. * ui: rounded frames + IRC handles + phosphor glow Three changes that take the phosphor theme from "color swap" to a proper btop / cool-retro-term aesthetic: ## Rounded frames everywhere, label-on-border Every UI region now lives inside a rounded `╭─ Label ─╮` frame (or half-frame for vertical strips like the right-hand panel), mirroring the btop reference where the section title sits *on* the top border rather than above it. - **Welcome banner**: block-letter `DIRGE` art centred inside a rounded frame with `DIRGE · PHOSPHOR` on the top border and `v1.0.0 · provider · model` on the bottom border. Replaces the earlier heavy gradient stripes (too busy / not soft). - **Right-hand panel**: top is `╭─ DIRGE.SYS ────`; each section becomes `╭─ MCP ──────` with a `│` chamber bar running down the left margin so items in each section read as a vertical block. - **Permission prompt**: rounded `╭─ ⚠ ALERT · PERMISSION ──╮` frame with bracketed action hints `[y] allow once · [a] allow always · [n] deny · [ESC] abort`. - **Tool calls**: each tool invocation opens a rounded chamber: ``` ╭─ READ ─ /path/to/file │ File: /path/to/file (47 lines, showing 1-47) │ 1: (ns ...) │ 2: (:gen-class) │ ░ +42 chars truncated ╰─ ``` Edit-tool diffs render their +/- lines inside the same chamber so the entire tool execution reads as one bounded unit. ## IRC-style chat handles Replaces `▌▌ USR ▏` / `▌▌ AI ▏` / `▌▌ SYS ▏` with the IRC convention seen in the cyberspace reference screenshots: `<you> what's the weather like` `<dirge> I'd need a weather tool ...` `<sys> loaded AGENTS.md` All handles padded to 8 columns so multi-role chats stay visually aligned. Continuation lines (markdown wrapping, multi-line input) indent to the same width — the handle never repeats. Turn divider is a single `·` in dim phosphor, replacing the full-width gradient that fought for attention with the chat content. Live-stream prefix `< ` swapped for `<dirge> ` at every site; user-echo `> ` swapped for `<you> ` at every site. ## CRT phosphor bloom via Bold Bright phosphor tones (`Color::Green`, `Color::Red`, etc.) now render with `SetAttribute(Bold)` wrapped around the `SetForegroundColor`. On most terminals this nudges the glyphs to a heavier weight and a brighter shade — the same effect that gives the btop / cool-retro-term reference screenshots their visible glow. Dim tones (`DarkGreen`, `DarkGrey`) stay un-bold so the two-tone depth (bright body / dim chrome) is preserved. A new `theme::is_bright(Color) -> bool` predicate gates the attribute; adding a new bright color to a future theme is a one-line change. ## Test plan - [x] `cargo test --features plugin` -> 599 pass, 0 fail. - [x] Both build profiles -> 0 warnings. - [ ] Eyeball: launch and verify the banner, panel section frames, tool chambers, IRC handles, and the slight glow. * ui: closed pills, opencode-style diff bg, fix mouse-report leak Address every issue from the visual review: ## Closed pills (panel + tool chambers) Every framed region now closes properly on the right + bottom: - **Panel sections**: `╭─ MCP ─────╮` ... `│ items │` ... `╰───────────╯`. The right border + bottom border were missing before; sections felt unfinished. Each item row pads to the panel's inner width so the right `│` aligns vertically. - **Tool chambers**: top `╭─ READ ─ /path ──────────╮` extends with dashes out to the frame width, content rows `│ line │` pad/truncate to fit, bottom `╰────────╯` matches. - Chamber inner content longer than the frame is truncated with `…` instead of soft-wrapping out of the box. Frame width caps at 120 cols so wide terminals don't sprawl. Two helpers carry the logic: `chamber_widths(renderer)` returns `(frame_w, inner)` based on `renderer.line_width().min(120)`; `chamber_row(content, inner)` produces a single `│ ... │` row. Tool-call header, normal output, edit diffs, and truncation markers all route through the same helpers. ## Opencode-style diff backgrounds The edit-tool diff renderer now matches opencode's tinted-bg approach (`tint(bg, green, 0.15)` etc.): - `+` lines: background palette 22 (dim green) across the inner chamber width, fg bright green. - `-` lines: background palette 52 (dim red), fg bright red. - `--- ` / `+++ ` header lines: no bg, fg cyan. - `@@` hunk markers: no bg, fg dark cyan. - Context lines: no bg, fg dim phosphor. Backgrounds embed raw SGR escapes (`\x1b[48;5;Nm…\x1b[49m`) inside the row so the chamber's left/right borders stay in their normal color while the diff tint fills the inner width. New `chamber_row_with_bg(content, inner, bg_idx)` helper. ## Color fixes - **User input text** (what the user types into the input box) is now rendered in `theme::user()` (bright green) instead of the terminal's default tone, which read as off-white/grey. - **Assistant reply body** (markdown-rendered prose) now uses `theme::agent()` (bright green) instead of `Color::White`. All 13 `Color::White` references in `src/ui/markdown.rs` swept to `theme::agent()`; under `plain` theme the helper resolves back to White so the legacy look is preserved. ## Defensive mouse-report sanitization Bug from the screenshot where `[<65;79;32M[<65;79;32M…` smeared across tool output. SGR mouse reports (`\x1b[<btn;col;row(M|m)`) were leaking into the rendered text without their leading `\x1b` — most likely from a shell command in the agent's bash tool capturing terminal input bytes. `sanitize_output` now runs a pre-pass (`strip_orphan_mouse_reports`) that walks the text matching `[<digits;digits;digits(M|m)` and drops matched runs. Genuine `[` characters that don't start a mouse-report pattern pass through unchanged. ## Test plan - [x] `cargo test --features plugin` -> 599 pass, 0 fail. - [x] Both build profiles -> 0 warnings. - [ ] Eyeball: panel sections render as closed cards, tool chambers close on both sides, diff `+`/`-` lines show tinted bg strips, no `[<…M` smear in tool output. * ui: add top padding above banner When the user scrolls to the very top of the buffer, the banner's top border `╭───╮` was sitting flush against the terminal's row 0 — visually pressed against the top edge of the screen. Reads as "cut off" and makes scrolling-up feel like it stopped working because there's no visual indicator that the absolute top has been reached. Two blank rows prepended at the start of every `render_session` pass, before the banner is drawn. Now when scrolled all the way up, the user sees a couple of empty rows above the banner — clear "this is the top" affordance, banner reads as breathing rather than truncated. * ui: panel top padding, chamber-close, scroll during permission Four bug fixes from the visual review: ## Panel top cut off Two blank padding rows at the start of `build_panel_lines` so the DIRGE.SYS frame's `╭───╮` no longer sits flush against terminal row 0. Same fix as the chat banner — without it the top reads as truncated, and some terminals additionally shift the panel up by a row when the bottom-right cell is touched (see below) which ate the entire DIRGE.SYS frame. ## Panel last-column scroll trigger `draw_panel` now writes `PANEL_WIDTH - 1` chars per row instead of `PANEL_WIDTH`. Writing to the terminal's absolute last column (col `cols-1` of any row) triggers an implicit scroll-up on most terminals — that's what was shifting the entire side panel up each redraw and consuming the top frame. ## Tool chambers stay open on error Previously, when a tool errored mid-flight (e.g. permission denied), no `ToolResult` event fired and so no `╰────╯` chamber bottom was emitted. The unclosed `╭─ NAME ─ args` chamber then absorbed whatever rendered next — most visibly, the `╭─ ⚠ ALERT · PERMISSION ─╮` box rendered nested inside the in-flight tool's frame, then the `Toolset error:` line dangled below with no closing border. New helper `close_tool_chamber_if_open(renderer, &mut last_tool_name)` emits `╰────╯` and clears the chamber flag. Called from: - `AgentEvent::Error` (the actual fix for the screenshot) - `AgentEvent::Interjected` (Ctrl-C mid-tool) - `AgentEvent::ToolCall` (defensive — close any stale chamber before opening the next one) - Right before the permission alert renders (so the alert sits outside the in-flight tool's chamber rather than nested inside) ## Scroll during permission alert The inner permission loop only handled `UserEvent::Key` and dropped `ScrollUp` / `ScrollDown` on the floor. That locked the chat viewport — once the alert appeared the user couldn't scroll back to read what triggered the permission check. Both scroll events now route through `scroll_line_up` / `scroll_line_down` + `render_viewport` while the alert is up. ## Test plan - [x] `cargo test --features plugin` -> 599 pass, 0 fail. - [x] Both build profiles -> 0 warnings. - [ ] Eyeball: trigger a permission-denied tool, verify the chamber closes cleanly and the alert renders outside it; verify panel DIRGE.SYS frame is fully visible at the top; verify scroll works while alert is open. * ui: center chat content + wrap to chamber width Two parts working together: ## Wrap chat messages to chamber width Markdown rendering for assistant messages and the live token stream used `renderer.line_width()` (the full content band), so long replies spilled across the entire terminal even when the tool chambers below them were capped at 120 cols. Result: chat text was visually a different width from chambers — the eye saw two parallel layouts. Both render paths now wrap to `renderer.content_width()` minus the 8-col chat handle prefix (`<dirge> ` + space), so wrapped lines sit beneath the handle and the right edge matches chamber width. ## Center the chat band The whole chat content area (chamber tops/bottoms, chat lines, banner, prompt input row, status row, cursor) now indents by `content_indent()` columns to center within the visible chat band. On a 160-col terminal with the panel visible: - band width = 160 - 33 (panel) - 1 (divider gutter) = 126 cols - target content width = min(126, 120) = 120 cols - indent = (126 - 120) / 2 = 3 cols So the chat sits in a 120-col column with ~3 cols of margin each side, regardless of how wide the terminal is. On a 100-col terminal the indent is 0 — the chat uses the full band. Two new helpers on `Renderer`: - `content_width() -> usize` — cap at 120 cols. - `content_indent() -> usize` — left padding to center within the band. `render_viewport` applies the indent at paint time so the buffer itself stays raw (no rewrites needed when terminal width changes). The bottom rows (input + status + cursor) match. ## Test plan - [x] `cargo test --features plugin` -> 599 pass, 0 fail. - [x] Both build profiles -> 0 warnings. - [ ] Eyeball: on a wide terminal, verify chat + chambers share the same 120-col column and both visibly center under the chat band; verify cursor lands where text is being typed; verify status line + prompt align with chat above. * ui: apply indent in streaming write paths too Fixes the "UI jumps around" bug from the screenshot. After the centering pass landed, the buffer-based `render_viewport` painted chat at the indented column, but two streaming write paths still wrote at column 0: - `write_line` — used by tool chambers, banner, etc. - `write` — used by the live token stream during agent responses. Both did `MoveTo(self.col, r)` to paint each chunk immediately. With `content_indent() > 0`, that left the streaming output at col 0 until the next full `render_viewport` repaint, at which point it visibly jumped to the centered position. New tool chambers that arrived between repaints stayed pinned to col 0 — which is what the screenshot showed: top blocks centered, bottom LIST_DIR blocks pressed against the left edge. Both paths now offset by `content_indent()`: - `write_line` writes the indent spaces first, then the chunk. - `write` uses `MoveTo(indent + self.col, r)` so multi-chunk streaming respects the column offset for every paint. `self.col` continues to track the *content-relative* column (0 means start of line); the indent is added only at the actual MoveTo / direct-paint site, so the rest of the wrap math stays unchanged. ## Test plan - [x] `cargo test --features plugin` -> 599 pass, 0 fail. - [x] Both build profiles -> 0 warnings. - [ ] Eyeball: run an agent that streams a long reply followed by multiple tool calls; verify every block lands at the same centered column with no left-aligned blocks appearing first. --------- Co-authored-by: Yogthos <yogthos@gmail.com>
1 parent 048d12b commit 7a3af9a

11 files changed

Lines changed: 1175 additions & 372 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: 190 additions & 26 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,61 +41,181 @@ 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+
// Top padding rows. Without this, when the user scrolls all the
45+
// way up, the banner's top border `╭───╮` sits pressed against
46+
// the terminal's top edge, which reads as "cut off." Two blank
47+
// rows give the eye breathing room above the banner.
48+
renderer.write_line("", Color::Reset)?;
49+
renderer.write_line("", Color::Reset)?;
50+
render_banner(renderer, &provider, &model)?;
5151
if context.agents.is_some() {
52-
renderer.write_line("loaded AGENTS.md", Color::DarkGrey)?;
53-
renderer.write_line("", Color::White)?;
52+
renderer.write_line("loaded AGENTS.md", theme::dim())?;
53+
renderer.write_line("", Color::Reset)?;
5454
}
5555
if !session.compactions.is_empty() {
5656
renderer.write_line(
5757
&format!(
58-
"compacted {} times (saved ~{} tokens)",
58+
"compacted {} times (saved ~{} tokens)",
5959
session.compactions.len(),
6060
session
6161
.compactions
6262
.last()
6363
.map(|c| c.token_savings)
6464
.unwrap_or(0),
6565
),
66-
Color::DarkGrey,
66+
theme::dim(),
6767
)?;
68-
renderer.write_line("", Color::White)?;
68+
renderer.write_line("", Color::Reset)?;
6969
}
70-
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),
70+
let total = session.messages.len();
71+
for (idx, msg) in session.messages.iter().enumerate() {
72+
// IRC-style angle-bracketed handle. All three handles padded
73+
// to 8 columns so multi-role chats stay visually aligned.
74+
// Continuation lines are indented to that same width so the
75+
// handle isn't repeated on every wrap.
76+
let (handle, line_color) = match msg.role {
77+
MessageRole::User => ("<you> ", theme::user()),
78+
MessageRole::Assistant => ("<dirge> ", theme::agent()),
79+
MessageRole::System => ("<sys> ", theme::system()),
7580
};
81+
let cont_indent = " ".repeat(handle.chars().count());
82+
7683
if msg.role == MessageRole::Assistant {
77-
let max_width = renderer.line_width();
84+
// Wrap chat to the same width tool chambers use so chat
85+
// and chamber blocks line up visually. The 8-col handle
86+
// prefix is subtracted so wrapped continuation text fits
87+
// beneath the handle position.
88+
let max_width = renderer
89+
.content_width()
90+
.saturating_sub(handle.chars().count() + 1);
7891
let mut styled = markdown::markdown_to_styled(&msg.content, max_width);
79-
if !styled.is_empty() {
80-
styled[0].text = CompactString::from(format!("{} {}", prefix, styled[0].text));
92+
for (i, entry) in styled.iter_mut().enumerate() {
93+
if i == 0 {
94+
entry.text = CompactString::from(format!("{} {}", handle, entry.text));
95+
} else {
96+
entry.text = CompactString::from(format!("{}{}", cont_indent, entry.text));
97+
}
8198
}
8299
for entry in styled {
83100
renderer.write_line(&entry.text, entry.color)?;
84101
}
85102
} else {
86-
for line in msg.content.lines() {
87-
renderer.write_line(&format!("{} {}", prefix, line), _c)?;
103+
for (i, line) in msg.content.lines().enumerate() {
104+
let prefix = if i == 0 {
105+
handle.to_string()
106+
} else {
107+
cont_indent.clone()
108+
};
109+
renderer.write_line(&format!("{} {}", prefix, line), line_color)?;
88110
}
89111
}
90-
renderer.write_line("", Color::White)?;
112+
// Thin chamber-bar divider between turns. Single character,
113+
// not a full-width gradient — the bar runs flush against the
114+
// left margin like an IRC log's timeline.
115+
if idx + 1 < total {
116+
renderer.write_line("·", theme::divider())?;
117+
} else {
118+
renderer.write_line("", Color::Reset)?;
119+
}
120+
}
121+
Ok(())
122+
}
123+
124+
/// Block-letter "DIRGE" in the ANSI Shadow figlet style. Period-correct
125+
/// 80s BBS aesthetic. Six lines tall, 38 chars wide.
126+
const DIRGE_BLOCK_ART: &[&str] = &[
127+
"██████╗ ██╗██████╗ ██████╗ ███████╗",
128+
"██╔══██╗██║██╔══██╗██╔════╝ ██╔════╝",
129+
"██║ ██║██║██████╔╝██║ ███╗█████╗ ",
130+
"██║ ██║██║██╔══██╗██║ ██║██╔══╝ ",
131+
"██████╔╝██║██║ ██║╚██████╔╝███████╗",
132+
"╚═════╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝",
133+
];
134+
135+
/// Welcome banner — block-letter "DIRGE" wordmark inside a rounded
136+
/// frame with the theme/version/provider/model on the bottom border.
137+
/// Mirrors the btop / cool-retro-term reference: every UI region is a
138+
/// rounded panel with its label sitting on the border, no heavy
139+
/// gradient stripes. Falls back to a single-line text banner on
140+
/// terminals narrower than 50 cols.
141+
fn render_banner(renderer: &mut Renderer, provider: &str, model: &str) -> anyhow::Result<()> {
142+
let label = theme::current().label;
143+
let version = env!("CARGO_PKG_VERSION");
144+
let term_w = renderer.line_width().max(20);
145+
146+
if term_w < 50 {
147+
renderer.write_line(
148+
&format!("╭─ DIRGE · {} · v{} ", label, version),
149+
theme::banner_primary(),
150+
)?;
151+
renderer.write_line(
152+
&format!("│ provider: {} · model: {}", provider, model),
153+
theme::banner_secondary(),
154+
)?;
155+
renderer.write_line("╰─", theme::banner_secondary())?;
156+
renderer.write_line("", Color::Reset)?;
157+
return Ok(());
91158
}
159+
160+
// Frame width: cap at 78 so the banner doesn't sprawl on wide
161+
// monitors, and so it sits visually proportionate to the chat.
162+
let frame_w = term_w.min(78);
163+
let inner_w = frame_w.saturating_sub(2);
164+
165+
// Top border with the wordmark label sitting on it.
166+
let top_label = format!(" DIRGE · {} ", label);
167+
let top_label_len = top_label.chars().count();
168+
let top_filler = inner_w.saturating_sub(top_label_len + 2);
169+
let top_left = "─".repeat(2);
170+
let top_right = "─".repeat(top_filler);
171+
let top_border = format!("╭{}{}{}╮", top_left, top_label, top_right);
172+
173+
// Bottom border with the status label.
174+
let bot_label = format!(" v{} · {} · {} ", version, provider, model);
175+
let bot_label_len = bot_label.chars().count();
176+
let bot_left = "─".repeat(inner_w.saturating_sub(bot_label_len + 2));
177+
let bot_right = "─".repeat(2);
178+
let bot_border = format!("╰{}{}{}╯", bot_left, bot_label, bot_right);
179+
180+
renderer.write_line(&top_border, theme::banner_secondary())?;
181+
// Padding row above the art.
182+
renderer.write_line(
183+
&format!("│{}│", " ".repeat(inner_w)),
184+
theme::banner_secondary(),
185+
)?;
186+
// Block-letter art, padded on both sides to fill the frame.
187+
// The whole line renders in the bright banner_primary tone so the
188+
// wordmark glows; the surrounding empty padding rows + borders
189+
// stay dim, giving the eye a clear focal point.
190+
for art_line in DIRGE_BLOCK_ART {
191+
let art_len = art_line.chars().count();
192+
let total_pad = inner_w.saturating_sub(art_len);
193+
let left = total_pad / 2;
194+
let right = total_pad - left;
195+
let line = format!("│{}{}{}│", " ".repeat(left), art_line, " ".repeat(right),);
196+
renderer.write_line(&line, theme::banner_primary())?;
197+
}
198+
// Padding row below the art.
199+
renderer.write_line(
200+
&format!("│{}│", " ".repeat(inner_w)),
201+
theme::banner_secondary(),
202+
)?;
203+
renderer.write_line(&bot_border, theme::banner_secondary())?;
204+
renderer.write_line("", Color::Reset)?;
92205
Ok(())
93206
}
94207

95208
pub fn sanitize_output(text: &str) -> CompactString {
96-
let mut result = String::with_capacity(text.len());
97-
let mut chars = text.chars();
209+
// Two-pass: first strip orphan SGR mouse reports of the form
210+
// `[<digits;digits;digits(M|m)` (no leading escape). These can
211+
// leak into tool output when a shell command captures terminal
212+
// input bytes, and without this guard they smear `[<65;79;32M…`
213+
// through the chamber. Then run the regular ANSI/control-char
214+
// sanitizer over the cleaned text.
215+
let stripped = strip_orphan_mouse_reports(text);
216+
217+
let mut result = String::with_capacity(stripped.len());
218+
let mut chars = stripped.chars();
98219
while let Some(c) = chars.next() {
99220
if c == '\x1b' {
100221
match chars.next() {
@@ -116,3 +237,46 @@ pub fn sanitize_output(text: &str) -> CompactString {
116237
}
117238
CompactString::from(result)
118239
}
240+
241+
/// Strip orphan SGR mouse-report sequences (e.g. `[<65;79;32M`) that
242+
/// arrive without their leading `\x1b`. Walks the input scanning for
243+
/// the literal pattern `[<` followed by digits and semicolons ending
244+
/// in `M` or `m`; matched runs are dropped. Anything else passes
245+
/// through unchanged.
246+
fn strip_orphan_mouse_reports(text: &str) -> String {
247+
let bytes: Vec<char> = text.chars().collect();
248+
let mut out = String::with_capacity(text.len());
249+
let mut i = 0;
250+
while i < bytes.len() {
251+
if bytes[i] == '[' && i + 1 < bytes.len() && bytes[i + 1] == '<' {
252+
// Try to match `[<digits;digits;digits(M|m)`.
253+
let mut j = i + 2;
254+
let mut saw_digit_or_semi = false;
255+
while j < bytes.len() {
256+
let c = bytes[j];
257+
if c.is_ascii_digit() || c == ';' {
258+
saw_digit_or_semi = true;
259+
j += 1;
260+
} else if (c == 'M' || c == 'm') && saw_digit_or_semi {
261+
i = j + 1;
262+
break;
263+
} else {
264+
// Not a mouse report — pass `[` through and resume
265+
// scanning at the next position.
266+
out.push(bytes[i]);
267+
i += 1;
268+
break;
269+
}
270+
}
271+
if j >= bytes.len() {
272+
// Truncated input — pass through what we have.
273+
out.push(bytes[i]);
274+
i += 1;
275+
}
276+
} else {
277+
out.push(bytes[i]);
278+
i += 1;
279+
}
280+
}
281+
out
282+
}

0 commit comments

Comments
 (0)