ui: styled-output-line mechanism (ADR-0028 step 1)

OutputLine gains an optional styled-runs payload — a
Vec<OutputSpan> of { byte_range, OutputStyleClass } over the
line text. render_output_line gains a branch: when the payload
is present it renders the text span-by-span, each run's
semantic class (Neutral / Efficient / Expensive /
AutomaticIndex) resolved to a theme colour at render time;
otherwise the existing whole-line kind styling. The echo path
is untouched.

Theme gains `plan_efficient` — a green deliberately distinct
from `system` so green never reads as two things (ADR-0028 §6);
`warning` is reused for expensive steps.

A general per-span output-styling capability (ADR-0016's OOS-3
realized); the query-plan renderer will be its first consumer.
No user-visible change on its own. 1133 passing, clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-19 10:45:43 +00:00
parent a1e4932858
commit 03d8a09457
3 changed files with 152 additions and 3 deletions
+60
View File
@@ -30,11 +30,65 @@ pub enum OutputKind {
Error, Error,
} }
/// The semantic style class of an [`OutputSpan`] (ADR-0028 §5).
///
/// A general output-styling vocabulary, resolved to a concrete
/// theme colour at render time — never a baked-in colour. The
/// query-plan renderer (ADR-0028) is its first consumer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputStyleClass {
/// Default foreground — connectors, names, structural text.
Neutral,
/// An efficient query-plan step — an index search, a
/// covering index, a primary-key lookup.
Efficient,
/// An expensive query-plan step — a full table scan or a
/// temp B-tree.
Expensive,
/// An automatic-index step — the engine built a temporary
/// index because none existed; the strongest "add an index
/// here" signal.
AutomaticIndex,
}
/// A styled span of an output line: a byte range over the
/// line's text and the semantic class it carries (ADR-0028 §5).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutputSpan {
/// Half-open byte range `[start, end)` into the line text.
pub byte_range: (usize, usize),
pub class: OutputStyleClass,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct OutputLine { pub struct OutputLine {
pub text: String, pub text: String,
pub kind: OutputKind, pub kind: OutputKind,
pub mode_at_submission: Mode, pub mode_at_submission: Mode,
/// Optional per-span styling (ADR-0028 §5). When `Some`,
/// `render_output_line` colours the text span-by-span from
/// these runs; when `None` it falls back to whole-line
/// styling by `kind`.
pub styled_runs: Option<Vec<OutputSpan>>,
}
impl OutputLine {
/// An output line carrying per-span styled runs (ADR-0028
/// §5) — the text is coloured per `runs`, not by `kind`.
#[must_use]
pub const fn styled(
text: String,
kind: OutputKind,
mode_at_submission: Mode,
runs: Vec<OutputSpan>,
) -> Self {
Self {
text,
kind,
mode_at_submission,
styled_runs: Some(runs),
}
}
} }
/// What mode the next submission would be evaluated in. /// What mode the next submission would be evaluated in.
@@ -948,6 +1002,7 @@ impl App {
text: effective_input, text: effective_input,
kind: OutputKind::Echo, kind: OutputKind::Echo,
mode_at_submission: effective_mode, mode_at_submission: effective_mode,
styled_runs: None,
}); });
Vec::new() Vec::new()
} }
@@ -1052,6 +1107,7 @@ impl App {
text: crate::t!("dsl.running", input = input), text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo, kind: OutputKind::Echo,
mode_at_submission: submission_mode, mode_at_submission: submission_mode,
styled_runs: None,
}); });
vec![Action::Replay { path }] vec![Action::Replay { path }]
} }
@@ -1060,6 +1116,7 @@ impl App {
text: crate::t!("dsl.running", input = input), text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo, kind: OutputKind::Echo,
mode_at_submission: submission_mode, mode_at_submission: submission_mode,
styled_runs: None,
}); });
vec![Action::ExecuteDsl { vec![Action::ExecuteDsl {
command: cmd, command: cmd,
@@ -1074,6 +1131,7 @@ impl App {
text: crate::t!("dsl.running", input = input), text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo, kind: OutputKind::Echo,
mode_at_submission: submission_mode, mode_at_submission: submission_mode,
styled_runs: None,
}); });
// Caret pointer at the failure position, when we // Caret pointer at the failure position, when we
// have one. Aligned to the "running: " prefix so // have one. Aligned to the "running: " prefix so
@@ -1754,6 +1812,7 @@ impl App {
text, text,
kind, kind,
mode_at_submission: self.mode, mode_at_submission: self.mode,
styled_runs: None,
}); });
return; return;
} }
@@ -1762,6 +1821,7 @@ impl App {
text: line.to_string(), text: line.to_string(),
kind, kind,
mode_at_submission: self.mode, mode_at_submission: self.mode,
styled_runs: None,
}); });
} }
} }
+9 -1
View File
@@ -40,8 +40,14 @@ pub struct Theme {
pub error: Color, pub error: Color,
/// Validity-indicator WARNING colour (ADR-0027 §4) — an /// Validity-indicator WARNING colour (ADR-0027 §4) — an
/// amber distinct from `error`'s red. Drives the `[WRN]` /// amber distinct from `error`'s red. Drives the `[WRN]`
/// label; `[ERR]` reuses `error`. /// label; `[ERR]` reuses `error`. Also the query-plan
/// "expensive step" colour (ADR-0028 §6).
pub warning: Color, pub warning: Color,
/// Query-plan "efficient step" colour (ADR-0028 §6) — a
/// green deliberately distinct from `system` so green never
/// reads as two things. Indexed lookups carry it; expensive
/// steps reuse `warning`.
pub plan_efficient: Color,
// ---- Per-token-class colours (ADR-0022 §3) ------------------- // ---- Per-token-class colours (ADR-0022 §3) -------------------
pub tok_keyword: Color, pub tok_keyword: Color,
pub tok_identifier: Color, pub tok_identifier: Color,
@@ -67,6 +73,7 @@ impl Theme {
system: Color::Rgb(0x9F, 0xD8, 0x91), system: Color::Rgb(0x9F, 0xD8, 0x91),
error: Color::Rgb(0xFF, 0x6B, 0x6B), error: Color::Rgb(0xFF, 0x6B, 0x6B),
warning: Color::Rgb(0xF5, 0xA9, 0x4B), // amber warning: Color::Rgb(0xF5, 0xA9, 0x4B), // amber
plan_efficient: Color::Rgb(0x4D, 0xD0, 0xA8), // teal-green
// Token classes — distinct enough to tell apart at a // Token classes — distinct enough to tell apart at a
// glance, quiet enough that 80-char lines don't read // glance, quiet enough that 80-char lines don't read
@@ -99,6 +106,7 @@ impl Theme {
system: Color::Rgb(0x2E, 0x7C, 0x3C), system: Color::Rgb(0x2E, 0x7C, 0x3C),
error: Color::Rgb(0xC0, 0x39, 0x2B), error: Color::Rgb(0xC0, 0x39, 0x2B),
warning: Color::Rgb(0xA6, 0x5A, 0x00), // burnt amber warning: Color::Rgb(0xA6, 0x5A, 0x00), // burnt amber
plan_efficient: Color::Rgb(0x0B, 0x80, 0x6A), // deep teal-green
// Light-theme token palette: same intent as dark — // Light-theme token palette: same intent as dark —
// identifier/punct close to fg/muted; warm tones for // identifier/punct close to fg/muted; warm tones for
+83 -2
View File
@@ -12,7 +12,7 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap};
use crate::app::{App, EffectiveMode, OutputKind, OutputLine}; use crate::app::{App, EffectiveMode, OutputKind, OutputLine, OutputStyleClass};
use crate::mode::Mode; use crate::mode::Mode;
use crate::theme::Theme; use crate::theme::Theme;
@@ -532,6 +532,19 @@ fn approximate_wrapped_rows_from_output(
.sum() .sum()
} }
/// Resolve a semantic [`OutputStyleClass`] to a concrete style
/// against the active theme (ADR-0028 §5/§6).
const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style {
match class {
OutputStyleClass::Neutral => Style::new().fg(theme.fg),
OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient),
OutputStyleClass::Expensive => Style::new().fg(theme.warning),
OutputStyleClass::AutomaticIndex => Style::new()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
}
}
fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> { fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
let tag_style = match line.mode_at_submission { let tag_style = match line.mode_at_submission {
Mode::Simple => Style::default().fg(theme.mode_simple), Mode::Simple => Style::default().fg(theme.mode_simple),
@@ -569,6 +582,24 @@ fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
// Echo body without the expected prefix, or any non-echo // Echo body without the expected prefix, or any non-echo
// line, falls through to the plain rendering below. // line, falls through to the plain rendering below.
// ADR-0028 §5: a line carrying a styled-runs payload is
// rendered span-by-span, each run's semantic class resolved
// to a colour from the active theme. The tag keeps its
// kind styling. (Echo lines never carry runs, so this never
// collides with the branch above.)
if let Some(runs) = &line.styled_runs {
let mut spans: Vec<Span<'a>> = Vec::with_capacity(runs.len() + 1);
spans.push(Span::styled(tag, tag_style));
for run in runs {
let (start, end) = run.byte_range;
spans.push(Span::styled(
&line.text[start..end],
output_span_style(run.class, theme),
));
}
return Line::from(spans);
}
let body_style = match line.kind { let body_style = match line.kind {
OutputKind::Echo => Style::default().fg(theme.fg), OutputKind::Echo => Style::default().fg(theme.fg),
OutputKind::System => Style::default().fg(theme.system), OutputKind::System => Style::default().fg(theme.system),
@@ -920,10 +951,56 @@ fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::app::App; use crate::app::{App, OutputSpan};
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::TestBackend; use ratatui::backend::TestBackend;
#[test]
fn styled_runs_payload_renders_each_span_with_its_class_colour() {
// ADR-0028 §5: an OutputLine carrying a styled-runs
// payload renders span-by-span, each run's semantic
// class resolved to a theme colour.
let theme = Theme::dark();
let line = OutputLine::styled(
"SCAN Customers".to_string(),
OutputKind::System,
Mode::Simple,
vec![
OutputSpan {
byte_range: (0, 4),
class: OutputStyleClass::Expensive,
},
OutputSpan {
byte_range: (4, 14),
class: OutputStyleClass::Neutral,
},
],
);
let rendered = render_output_line(&line, &theme);
// tag span + 2 run spans.
assert_eq!(rendered.spans.len(), 3);
assert_eq!(rendered.spans[1].content.as_ref(), "SCAN");
assert_eq!(rendered.spans[1].style.fg, Some(theme.warning));
assert_eq!(rendered.spans[2].content.as_ref(), " Customers");
assert_eq!(rendered.spans[2].style.fg, Some(theme.fg));
}
#[test]
fn a_line_without_styled_runs_keeps_whole_line_kind_styling() {
let theme = Theme::dark();
let line = OutputLine {
text: "plain system line".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
};
let rendered = render_output_line(&line, &theme);
// tag span + single whole-line body span.
assert_eq!(rendered.spans.len(), 2);
assert_eq!(rendered.spans[1].content.as_ref(), "plain system line");
assert_eq!(rendered.spans[1].style.fg, Some(theme.system));
}
fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String { fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
// Snapshot tests need realistic state, not the boot // Snapshot tests need realistic state, not the boot
// fallback "(no project)" — every real session has a // fallback "(no project)" — every real session has a
@@ -1057,21 +1134,25 @@ mod tests {
text: "[ok] create table Customers".to_string(), text: "[ok] create table Customers".to_string(),
kind: OutputKind::System, kind: OutputKind::System,
mode_at_submission: Mode::Simple, mode_at_submission: Mode::Simple,
styled_runs: None,
}); });
app.output.push_back(OutputLine { app.output.push_back(OutputLine {
text: " Customers".to_string(), text: " Customers".to_string(),
kind: OutputKind::System, kind: OutputKind::System,
mode_at_submission: Mode::Simple, mode_at_submission: Mode::Simple,
styled_runs: None,
}); });
app.output.push_back(OutputLine { app.output.push_back(OutputLine {
text: " id serial [PK]".to_string(), text: " id serial [PK]".to_string(),
kind: OutputKind::System, kind: OutputKind::System,
mode_at_submission: Mode::Simple, mode_at_submission: Mode::Simple,
styled_runs: None,
}); });
app.output.push_back(OutputLine { app.output.push_back(OutputLine {
text: " Name text".to_string(), text: " Name text".to_string(),
kind: OutputKind::System, kind: OutputKind::System,
mode_at_submission: Mode::Simple, mode_at_submission: Mode::Simple,
styled_runs: None,
}); });
let theme = Theme::dark(); let theme = Theme::dark();