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:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user