feat(tui): brighten ocean and animate working status

Update the default ocean palette and reuse the ratatui motion primitive for a restrained working sweep when terminal capabilities and motion preferences allow it. Parsed Mac PTY frames showed the sweep across 18 working samples and static idle and settled samples; physical-terminal appearance remains unqualified.
This commit is contained in:
CodeWhale Bot committed 2026-11-06 21:09:05 -08:00
1 parent e7f2844049
commit 812f8c7c47
8 files changed
+267 -13

No files matched your search

+2
View File
@@ -1020,6 +1020,7 @@ impl Default for ComposerState {
/// Viewport/scroll state — fields related to transcript scrolling and caching.
pub struct ViewportState {
pub(crate) working_glow_visible: bool,
/// Per-draw copy of the owning backend's negotiated capability facts.
/// Unavailable outside a backend draw; never redetect or infer depth.
pub(crate) ocean_caps: Option<codewhale_ratatui::Caps>,
@@ -1095,6 +1096,7 @@ pub struct ViewportState {
impl Default for ViewportState {
fn default() -> Self {
Self {
working_glow_visible: false,
ocean_caps: None,
ocean_semantic_surfaces: Vec::new(),
transcript_scroll: TranscriptScroll::to_bottom(),
+16 -3
View File
@@ -442,15 +442,28 @@ impl OceanRamp {
return None;
}
let default = *theme == codewhale_palette::UNDERWATER_UI_THEME;
Some(Self {
// The authored Codewhale water column: unmistakably blue all the
// way to the floor. These restrained ocean shades sit between the
// shell's ink surfaces and its ambient blue, so the field gains
// depth without becoming a saturated blue panel.
surface: NativeRamp::SURFACE,
middle: NativeRamp::MIDDLE,
surface: if default {
Color::Rgb(0x0b, 0x2c, 0x53)
} else {
NativeRamp::SURFACE
},
middle: if default {
Color::Rgb(0x09, 0x22, 0x41)
} else {
NativeRamp::MIDDLE
},
deep: NativeRamp::DEEP,
ambient: NativeRamp::AMBIENT,
ambient: if default {
Color::Rgb(0x2a, 0x5e, 0x91)
} else {
NativeRamp::AMBIENT
},
attention: theme.warning,
failure: theme.error_fg,
})
+2 -2
View File
@@ -34,8 +34,8 @@ fn contrast_ratio(foreground: Color, background: Color) -> f64 {
fn whale_ramp_is_perceptibly_deep_not_merely_non_equal() {
let ramp =
OceanRamp::for_theme(&codewhale_palette::UNDERWATER_UI_THEME).expect("underwater ramp");
assert_eq!(ramp.surface, Color::Rgb(0x10, 0x2a, 0x45));
assert_eq!(ramp.middle, Color::Rgb(0x0a, 0x1e, 0x33));
assert_eq!(ramp.surface, Color::Rgb(0x0b, 0x2c, 0x53));
assert_eq!(ramp.middle, Color::Rgb(0x09, 0x22, 0x41));
assert_eq!(ramp.deep, Color::Rgb(0x06, 0x13, 0x20));
assert!(
distance(ramp.surface, ramp.deep) >= 32,
+42
View File
@@ -686,6 +686,11 @@ pub struct TidelineFooter<'a> {
/// the permission and mode chips, and the cap warning when it is owed —
/// and nothing live. Width sheds the rest exactly as it always did.
pub compact: bool,
working_glow: Option<(
codewhale_ratatui::Caps,
std::time::Duration,
codewhale_ratatui::MotionMode,
)>,
}
impl<'a> TidelineFooter<'a> {
@@ -708,6 +713,7 @@ impl<'a> TidelineFooter<'a> {
right: None,
ascii_safe: false,
compact: false,
working_glow: None,
}
}
@@ -777,6 +783,24 @@ impl<'a> TidelineFooter<'a> {
self
}
pub(crate) fn working_glow(
mut self,
glow: Option<(
codewhale_ratatui::Caps,
std::time::Duration,
codewhale_ratatui::MotionMode,
)>,
) -> Self {
self.working_glow = glow;
self
}
pub(crate) fn working_clock_visible(&self, area: Rect) -> bool {
self.kit()
.turn_clock_hitbox(area, &crate::tui::infoline::source_theme(self.ascii_safe))
.is_some()
}
fn kit(&self) -> PostureBar<'_> {
use crate::tui::infoline::ink_role;
// Preserve every host ink in a distinct role, including the right
@@ -829,6 +853,24 @@ pub fn render_tideline_footer(
let bar = footer.kit();
let count_rects = bar.count_hitboxes(area, &source_theme(footer.ascii_safe));
paint_native_row(&bar, area, buf, footer.theme, footer.ascii_safe);
if let Some((mut caps, elapsed, motion)) = footer.working_glow
&& let Some((label, _)) = footer.turn_clock
&& let Some(clock_area) = bar.turn_clock_hitbox(area, &source_theme(footer.ascii_safe))
&& let Some(appearance) =
codewhale_ratatui::detect::appearance_for_background(footer.theme.footer_bg)
{
caps.appearance = appearance;
let theme = codewhale_ratatui::Theme::new(caps);
codewhale_ratatui::paint_working_glow(
buf,
clock_area,
label,
elapsed,
codewhale_ratatui::MotionPolicy::new(motion, &theme),
footer.theme.text_body,
footer.theme.footer_bg,
);
}
count_rects
}
+89 -1
View File
@@ -524,6 +524,65 @@ fn render_info_row(
.style(Style::default().bg(app.ui_theme.header_bg))
.render(area, buf);
ratatui::widgets::Widget::render(info, area, buf);
if !identity_only
&& !app.viewport.working_glow_visible
&& app.posture_bar == codewhale_runtime::config::ChromeRowPreset::Full
&& !crate::tui::pet_watch::main_view(app)
&& !app.onboarding_needs_api_key
&& hovered != Some(InfoSegmentId::Model)
&& !app.last_mouse_pos.is_some_and(|(x, y)| {
route_area.is_some_and(|route| route.x <= x && x < route.right() && route.y == y)
})
&& app.motion_policy().should_request_animation_frames()
&& matches!(
crate::tui::underwater::ShellPhase::from_app(app),
crate::tui::underwater::ShellPhase::Working
)
&& matches!(
crate::tui::underwater::LiveActivity::from_app(app).kind(),
crate::tui::underwater::LiveActivityKind::Working
| crate::tui::underwater::LiveActivityKind::Reasoning
)
&& let Some((mut caps, started)) = app.viewport.ocean_caps.zip(app.turn_started_at)
&& caps.depth == codewhale_ratatui::color::ColorDepth::TrueColor
&& let Some(appearance) =
codewhale_ratatui::detect::appearance_for_background(app.ui_theme.header_bg)
&& app.ui_theme.header_bg != app.ui_theme.surface_bg
&& matches!(app.ui_theme.status_working, ratatui::style::Color::Rgb(..))
&& let Some(model_area) = model_area
&& let Some(model) = match route_fields.as_deref() {
Some(fields) => fields
.iter()
.find(|field| field.kind == crate::tui::phase_strip::RouteFieldKind::Model)
.map(|field| field.text.as_str()),
None => segments
.iter()
.find(|segment| segment.id == InfoSegmentId::Model && segment.label.is_empty())
.map(|segment| segment.value.as_str()),
}
{
use unicode_width::UnicodeWidthStr as _;
let width = model.width().min(usize::from(model_area.width));
if width > 0 {
caps.appearance = appearance;
let theme = codewhale_ratatui::Theme::new(caps);
let mode =
codewhale_ratatui::MotionMode::from_settings(app.low_motion, app.fancy_animations);
codewhale_ratatui::paint_working_glow(
buf,
Rect {
width: width as u16,
..model_area
},
model,
started.elapsed(),
codewhale_ratatui::MotionPolicy::new(mode, &theme),
app.ui_theme.status_working,
app.ui_theme.header_bg,
);
app.viewport.working_glow_visible = true;
}
}
app.viewport.last_infoline_hitboxes = hitboxes;
interaction_hitboxes
}
@@ -1562,6 +1621,7 @@ pub(crate) fn build_pending_input_preview(app: &App) -> PendingInputPreview {
}
pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(u16, u16)> {
app.viewport.working_glow_visible = false;
app.viewport.ocean_semantic_surfaces.clear();
let size = f.area();
// Hover targets belong to the whole composed frame. Resetting inside the
@@ -2041,12 +2101,40 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
facts.hint = None;
}
}
let working_glow = if matches!(
crate::tui::underwater::ShellPhase::from_app(app),
crate::tui::underwater::ShellPhase::Working
| crate::tui::underwater::ShellPhase::Verifying
) {
app.viewport
.ocean_caps
.zip(app.turn_started_at)
.map(|(caps, started)| {
(
caps,
started.elapsed(),
codewhale_ratatui::MotionMode::from_settings(
app.low_motion,
app.fancy_animations,
),
)
})
} else {
None
};
let footer = facts
.widget(
&app.ui_theme,
crate::tui::color_compat::ascii_safe_enabled(),
)
.compact(app.posture_bar == codewhale_runtime::config::ChromeRowPreset::Compact);
.compact(app.posture_bar == codewhale_runtime::config::ChromeRowPreset::Compact)
.working_glow(working_glow);
app.viewport.working_glow_visible = footer.working_clock_visible(area)
&& working_glow.is_some_and(|(caps, _, mode)| {
caps.depth == codewhale_ratatui::color::ColorDepth::TrueColor && mode.animates()
})
&& matches!(app.ui_theme.footer_bg, ratatui::style::Color::Rgb(..))
&& matches!(app.ui_theme.text_body, ratatui::style::Color::Rgb(..));
let buf = f.buffer_mut();
Block::default()
.style(Style::default().bg(app.ui_theme.footer_bg))
+19
View File
@@ -155,6 +155,23 @@ pub(crate) fn rail_min_chat_width(idle_empty: bool) -> u16 {
}
}
fn working_clock_has_glow(app: &App) -> bool {
app.motion_policy().should_request_animation_frames()
&& app.viewport.working_glow_visible
&& app.posture_bar == codewhale_runtime::config::ChromeRowPreset::Full
&& !crate::tui::pet_watch::main_view(app)
&& app.turn_started_at.is_some()
&& app
.viewport
.ocean_caps
.is_some_and(|caps| caps.depth == codewhale_ratatui::color::ColorDepth::TrueColor)
&& matches!(
crate::tui::underwater::ShellPhase::from_app(app),
crate::tui::underwater::ShellPhase::Working
| crate::tui::underwater::ShellPhase::Verifying
)
}
pub(crate) fn status_animation_interval_ms(app: &App) -> u64 {
if app.effective_low_motion_for_status() {
crate::tui::display_refresh::adaptive_animation_interval_ms(true)
@@ -162,6 +179,7 @@ pub(crate) fn status_animation_interval_ms(app: &App) -> u64 {
|| !(app.is_loading
|| app.is_compacting
|| app.is_purging
|| working_clock_has_glow(app)
|| crate::tui::subagent_routing::running_agent_count(app) > 0)
{
UI_QUIET_STATUS_ANIMATION_MS
@@ -249,6 +267,7 @@ pub(crate) fn should_tick_status_animation(
) -> bool {
!matches!(app.motion_policy().mode(), MotionMode::Still)
&& (app.is_loading
|| working_clock_has_glow(app)
|| has_running_agents
|| app.is_compacting
|| app.is_purging
+68 -6
View File
@@ -1,9 +1,5 @@
//! Motion: the few things that move, and the promise that nothing else does.
//!
//! Motion is allowed for a change of state or for spatial continuity, and
//! nothing else: no sweeps, dissolves, glitches or bounces. This module is
//! that layer in a few hundred lines, instead of a dependency.
//!
//! - A [`MotionStep`] eases a value from where it is to a target in `0..=1`,
//! sampled at an instant the caller passes in. Nothing here reads a clock,
//! spawns a thread or waits.
@@ -23,11 +19,16 @@
use std::borrow::Cow;
use std::time::{Duration, Instant};
use ratatui::{buffer::Buffer, layout::Rect, style::Style};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
};
use unicode_segmentation::UnicodeSegmentation;
use crate::{
MotionMode, Paint, Role, Theme,
color::{ColorDepth, blend},
color::{ColorDepth, blend, contrast_ratio},
glyphs, text, tokens,
};
@@ -173,6 +174,67 @@ fn can_blend(theme: &Theme) -> bool {
theme.paints_grounds() && theme.depth() == ColorDepth::TrueColor
}
pub fn paint_working_glow(
buf: &mut Buffer,
area: Rect,
label: &str,
elapsed: Duration,
policy: MotionPolicy,
highlight: Color,
background: Color,
) {
if !policy.allows(MotionChannel::Tint)
|| elapsed < crate::spin::EARN_DELAY
|| !matches!(highlight, Color::Rgb(..))
|| !matches!(background, Color::Rgb(..))
{
return;
}
let area = area.intersection(buf.area);
if area.is_empty() {
return;
}
let label = text::display_safe(label);
let width = text::width(&label).min(usize::from(area.width));
if width == 0 {
return;
}
let cycle = tokens::MOTION_LOOP_BREATHE_MS as u128;
let phase = (elapsed - crate::spin::EARN_DELAY).as_millis() % cycle;
let center = phase as f32 / cycle as f32 * (width as f32 + 4.0) - 2.0;
let mut offset = 0usize;
for grapheme in label.graphemes(true) {
let cells = text::width(grapheme);
if cells == 0 {
continue;
}
if offset + cells > width {
break;
}
let cell = &mut buf[(area.x + offset as u16, area.y)];
let distance = (offset as f32 + (cells - 1) as f32 / 2.0 - center).abs();
let strength = (1.0 - distance / 2.5).max(0.0) * 0.45;
if strength > 0.0
&& cell.symbol() == grapheme
&& !grapheme.chars().all(char::is_whitespace)
&& matches!(cell.fg, Color::Rgb(..))
{
let ground = match cell.bg {
Color::Rgb(..) => cell.bg,
_ => background,
};
let ink = blend(highlight, cell.fg, strength);
if let (Some(before), Some(after)) =
(contrast_ratio(cell.fg, ground), contrast_ratio(ink, ground))
&& after >= before.min(4.5)
{
cell.fg = ink;
}
}
offset += cells;
}
}
/// One value easing from where it is to a target, both in `0..=1`.
///
/// `0.0` is the start state (the `from` role, the home column, nothing
+29 -1
View File
@@ -202,6 +202,7 @@ impl<'a> PostureBar<'a> {
bold: true,
joined: false,
count_index: None,
turn_clock: false,
}];
if let Some(mode) = self.mode.as_ref().filter(|_| shed < SHED_MODE) {
items.push(PostureItem {
@@ -216,6 +217,7 @@ impl<'a> PostureBar<'a> {
bold: false,
joined: false,
count_index: None,
turn_clock: false,
});
}
// Preserve the native turn-only clock rung: removing the session
@@ -226,7 +228,9 @@ impl<'a> PostureBar<'a> {
SHED_TURN_CLOCK
};
if let Some(clock) = self.turn_clock.as_ref().filter(|_| shed < turn_shed) {
items.push(PostureItem::fact(clock, ascii));
let mut item = PostureItem::fact(clock, ascii);
item.turn_clock = true;
items.push(item);
}
if shed < SHED_COUNTS {
for (index, count) in self.counts.iter().enumerate() {
@@ -243,6 +247,7 @@ impl<'a> PostureBar<'a> {
bold: false,
joined: index > 0,
count_index: Some(index),
turn_clock: false,
});
}
}
@@ -312,6 +317,11 @@ impl<'a> PostureBar<'a> {
};
layout.count_hitboxes(theme.ascii())
}
#[must_use]
pub fn turn_clock_hitbox(&self, area: Rect, theme: &Theme) -> Option<Rect> {
self.layout(area, theme)?.turn_clock_hitbox(theme.ascii())
}
}
struct PostureItem {
@@ -322,6 +332,7 @@ struct PostureItem {
bold: bool,
joined: bool,
count_index: Option<usize>,
turn_clock: bool,
}
impl PostureItem {
@@ -334,6 +345,7 @@ impl PostureItem {
bold: false,
joined: false,
count_index: None,
turn_clock: false,
}
}
@@ -400,6 +412,22 @@ impl PostureLayout {
}
out
}
fn turn_clock_hitbox(&self, ascii: bool) -> Option<Rect> {
let mut x = usize::from(self.area.x);
for (index, item) in self.items.iter().enumerate() {
if index > 0 {
x += text::width(&project(item.separator(), ascii));
}
if item.turn_clock {
let width = text::width(&self.clip(x, &item.text));
return (width > 0 && x <= usize::from(u16::MAX))
.then(|| Rect::new(x as u16, self.area.y, width as u16, 1));
}
x += item.width();
}
None
}
}
impl Paint for PostureBar<'_> {