feat(tui): replace literal speaker labels with calm glyphs

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

User cells now lead with `▎` (solid bar, no animation — input is finished).
Assistant cells lead with `●`. While streaming, the bullet pulses on a
2-second cycle between 30%..100% brightness via `palette::pulse_brightness`;
once a turn completes the bullet sits at full DEEPSEEK_SKY so finished
history reads as solid.

Honors `low_motion`: the pulse is suppressed and the glyph holds full
brightness regardless of streaming state. Pager / clipboard exports
(`transcript_lines`) also skip the pulse so screenshots are stable.

Existing pager titles in `ui.rs` (`history_cell_to_text`) keep the literal
"You" / "Assistant" wording — those drive the modal title bar and read
better as words than as glyphs.

Tests: glyphs replace the literal labels in both User and Assistant cells;
streaming pulse demonstrably dips below source brightness; idle and
low_motion both pin to source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-27 20:50:24 -05:00
parent 9358b92af5
commit 5b7ff8cb69
2 changed files with 148 additions and 16 deletions
-1
View File
@@ -224,7 +224,6 @@ pub fn reasoning_surface_tint(depth: ColorDepth) -> Option<Color> {
}
/// Pulse `color` between 30% and 100% brightness on a 2s cycle keyed off
#[allow(dead_code)]
/// `now_ms` (epoch ms). The minimum keeps the glyph readable at trough; the
/// maximum is the source color verbatim. Linear interpolation between them
/// reads as a slow heartbeat.
+148 -15
View File
@@ -27,6 +27,12 @@ const TOOL_RUNNING_SYMBOLS: [&str; 4] = ["·", "◦", "•", "◦"];
// ~2.88 s — fast enough that the user sees motion within a few hundred ms of
// starting a tool, slow enough to read as a pulse rather than a strobe.
const TOOL_STATUS_SYMBOL_MS: u64 = 720;
/// Visual marker for the user role at the start of their message line. Solid
/// vertical bar — no animation; user input is a finished thing.
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}"; // ●
const TOOL_CARD_SUMMARY_LINES: usize = 4;
const THINKING_SUMMARY_LINE_LIMIT: usize = 4;
const TOOL_DONE_SYMBOL: &str = "";
@@ -108,15 +114,15 @@ impl HistoryCell {
pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
match self {
HistoryCell::User { content } => render_message(
"You",
USER_GLYPH,
user_label_style(),
message_body_style(),
content,
width,
),
HistoryCell::Assistant { content, .. } => render_message(
"Assistant",
assistant_label_style(),
HistoryCell::Assistant { content, streaming } => render_message(
ASSISTANT_GLYPH,
assistant_label_style_for(*streaming, /*low_motion*/ false),
message_body_style(),
content,
width,
@@ -179,9 +185,21 @@ impl HistoryCell {
lines
}
HistoryCell::Tool(cell) => cell.lines_with_motion(width, options.low_motion),
HistoryCell::User { .. }
| HistoryCell::Assistant { .. }
| HistoryCell::System { .. } => self.lines(width),
HistoryCell::User { content } => render_message(
USER_GLYPH,
user_label_style(),
message_body_style(),
content,
width,
),
HistoryCell::Assistant { content, streaming } => render_message(
ASSISTANT_GLYPH,
assistant_label_style_for(*streaming, options.low_motion),
message_body_style(),
content,
width,
),
HistoryCell::System { .. } => self.lines(width),
}
}
@@ -195,9 +213,23 @@ impl HistoryCell {
/// diverge.
pub fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
match self {
HistoryCell::User { .. }
| HistoryCell::Assistant { .. }
| HistoryCell::System { .. } => self.lines(width),
HistoryCell::User { content } => render_message(
USER_GLYPH,
user_label_style(),
message_body_style(),
content,
width,
),
HistoryCell::Assistant { content, streaming } => render_message(
ASSISTANT_GLYPH,
// Pager / clipboard surface — pin the glyph at full
// brightness so a screenshot reads the same as a live frame.
assistant_label_style_for(*streaming, /*low_motion*/ true),
message_body_style(),
content,
width,
),
HistoryCell::System { .. } => self.lines(width),
HistoryCell::Thinking {
content,
streaming,
@@ -1647,8 +1679,22 @@ fn user_label_style() -> Style {
Style::default().fg(palette::TEXT_MUTED)
}
fn assistant_label_style() -> Style {
Style::default().fg(palette::DEEPSEEK_SKY)
/// Style for the assistant glyph (`●`). When the cell is streaming and
/// motion is allowed, the foreground pulses on a 2s cycle between 30% and
/// 100% brightness — the only deliberately animated element in a calm
/// transcript. When idle (or low_motion is on) it sits at the full DeepSeek
/// sky color so finished turns read as solid rather than dim.
fn assistant_label_style_for(streaming: bool, low_motion: bool) -> Style {
let color = if streaming && !low_motion {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
palette::pulse_brightness(palette::DEEPSEEK_SKY, now_ms)
} else {
palette::DEEPSEEK_SKY
};
Style::default().fg(color)
}
fn system_label_style() -> Style {
@@ -1826,10 +1872,12 @@ fn thinking_state_accent(state: ThinkingVisualState) -> Color {
#[cfg(test)]
mod tests {
use super::{
ExecCell, ExecSource, GenericToolCell, HistoryCell, PlanStep, PlanUpdateCell,
TOOL_RUNNING_SYMBOLS, TOOL_STATUS_SYMBOL_MS, ToolCell, ToolStatus, TranscriptRenderOptions,
extract_reasoning_summary, render_thinking, running_status_label_with_elapsed,
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,
};
use crate::palette;
use crate::deepseek_theme::Theme;
use ratatui::style::Modifier;
use std::time::{Duration, Instant};
@@ -1921,6 +1969,91 @@ mod tests {
assert_ne!(animated_symbol, TOOL_RUNNING_SYMBOLS[0]);
}
// === Speaker glyph tests (v0.6.6 UI redesign) ===
//
// The literal "Assistant" / "You" labels are replaced by the calmer
// bullet/bar glyphs (`●` / `▎`). Only the assistant glyph pulses, and
// only while the cell is streaming — finished turns sit at the source
// sky color so the transcript reads as solid history.
#[test]
fn user_cell_renders_with_bar_glyph_not_literal_label() {
let cell = HistoryCell::User {
content: "hello".to_string(),
};
let lines = cell.lines(80);
let head = &lines[0];
assert_eq!(head.spans[0].content.as_ref(), USER_GLYPH);
// No "You" literal anywhere in the rendered head line.
let visible: String = head
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(!visible.contains("You"), "user label dropped: {visible:?}");
assert!(visible.contains("hello"));
}
#[test]
fn assistant_cell_renders_with_bullet_glyph_not_literal_label() {
let cell = HistoryCell::Assistant {
content: "ready".to_string(),
streaming: false,
};
let lines = cell.lines(80);
let head = &lines[0];
assert_eq!(head.spans[0].content.as_ref(), ASSISTANT_GLYPH);
let visible: String = head
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(
!visible.contains("Assistant"),
"assistant label dropped: {visible:?}"
);
assert!(visible.contains("ready"));
}
#[test]
fn assistant_glyph_holds_full_brightness_when_idle() {
// Idle (streaming=false) and low_motion both pin the colour to the
// source sky — pulse only fires when actively streaming.
let idle = assistant_label_style_for(false, false);
let low_motion = assistant_label_style_for(true, true);
assert_eq!(idle.fg, Some(palette::DEEPSEEK_SKY));
assert_eq!(low_motion.fg, Some(palette::DEEPSEEK_SKY));
}
#[test]
fn assistant_glyph_pulses_when_streaming_and_motion_allowed() {
// The streaming path runs through `pulse_brightness`, which yields
// an RGB colour scaled within 30%..100% of the source. Sample twice
// — at least one of the samples must fall below 100% brightness, or
// the test wouldn't be exercising the pulse at all. (We can't pin
// the value because the function reads SystemTime::now().)
use ratatui::style::Color;
let mut saw_dimmed = false;
for _ in 0..50 {
if let Some(Color::Rgb(_, _, b)) =
assistant_label_style_for(true, false).fg
{
let Color::Rgb(_, _, src_b) = palette::DEEPSEEK_SKY else {
panic!("DEEPSEEK_SKY must be RGB");
};
if b < src_b {
saw_dimmed = true;
break;
}
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(
saw_dimmed,
"expected the streaming pulse to dip below source brightness at least once",
);
}
// === Theme parity tests ===
//
// These lock the visible color/style choices for one plan cell and one