diff --git a/src/dsl/mod.rs b/src/dsl/mod.rs
index 83708bd..20c03d3 100644
--- a/src/dsl/mod.rs
+++ b/src/dsl/mod.rs
@@ -26,3 +26,31 @@ pub use command::{
pub use parser::{ParseError, parse_command};
pub use types::Type;
pub use value::Value;
+
+/// Prefix every echoed DSL command carries in the output
+/// panel — i.e. `[simple] running: ` reads as
+/// `[simple]` (tag) + `running: ` (this constant) + the
+/// user's input.
+///
+/// The catalog template `dsl.running` is contracted to equal
+/// `format!("{ECHO_PREFIX}{{input}}")`. The constant lives
+/// in code because the echo-line renderer (ADR-0022 §5)
+/// peels this prefix off and re-tokenises the rest for
+/// highlighting; a unit test (`echo_prefix_matches_catalog_template`)
+/// pins the binding.
+pub const ECHO_PREFIX: &str = "running: ";
+
+#[cfg(test)]
+mod tests {
+ use super::ECHO_PREFIX;
+
+ #[test]
+ fn echo_prefix_matches_catalog_template() {
+ // The catalog template `dsl.running` must produce
+ // `` so the echo-line renderer can
+ // peel the prefix and re-tokenise the rest. A
+ // translator changing the prefix breaks this test.
+ let rendered = crate::t!("dsl.running", input = "");
+ assert_eq!(rendered, ECHO_PREFIX);
+ }
+}
diff --git a/src/input_render.rs b/src/input_render.rs
index 5825b71..d950ec4 100644
--- a/src/input_render.rs
+++ b/src/input_render.rs
@@ -55,11 +55,20 @@ impl StyledRun {
/// the cursor at `cursor_byte` (clamped to `input.len()`).
#[must_use]
pub fn render_input_runs(input: &str, cursor_byte: usize, theme: &Theme) -> Vec {
- let mut runs = base_runs(input, theme);
+ let mut runs = lex_to_runs(input, theme);
inject_cursor(&mut runs, input, cursor_byte, theme);
runs
}
+/// Cursor-less variant: tokenises `input` into styled runs
+/// covering the full byte range, with no inverted cursor.
+/// Used by the echo-line renderer (ADR-0022 §5) where there's
+/// no cursor to show.
+#[must_use]
+pub fn lex_to_runs(input: &str, theme: &Theme) -> Vec {
+ base_runs(input, theme)
+}
+
fn base_runs(input: &str, theme: &Theme) -> Vec {
let tokens = lex(input);
let mut runs = Vec::with_capacity(tokens.len() * 2);
diff --git a/src/ui.rs b/src/ui.rs
index d705643..feab6f9 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -530,16 +530,43 @@ fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
Mode::Simple => Style::default().fg(theme.mode_simple),
Mode::Advanced => Style::default().fg(theme.mode_advanced),
};
- let body_style = match line.kind {
- OutputKind::Echo => Style::default().fg(theme.fg),
- OutputKind::System => Style::default().fg(theme.system),
- OutputKind::Error => Style::default().fg(theme.error),
- };
let tag = match line.kind {
OutputKind::Echo => format!("[{}] ", line.mode_at_submission.label().to_lowercase()),
OutputKind::System => "[system] ".to_string(),
OutputKind::Error => "[error] ".to_string(),
};
+
+ // Simple-mode echo lines get token-class highlighting on
+ // their input portion (ADR-0022 §5). Echo body shape is
+ // contracted to ``; the prefix is
+ // pinned to the catalog template by
+ // `dsl::tests::echo_prefix_matches_catalog_template`.
+ if line.kind == OutputKind::Echo
+ && line.mode_at_submission == Mode::Simple
+ && let Some(rest) = line.text.strip_prefix(crate::dsl::ECHO_PREFIX)
+ {
+ let mut spans: Vec> = Vec::with_capacity(2 + rest.len() / 4);
+ spans.push(Span::styled(tag, tag_style));
+ spans.push(Span::styled(
+ crate::dsl::ECHO_PREFIX,
+ Style::default().fg(theme.fg),
+ ));
+ for run in crate::input_render::lex_to_runs(rest, theme) {
+ spans.push(Span::styled(
+ &rest[run.byte_range.0..run.byte_range.1],
+ run.style,
+ ));
+ }
+ return Line::from(spans);
+ }
+ // Echo body without the expected prefix, or any non-echo
+ // line, falls through to the plain rendering below.
+
+ let body_style = match line.kind {
+ OutputKind::Echo => Style::default().fg(theme.fg),
+ OutputKind::System => Style::default().fg(theme.system),
+ OutputKind::Error => Style::default().fg(theme.error),
+ };
Line::from(vec![
Span::styled(tag, tag_style),
Span::styled(line.text.as_str(), body_style),