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
+83 -2
View File
@@ -12,7 +12,7 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
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::theme::Theme;
@@ -532,6 +532,19 @@ fn approximate_wrapped_rows_from_output(
.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> {
let tag_style = match line.mode_at_submission {
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
// 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 {
OutputKind::Echo => Style::default().fg(theme.fg),
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)]
mod tests {
use super::*;
use crate::app::App;
use crate::app::{App, OutputSpan};
use ratatui::Terminal;
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 {
// Snapshot tests need realistic state, not the boot
// fallback "(no project)" — every real session has a
@@ -1057,21 +1134,25 @@ mod tests {
text: "[ok] create table Customers".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
});
app.output.push_back(OutputLine {
text: " Customers".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
});
app.output.push_back(OutputLine {
text: " id serial [PK]".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
});
app.output.push_back(OutputLine {
text: " Name text".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
});
let theme = Theme::dark();