feat(tui): reasoning cells get dashed rail, italic body, warm tint

Sub-area #2 of the v0.6.6 UI redesign (issue #121).

Reasoning is the only deliberately-warm element in the redesigned
transcript. The treatment makes that visible:

- Header opener becomes `…` (slow exhale) instead of the spinner glyph
- Body left rail switches from solid `▏` to dashed `╎` so it visibly
  differs from message body and tool output rails
- Body text carries an italic modifier
- Body lines tint with `palette::reasoning_surface_tint(depth)` — 12%
  blend of SURFACE_REASONING (#362C1A) over DEEPSEEK_INK. ANSI-16
  terminals get no bg (the named-color mapping would distort the warm)
- A trailing `▎` cursor in ACCENT_REASONING_LIVE follows the most recent
  body line during streaming, suppressed under low_motion

Wires up palette helpers from the prior commit: `ColorDepth::detect`,
`reasoning_surface_tint`, `blend`. SURFACE_REASONING is no longer
dead-coded. The unused `thinking_symbol` helper is removed since the new
header doesn't spin.

Tests: dashed rail and italic body land on every body line; streaming
cursor appears only when motion is allowed; collapsed-summary affordance
keeps working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-27 20:52:59 -05:00
parent 5b7ff8cb69
commit 2b0e73a4cf
2 changed files with 137 additions and 27 deletions
-4
View File
@@ -71,7 +71,6 @@ pub const STATUS_NEUTRAL: Color = Color::Rgb(160, 160, 160); // #A0A0A0
pub const SURFACE_PANEL: Color = Color::Rgb(21, 33, 52); // #152134
#[allow(dead_code)]
pub const SURFACE_ELEVATED: Color = Color::Rgb(28, 42, 64); // #1C2A40
#[allow(dead_code)]
pub const SURFACE_REASONING: Color = Color::Rgb(54, 44, 26); // #362C1A
#[allow(dead_code)]
pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(68, 53, 28); // #44351C
@@ -136,7 +135,6 @@ pub enum ColorDepth {
impl ColorDepth {
/// Detect the active terminal's color depth. Honors `COLORTERM`
#[allow(dead_code)]
/// (truecolor / 24bit) first, then falls back to `TERM`. Defaults to
/// `TrueColor` because most modern terminals support it; the conservative
/// fallback is `Ansi16` so background tints disappear safely.
@@ -191,7 +189,6 @@ pub fn adapt_bg(color: Color, depth: ColorDepth) -> Color {
}
/// Mix two RGB colors at `alpha` (0.0 = `bg`, 1.0 = `fg`). Anything that's not
#[allow(dead_code)]
/// RGB falls back to `fg` — there's no meaningful alpha blend on a named
/// palette entry.
#[must_use]
@@ -211,7 +208,6 @@ pub fn blend(fg: Color, bg: Color, alpha: f32) -> Color {
}
/// Return the reasoning surface color tinted at 12% over the app background.
#[allow(dead_code)]
/// This is the headline reasoning treatment in v0.6.6; a 12% blend keeps the
/// warm bias subtle without competing with body text. Returns `None` when the
/// terminal can't render the bg faithfully.
+137 -23
View File
@@ -33,6 +33,15 @@ const USER_GLYPH: &str = "\u{258E}"; // ▎
/// Visual marker for the assistant role. Solid bullet that pulses at 2s
/// cycle while the response is streaming, holds full brightness when idle.
const ASSISTANT_GLYPH: &str = "\u{25CF}"; // ●
/// Reasoning header opener. Replaces the spinner glyph on thinking cells —
/// reasoning is a slow exhale, not a tool spin.
const REASONING_OPENER: &str = "\u{2026}"; // …
/// Reasoning body left rail. Dashed (`╎`) instead of the solid `▏` block to
/// visually separate reasoning from message body and tool output.
const REASONING_RAIL: &str = "\u{254E} "; // ╎ + space
/// Trailing-line cursor on streaming reasoning. Anchored to the live colour
/// so the user sees where new tokens land.
const REASONING_CURSOR: &str = "\u{258E}"; // ▎
const TOOL_CARD_SUMMARY_LINES: usize = 4;
const THINKING_SUMMARY_LINE_LIMIT: usize = 4;
const TOOL_DONE_SYMBOL: &str = "";
@@ -1316,10 +1325,22 @@ fn render_thinking(
) -> Vec<Line<'static>> {
let state = thinking_visual_state(streaming, duration_secs);
let style = thinking_style();
// 12% reasoning surface tint over the app ink — the only deliberately
// warm element in the transcript. Dropped on Ansi-16 terminals where the
// tint would distort the named palette.
let depth = palette::ColorDepth::detect();
let body_bg = palette::reasoning_surface_tint(depth);
let body_style = match body_bg {
Some(bg) => style.italic().bg(bg),
None => style.italic(),
};
let mut lines = Vec::new();
// Header: `…` opener (replaces the spinner; reasoning isn't a tool, it's
// a slow exhale) followed by the `thinking` label and live status.
let mut header_spans = vec![
Span::styled(
format!("{} ", thinking_symbol(state, low_motion)),
format!("{REASONING_OPENER} "),
Style::default().fg(thinking_state_accent(state)),
),
Span::styled("thinking", thinking_title_style()),
@@ -1341,32 +1362,49 @@ fn render_thinking(
} else {
content.to_string()
};
let mut rendered = markdown_render::render_markdown(&body_text, content_width, style);
let mut rendered = markdown_render::render_markdown(&body_text, content_width, body_style);
let mut truncated = false;
if collapsed && rendered.len() > THINKING_SUMMARY_LINE_LIMIT {
rendered.truncate(THINKING_SUMMARY_LINE_LIMIT);
truncated = true;
}
let rail_style = Style::default().fg(thinking_state_accent(state));
let cursor_style = Style::default().fg(palette::ACCENT_REASONING_LIVE);
if rendered.is_empty() && streaming {
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(thinking_state_accent(state))),
Span::styled("reasoning in progress...", style.italic()),
]));
let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)];
spans.push(Span::styled(
"reasoning in progress...",
body_style.italic(),
));
if !low_motion {
spans.push(Span::styled(
format!(" {REASONING_CURSOR}"),
cursor_style,
));
}
lines.push(Line::from(spans));
}
for line in rendered {
let mut spans = vec![Span::styled(
"",
Style::default().fg(thinking_state_accent(state)),
)];
let last_idx = rendered.len().saturating_sub(1);
for (idx, line) in rendered.into_iter().enumerate() {
let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)];
spans.extend(line.spans);
// Trailing cursor on the very last body line while streaming —
// signals "still generating" without churning every line.
if streaming && !low_motion && idx == last_idx {
spans.push(Span::styled(
format!(" {REASONING_CURSOR}"),
cursor_style,
));
}
lines.push(Line::from(spans));
}
if collapsed && (!streaming && (truncated || body_text.trim() != content.trim())) {
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(thinking_state_accent(state))),
Span::styled(REASONING_RAIL.to_string(), rail_style),
Span::styled(
"thinking collapsed; press Ctrl+O for full text",
Style::default().fg(palette::TEXT_MUTED).italic(),
@@ -1835,14 +1873,6 @@ fn thinking_status_label(state: ThinkingVisualState) -> &'static str {
}
}
fn thinking_symbol(state: ThinkingVisualState, low_motion: bool) -> String {
match state {
ThinkingVisualState::Live => status_symbol(None, ToolStatus::Running, low_motion),
ThinkingVisualState::Done => "".to_string(),
ThinkingVisualState::Idle => "·".to_string(),
}
}
fn thinking_title_style() -> Style {
Style::default()
.fg(palette::TEXT_SOFT)
@@ -1873,9 +1903,10 @@ fn thinking_state_accent(state: ThinkingVisualState) -> Color {
mod tests {
use super::{
ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, PlanStep,
PlanUpdateCell, TOOL_RUNNING_SYMBOLS, TOOL_STATUS_SYMBOL_MS, ToolCell, ToolStatus,
TranscriptRenderOptions, USER_GLYPH, assistant_label_style_for, extract_reasoning_summary,
render_thinking, running_status_label_with_elapsed,
PlanUpdateCell, REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, TOOL_RUNNING_SYMBOLS,
TOOL_STATUS_SYMBOL_MS, ToolCell, ToolStatus, TranscriptRenderOptions, USER_GLYPH,
assistant_label_style_for, extract_reasoning_summary, render_thinking,
running_status_label_with_elapsed,
};
use crate::palette;
use crate::deepseek_theme::Theme;
@@ -2054,6 +2085,89 @@ mod tests {
);
}
// === Reasoning treatment tests (v0.6.6 UI redesign) ===
#[test]
fn render_thinking_uses_dotted_opener_in_header() {
let lines = render_thinking("Step one\nStep two", 80, false, Some(2.0), false, true);
let header = &lines[0];
// First span carries `…` followed by a space.
assert!(
header.spans[0].content.starts_with(REASONING_OPENER),
"header opener: {:?}",
header.spans[0].content
);
}
#[test]
fn render_thinking_body_lines_use_dashed_rail_and_italic() {
let lines = render_thinking(
"concrete reasoning content",
80,
/*streaming*/ false,
Some(1.0),
/*collapsed*/ false,
/*low_motion*/ true,
);
// Header is index 0; first body line is index 1.
assert!(lines.len() >= 2, "expected at least one body line");
let body = &lines[1];
assert_eq!(
body.spans[0].content.as_ref(),
REASONING_RAIL,
"body rail must be the dashed `╎ ` glyph"
);
// The body span should carry italic.
let italic_seen = body
.spans
.iter()
.skip(1)
.any(|span| span.style.add_modifier.contains(Modifier::ITALIC));
assert!(italic_seen, "body content should carry italic modifier");
}
#[test]
fn render_thinking_streaming_appends_cursor_when_motion_allowed() {
let lines = render_thinking(
"ongoing reasoning...",
80,
/*streaming*/ true,
None,
/*collapsed*/ false,
/*low_motion*/ false,
);
// Last line is the most recent body line — cursor lives there.
let last = lines.last().expect("body line present");
let last_span = last.spans.last().expect("trailing span present");
assert!(
last_span.content.contains(REASONING_CURSOR),
"expected trailing cursor `▎` on last streaming body line, got {:?}",
last_span.content
);
}
#[test]
fn render_thinking_streaming_omits_cursor_when_low_motion() {
let lines = render_thinking(
"ongoing reasoning...",
80,
/*streaming*/ true,
None,
/*collapsed*/ false,
/*low_motion*/ true,
);
let last = lines.last().expect("body line present");
let visible: String = last
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(
!visible.contains(REASONING_CURSOR),
"low_motion must suppress the streaming cursor: {visible:?}"
);
}
// === Theme parity tests ===
//
// These lock the visible color/style choices for one plan cell and one