Skip to content

ui: phosphor theme (80s CRT green) - #49

Merged
yogthos merged 9 commits into
mainfrom
feat/phosphor-theme
May 20, 2026
Merged

ui: phosphor theme (80s CRT green)#49
yogthos merged 9 commits into
mainfrom
feat/phosphor-theme

Conversation

@yogthos

@yogthos yogthos commented May 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a centralized UI color theme with two presets and a default-on
phosphor green palette inspired by 80s CRT terminals. Usability
preserved: errors stay red and warnings stay yellow across every
preset — semantic colors are not sacrificed for aesthetics.

What's new

  • src/ui/theme.rsTheme struct with semantic roles plus two
    presets:

    • phosphor (default): green accents on black, dim green for
      secondary text, yellow for permission prompts (loud on purpose).
    • plain: the pre-theme white/cyan look. Opt back in with
      \"theme\": \"plain\" in config.json.
      Active theme is initialized once at boot and read everywhere via
      helper fns (theme::agent(), theme::error(), …) so adding a new
      preset is a one-file change.
  • CRT-style welcome banner — 4 lines, box-drawn:

    ╔══════════════════════════════════════════╗
    ║ ░ DIRGE ░ PHOSPHOR ░ v1.0.0              ║
    ║ provider: openai · model: gpt-4          ║
    ╚══════════════════════════════════════════╝
    

    Truncates gracefully on narrow terminals.

  • theme: Option<String> config — documented in CONFIG.md +
    README note.

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() / c_error() / … fn calls that delegate to the theme
module. Spelling stayed similar so the diff is mostly mechanical.
renderer.rs panel + status + input-prompt color literals and
picker.rs selection colors were replaced with theme accessors.

Test plan

  • 3 new ui::theme::tests:
    • presets_are_distinct
    • error_and_warn_stay_loud (locks the red/yellow contract)
    • init_with_unknown_name_falls_back
  • cargo test --features plugin -> 599 pass, 0 fail (was 596).
  • cargo build --features plugin -> 0 warnings.
  • cargo build -> 0 warnings.
  • Binary runs (./target/debug/dirge --version).
  • Eyeball: launch dirge and verify the banner + chat colors
    render correctly on your terminal.

CLI flag intentionally omitted — palette is a long-lived per-user
preference, not a per-invocation choice.

Yogthos added 9 commits May 20, 2026 15:37
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.
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.
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.
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.
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.
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.
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.
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.
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.
@yogthos
yogthos merged commit 7a3af9a into main May 20, 2026
1 check passed
@yogthos
yogthos deleted the feat/phosphor-theme branch May 20, 2026 21:46
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant