mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
Make Underwater the fresh default and quiet the home surface
Preserve saved themes, remove empty recent chrome and secondary counts, and replace native fish/jelly silhouettes with cached dot poses using one continuous clock. Remove unused motion scheduling APIs. Add opt-in timestamped terminal motion capture. Validation: combined targeted TUI run 139 passed, 1 ignored, 1 stale MCP alias assertion failed; that unrelated assertion was corrected and rechecked 1/1 in preceding Extensions commit. All theme, home, motion and native-life cases passed. npm test: 551 passed, 0 failed. npm run check:web: passed (821 routes). Blocking budget: 603 sites/178 files passed. Live rebuilt-binary motion qualification remains pending.
This commit is contained in:
1 parent
9426d193ed
commit
ad20493c0e
14 files changed
+605
-244
No files matched your search
@@ -336,8 +336,8 @@ pub const LIGHT_UI_THEME: UiTheme = UiTheme {
|
||||
}
|
||||
.with_terminal_native_shell();
|
||||
|
||||
/// Shoreline — the default shell from 0.9.14, and the terminal's half of the
|
||||
/// product-client palette.
|
||||
/// Shoreline — the warm charcoal alternative and the terminal's half of the
|
||||
/// product-client palette. Fresh 0.10.0 terminal installs use Underwater.
|
||||
///
|
||||
/// The GPUI desktop window and this terminal now cite the same slots, so the
|
||||
/// two clients read as one product: a warm charcoal ground, one raised plate
|
||||
@@ -348,7 +348,7 @@ pub const LIGHT_UI_THEME: UiTheme = UiTheme {
|
||||
/// direct terminal constant onto these slots, so nothing navy-tuned leaks
|
||||
/// through.
|
||||
///
|
||||
/// The ocean is not gone, it is a choice: `underwater` keeps the painted
|
||||
/// The terminal's `underwater` default keeps the painted
|
||||
/// water column, the ambient life and the ombre.
|
||||
///
|
||||
/// Every pair `contrast::theme_contrast_violations` audits clears its floor.
|
||||
|
||||
@@ -225,8 +225,8 @@ pub const MATRIX_BORDER_RGB: (u8, u8, u8) = (0, 204, 0); // #00CC00
|
||||
// Shoreline — the product-client palette, shared with the GPUI desktop.
|
||||
//
|
||||
// Warm charcoal ground and warm paper sheet, one restrained blue, and the
|
||||
// whale's ivory ink on both sides. This is the default shell from 0.9.14:
|
||||
// the navy "Underwater" water column remains a selectable theme. The
|
||||
// whale's ivory ink on both sides. This is the charcoal alternative to the
|
||||
// terminal's navy "Underwater" default in 0.10.0. The
|
||||
// 0.10.0 action pair uses glacial blue on charcoal and deep ocean blue on
|
||||
// paper. Shared tokens keep the terminal, desktop, and web in one system.
|
||||
//
|
||||
|
||||
+27
-15
@@ -19,6 +19,9 @@ use codewhale_palette::{normalize_hex_rgb_color, normalize_theme_setting};
|
||||
|
||||
const SETTINGS_FILE_NAME: &str = "settings.toml";
|
||||
|
||||
/// Fresh terminal installs and explicit theme resets share one default.
|
||||
pub(crate) const DEFAULT_TUI_THEME: &str = "underwater";
|
||||
|
||||
/// Smallest Top work surface that can show its divider plus the compact
|
||||
/// goal / to-do / Agent projection without turning the rail into invisible
|
||||
/// keyboard state. Older releases accepted two rows, which left only one
|
||||
@@ -345,9 +348,9 @@ pub struct Settings {
|
||||
/// ca, de, fr, id, hi, ru, uk.
|
||||
/// Every shipped pack holds full `en.json` parity; nothing falls back.
|
||||
pub locale: String,
|
||||
/// Named UI theme. `"shoreline"` is the fresh-install default: the warm
|
||||
/// charcoal and restrained blue the desktop client paints too.
|
||||
/// `"underwater"` paints the ocean field. `"terminal"` fully inherits the
|
||||
/// Named UI theme. `"underwater"` is the fresh-install default: a dark
|
||||
/// navy water column. `"shoreline"` is the warm charcoal alternative.
|
||||
/// `"terminal"` fully inherits the
|
||||
/// host terminal's foreground/background. `"system"`, `"dark"`,
|
||||
/// `"light"`, `"grayscale"`, and the community presets:
|
||||
/// `"catppuccin-mocha"`, `"tokyo-night"`, `"dracula"`,
|
||||
@@ -572,7 +575,7 @@ impl Default for Settings {
|
||||
show_tool_details: false,
|
||||
inline_diffs: "full".to_string(),
|
||||
locale: "auto".to_string(),
|
||||
theme: "shoreline".to_string(),
|
||||
theme: DEFAULT_TUI_THEME.to_string(),
|
||||
background_color: None,
|
||||
composer_density: "comfortable".to_string(),
|
||||
composer_border: true,
|
||||
@@ -2924,10 +2927,9 @@ fn normalize_synchronized_output(value: &str) -> &str {
|
||||
}
|
||||
|
||||
fn normalize_settings_theme(value: &str) -> String {
|
||||
// A malformed persisted selector must not turn into a painted application
|
||||
// background. Falling back to the compiled default keeps a single
|
||||
// first-party theme until the user picks an explicit palette.
|
||||
normalize_theme_setting(value).unwrap_or_else(|_| "shoreline".to_string())
|
||||
// Unknown persisted selectors fall back to the same fresh-install default.
|
||||
// Valid saved choices, including Shoreline, remain unchanged.
|
||||
normalize_theme_setting(value).unwrap_or_else(|_| DEFAULT_TUI_THEME.to_string())
|
||||
}
|
||||
|
||||
/// Returns `true` when the active terminal is Ptyxis (the new default
|
||||
@@ -4075,21 +4077,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_settings_resolve_to_the_shoreline_theme() {
|
||||
// The fresh-install default is the Shoreline theme, end to end from
|
||||
fn default_settings_resolve_to_the_underwater_theme() {
|
||||
// The fresh-install default is the Underwater theme, end to end from
|
||||
// `Settings::default()` through theme resolution.
|
||||
let settings = Settings::default();
|
||||
assert_eq!(settings.theme, "shoreline");
|
||||
assert_eq!(settings.theme, "underwater");
|
||||
let (name, id, theme) = codewhale_palette::resolve_theme_setting(&settings.theme, None)
|
||||
.expect("default resolves");
|
||||
assert_eq!(id, codewhale_palette::ThemeId::Shoreline);
|
||||
assert_eq!(name, "shoreline");
|
||||
assert_eq!(theme.name, "shoreline");
|
||||
assert_eq!(id, codewhale_palette::ThemeId::Underwater);
|
||||
assert_eq!(name, "underwater");
|
||||
assert_eq!(theme.name, "underwater");
|
||||
let saved: Settings = toml::from_str("theme = \"shoreline\"\n").expect("saved theme");
|
||||
assert_eq!(
|
||||
saved.theme, "shoreline",
|
||||
"upgrades preserve an explicit choice"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_normalizes_supported_values_and_rejects_unknowns() {
|
||||
let mut settings = Settings::default();
|
||||
assert_eq!(settings.theme, "underwater");
|
||||
|
||||
settings
|
||||
.set("theme", "charcoal")
|
||||
.expect("set charcoal alternative");
|
||||
assert_eq!(settings.theme, "shoreline");
|
||||
|
||||
settings.set("theme", "grayscale").expect("set grayscale");
|
||||
@@ -5525,7 +5537,7 @@ mod tests {
|
||||
let loaded = Settings::load().expect("load settings");
|
||||
|
||||
assert_eq!(
|
||||
loaded.theme, "shoreline",
|
||||
loaded.theme, "underwater",
|
||||
"explicit CODEWHALE_HOME must not inherit ambient legacy settings"
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
//! existing delta/interpolation path: this module never requests frames on
|
||||
//! its own.
|
||||
//!
|
||||
//! Native silhouettes use the shared 2×4 braille cell: fish move in half
|
||||
//! columns with a one-dot bob; jellyfish rise in quarter rows. A bounded pose
|
||||
//! table owns no clock or simulation. ASCII-safe terminals retain the original
|
||||
//! silhouettes through the same habitat, population and collision path.
|
||||
//!
|
||||
//! Motion language (shared with the rest of the shell): every mark can lerp
|
||||
//! between the water and its ink at a time-varying brightness. Fish carry a
|
||||
//! travelling sin² wave, jellyfish a slow band-bounded pulse that opens and
|
||||
@@ -65,6 +70,8 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use crate::tui::ocean::{self, OceanColumn};
|
||||
|
||||
#[path = "ambient_life/native_poses.rs"]
|
||||
mod native_poses;
|
||||
#[path = "ambient_life/pet_cameo.rs"]
|
||||
mod pet_cameo;
|
||||
#[path = "ambient_life/pet_sim.rs"]
|
||||
@@ -235,27 +242,6 @@ impl AmbientActivity {
|
||||
_ => Self::Baseline,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ocean-clock speed factor: thinking reads as the slow deep, tool work
|
||||
/// as the fast current.
|
||||
fn speed(self) -> f32 {
|
||||
match self {
|
||||
Self::Reasoning => 0.6,
|
||||
Self::Reading => 0.8,
|
||||
Self::Tools | Self::Subagents => 1.25,
|
||||
Self::Verifying => 1.0,
|
||||
Self::Baseline => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn scaled_time_ms(self, elapsed_ms: u128) -> u128 {
|
||||
let speed = self.speed();
|
||||
if (speed - 1.0).abs() < f32::EPSILON {
|
||||
elapsed_ms
|
||||
} else {
|
||||
((elapsed_ms as f64) * f64::from(speed)) as u128
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render ambient life into empty water cells of `area`.
|
||||
@@ -282,17 +268,22 @@ pub fn render_ambient_life(
|
||||
return AmbientFrameStats::default();
|
||||
}
|
||||
|
||||
// Activity shifts the ocean clock: reading feels like the deep (slow
|
||||
// drift), tool work like a brighter current (faster), verification stays
|
||||
// on the metered pulse. Density stays bounded by MAX_FRAME_MARKS.
|
||||
let elapsed_ms = activity.scaled_time_ms(elapsed_ms);
|
||||
|
||||
// Geometry always samples the same clock. Scaling its absolute age when
|
||||
// activity changes teleports the scene; activity already owns ink/cameos.
|
||||
let density = LifeDensity::from_area(area);
|
||||
let mut stats = AmbientFrameStats::default();
|
||||
// Positions always ride the live monotonic clock; `presence` fades the
|
||||
// marks in and out, so the animated/static boundary eases instead of
|
||||
// snapping fish between t=0 and their mid-path positions.
|
||||
let frame = build_frame_marks(area, elapsed_ms, density, lines, cursor, &mut stats);
|
||||
let frame = build_frame_marks(
|
||||
area,
|
||||
elapsed_ms,
|
||||
density,
|
||||
lines,
|
||||
cursor,
|
||||
crate::tui::color_compat::ascii_safe_enabled(),
|
||||
&mut stats,
|
||||
);
|
||||
paint_marks(area, buf, inks, lines, &frame, presence, &mut stats);
|
||||
pet_cameo::paint(
|
||||
area, buf, inks.0, lines, presence, whale, activity, &mut stats,
|
||||
@@ -307,6 +298,7 @@ fn build_frame_marks(
|
||||
density: LifeDensity,
|
||||
lines: &[Line<'static>],
|
||||
cursor: AmbientCursor,
|
||||
ascii_safe: bool,
|
||||
stats: &mut AmbientFrameStats,
|
||||
) -> FrameMarks {
|
||||
let mut marks = Vec::with_capacity(48);
|
||||
@@ -341,6 +333,7 @@ fn build_frame_marks(
|
||||
let cycle_index = school_clock / cycle_ms;
|
||||
let cycle_frac = (school_clock % cycle_ms) as f64 / cycle_ms as f64;
|
||||
let cycle_step = (cycle_frac * travel as f64).round() as i32;
|
||||
let cycle_dot_step = (cycle_frac * travel as f64 * 2.0).floor() as i32;
|
||||
let swims_right = school_swims_right(cycle_index);
|
||||
// The school has one home: the deep water just off the floor. It used to
|
||||
// alternate between an upper and a lower band, which is most of why the
|
||||
@@ -351,20 +344,44 @@ fn build_frame_marks(
|
||||
let ptr = cursor.column.saturating_sub(area.x);
|
||||
let ptr_y = cursor.row.saturating_sub(area.y);
|
||||
for (m, (dy, dx)) in SCHOOL_WEDGE.iter().take(school_size).enumerate() {
|
||||
let body = fish_body(swims_right, m == 0);
|
||||
let body_w = body.len() as u16; // ASCII bodies: len == width
|
||||
let ascii_body = fish_body(swims_right, m == 0);
|
||||
let body_w = if ascii_safe {
|
||||
ascii_body.width() as u16
|
||||
} else {
|
||||
4
|
||||
};
|
||||
// Nose position in wrap space; trailers sit `dx` columns behind the
|
||||
// lead relative to travel, so the wedge follows instead of leading.
|
||||
// Right-swimmers enter from the left edge, left-swimmers from the
|
||||
// right edge — both facing exactly the way they move.
|
||||
let mut x_i32 = if swims_right {
|
||||
cycle_step - 1 - i32::from(*dx) - (i32::from(body_w) - 1)
|
||||
let x_dots = if swims_right {
|
||||
cycle_dot_step - i32::from(*dx) * 2 - i32::from(body_w) * 2
|
||||
} else {
|
||||
i32::from(area.width) - cycle_step + i32::from(*dx)
|
||||
i32::from(area.width) * 2 - cycle_dot_step + i32::from(*dx) * 2
|
||||
};
|
||||
// Slight per-fish vertical stagger + slow bob.
|
||||
let mut x_i32 = if ascii_safe {
|
||||
if swims_right {
|
||||
cycle_step - i32::from(*dx) - i32::from(body_w)
|
||||
} else {
|
||||
i32::from(area.width) - cycle_step + i32::from(*dx)
|
||||
}
|
||||
} else {
|
||||
x_dots.div_euclid(2)
|
||||
};
|
||||
// Native fish bob by one dot inside a cell, never by a whole text row.
|
||||
let bob = sine_bob(t, 3_400 + (m as u128) * 640, 1);
|
||||
let y_i32 = i32::from(anchor_y) + i32::from(*dy) + i32::from(bob);
|
||||
let y_i32 =
|
||||
i32::from(anchor_y) + i32::from(*dy) + if ascii_safe { i32::from(bob) } else { 0 };
|
||||
let body = if ascii_safe {
|
||||
ascii_body
|
||||
} else {
|
||||
native_poses::fish(
|
||||
swims_right,
|
||||
((t / 300 + m as u128) % 4) as usize,
|
||||
x_dots.rem_euclid(2) as usize,
|
||||
usize::from(bob),
|
||||
)
|
||||
};
|
||||
// Fish dart sideways away from the scatter anchor (nearby only).
|
||||
if let Some(flee_ms) = cursor.flee_elapsed_ms {
|
||||
let flee = i32::from(fish_flee_offset(flee_ms));
|
||||
@@ -409,14 +426,11 @@ fn build_frame_marks(
|
||||
}
|
||||
|
||||
// --- Jellyfish: a pulsing dome with lagging tentacles ---
|
||||
// Two dome rows (arc + bell rim) over a row of swaying
|
||||
// tentacles. The dome opens and closes on a slow floor-bounded sin²;
|
||||
// the tentacles repeat the pulse ~350 ms later and sway out of phase
|
||||
// with each other — the lag is what sells "jellyfish". Rich/Normal get
|
||||
// the full 5-cell dome; Sparse (narrow) swaps in a compact 3-cell one — a
|
||||
// real fallback silhouette, not just fewer jellies. Both hang two
|
||||
// tentacles. They drift slowly upward through a side lane of the deep
|
||||
// water and vanish before the rise would reach the composition.
|
||||
// Native braille shapes have a contracting bell and a wave travelling
|
||||
// down two trailing arms; fractional placement still fits the 5×3-cell
|
||||
// habitat. ASCII keeps two dome rows above two swaying strokes, with a
|
||||
// 3-cell compact silhouette. Both representations share the rare visit,
|
||||
// shallow glow and whole-silhouette clearance below.
|
||||
//
|
||||
// It only visits water deep enough to hold it: three rows of silhouette,
|
||||
// a row of clear water, and the school's own band, measured up from the
|
||||
@@ -444,18 +458,22 @@ fn build_frame_marks(
|
||||
// the bell, which is what the dogfood frame actually showed.
|
||||
(JELLY_DOME_TOP_FRAMES, JELLY_DOME_SKIRT_FRAMES, &[1, 3])
|
||||
};
|
||||
let dome_w = dome_top[0].len() as u16; // ASCII frames: len == width
|
||||
let dome_w = if ascii_safe {
|
||||
dome_top[0].width() as u16
|
||||
} else {
|
||||
5
|
||||
};
|
||||
let wobble_dots = sine_bob(t, 5_200 + phase, 2);
|
||||
let x = lane_x
|
||||
.saturating_add(wobble)
|
||||
.saturating_add(if ascii_safe { wobble } else { wobble_dots / 2 })
|
||||
.min(area.width.saturating_sub(dome_w + 1));
|
||||
if deep_water_rows(area, lines, x, dome_w) < JELLY_MIN_DEEP_ROWS {
|
||||
continue;
|
||||
}
|
||||
// A visit is a short, slow rise near the floor followed by a long
|
||||
// absence: the jelly climbs [`JELLY_VISIT_ROWS`] rows and then spends
|
||||
// the rest of the cycle out of sight. Rows are discrete cells, so the
|
||||
// per-row dwell stays long — a jellyfish should read as drifting, not
|
||||
// as stepping.
|
||||
// the rest of the cycle out of sight. Native movement samples quarter
|
||||
// rows; the ASCII fallback retains its slow whole-row steps.
|
||||
let rise_period = JELLY_RISE_ROW_MS.saturating_add((j as u128) * JELLY_RISE_ROW_STAGGER_MS);
|
||||
let cycle_duration = rise_period.saturating_mul(JELLY_VISIT_CYCLE_SLOTS);
|
||||
let cycle_pos = t.saturating_add(phase) % cycle_duration;
|
||||
@@ -465,10 +483,15 @@ fn build_frame_marks(
|
||||
}
|
||||
let visit_progress = cycle_pos as f64 / visit_duration as f64;
|
||||
let risen = (visit_progress * f64::from(JELLY_VISIT_ROWS)).round() as u16;
|
||||
let y = area
|
||||
.height
|
||||
.saturating_sub(JELLY_FLOOR_GAP)
|
||||
.saturating_sub(risen);
|
||||
let y_dots = i32::from(area.height.saturating_sub(JELLY_FLOOR_GAP)) * 4
|
||||
- (visit_progress * f64::from(JELLY_VISIT_ROWS) * 4.0).floor() as i32;
|
||||
let y = if ascii_safe {
|
||||
area.height
|
||||
.saturating_sub(JELLY_FLOOR_GAP)
|
||||
.saturating_sub(risen)
|
||||
} else {
|
||||
y_dots.div_euclid(4).max(0) as u16
|
||||
};
|
||||
if y == 0 || !water(x, y, dome_w) {
|
||||
continue;
|
||||
}
|
||||
@@ -496,6 +519,32 @@ fn build_frame_marks(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !ascii_safe {
|
||||
let pose = ((t.saturating_add(phase) % JELLY_PULSE_MS) * 16 / JELLY_PULSE_MS) as usize;
|
||||
for (row, glyph) in native_poses::jelly(
|
||||
pose,
|
||||
usize::from(wobble_dots % 2),
|
||||
y_dots.rem_euclid(4) as usize,
|
||||
)
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
marks.push(AmbientMark {
|
||||
x,
|
||||
y: y + row as u16,
|
||||
glyph,
|
||||
jellyfish: Some(j),
|
||||
depth: Depth::Background,
|
||||
style_mod: None,
|
||||
brightness: Some(if row == 0 {
|
||||
dome_brightness
|
||||
} else {
|
||||
tentacle_brightness
|
||||
}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (row, glyph) in [
|
||||
(y, dome_top[pulse_frame]),
|
||||
(skirt_row, dome_skirt[pulse_frame]),
|
||||
@@ -672,8 +721,8 @@ const JELLY_MAX_TEXT_DODGE_COLS: u16 = 3;
|
||||
// balance can be retuned without re-deriving it from the motion code.
|
||||
|
||||
/// Wall-clock milliseconds a jellyfish spends on each row of its rise
|
||||
/// (~9.4 s). A row step is a discrete one-cell jump, so the dwell has to stay
|
||||
/// long or the rise reads as stepping rather than drifting.
|
||||
/// (~9.4 s). Native placement samples quarter rows within this duration;
|
||||
/// ASCII-safe placement keeps the original slow whole-row cadence.
|
||||
const JELLY_RISE_ROW_MS: u128 = 9_400;
|
||||
/// Per-jelly rise-rate stagger, so two jellyfish (should a tier ever want
|
||||
/// them again) can never step in lockstep.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Bounded native replacements for the ASCII fish and jelly silhouettes.
|
||||
//! These are authored dot poses, not another simulation. The habitat owns
|
||||
//! clock, travel, brightness, collision and lifetime. Two tiny immutable tables
|
||||
//! retain their rows so a frame borrows glyphs without allocating strings.
|
||||
//! Braille remains discrete: half-column / quarter-row motion is not pixels.
|
||||
|
||||
use super::pet_sim::BRAILLE_BITS;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// Fish: 2 directions × 4 tail poses × 2 horizontal × 2 vertical dot offsets.
|
||||
static FISH: LazyLock<Vec<String>> = LazyLock::new(|| {
|
||||
(0..32)
|
||||
.map(|index| {
|
||||
let dy = index % 2;
|
||||
let dx = index / 2 % 2;
|
||||
let pose = index / 4 % 4;
|
||||
let right = index / 16 != 0;
|
||||
let mut dots = Vec::from([(2, 1), (3, 0), (4, 0), (5, 1), (6, 1), (3, 2), (4, 2)]);
|
||||
// A small forked tail opens, folds and opens; no blinking eye or flash.
|
||||
dots.extend(match pose {
|
||||
0 => [(0, 0), (0, 2), (1, 1)],
|
||||
1 => [(0, 0), (1, 1), (1, 2)],
|
||||
2 => [(1, 0), (0, 1), (1, 2)],
|
||||
_ => [(1, 0), (1, 1), (0, 2)],
|
||||
});
|
||||
if !right {
|
||||
for point in &mut dots {
|
||||
point.0 = 6 - point.0;
|
||||
}
|
||||
}
|
||||
raster(&dots, 4, 1, dx, dy).remove(0)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
// Jelly: 16 bell/tentacle poses × 2 horizontal × 4 vertical dot offsets.
|
||||
// Eight-by-eight source dots plus offsets fit the existing 5×3-cell habitat.
|
||||
static JELLY: LazyLock<Vec<[String; 3]>> = LazyLock::new(|| {
|
||||
(0..128)
|
||||
.map(|index| {
|
||||
let dy = index % 4;
|
||||
let dx = index / 4 % 2;
|
||||
let phase = (index / 8) as f64 / 16.0 * std::f64::consts::TAU;
|
||||
let contracted = (1.0 - phase.cos()) * 0.5;
|
||||
let inset = usize::from(contracted > 0.55);
|
||||
let mut dots = Vec::with_capacity(24);
|
||||
// An open contour, never a solid luminous block.
|
||||
dots.extend([
|
||||
(2, 0),
|
||||
(3, 0),
|
||||
(4, 0),
|
||||
(5, 0),
|
||||
(1 + inset, 1),
|
||||
(6 - inset, 1),
|
||||
]);
|
||||
for x in inset..8 - inset {
|
||||
dots.push((x, 2));
|
||||
}
|
||||
for y in 3..8 {
|
||||
// Wave travels down both arms after the bell; columns are offset.
|
||||
let lag = (y - 2) as f64 * 0.45 + 0.75;
|
||||
let left = (2.0 + (phase - lag).sin() * 0.9).round() as usize;
|
||||
let right = (5.0 + (phase - lag - 0.8).sin() * 0.9).round() as usize;
|
||||
dots.extend([(left, y), (right, y)]);
|
||||
}
|
||||
let mut rows = raster(&dots, 5, 3, dx, dy).into_iter();
|
||||
std::array::from_fn(|_| rows.next().expect("three jelly rows"))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
fn raster(
|
||||
dots: &[(usize, usize)],
|
||||
width: usize,
|
||||
height: usize,
|
||||
dx: usize,
|
||||
dy: usize,
|
||||
) -> Vec<String> {
|
||||
let mut cells = vec![0u8; width * height];
|
||||
for &(x, y) in dots {
|
||||
let (x, y) = (x + dx, y + dy);
|
||||
assert!(
|
||||
x < width * 2 && y < height * 4,
|
||||
"native pose escaped its habitat"
|
||||
);
|
||||
cells[y / 4 * width + x / 2] |= BRAILLE_BITS[y % 4][x % 2];
|
||||
}
|
||||
cells
|
||||
.chunks_exact(width)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|bits| {
|
||||
if *bits == 0 {
|
||||
' '
|
||||
} else {
|
||||
char::from_u32(0x2800 + u32::from(*bits)).expect("braille dot mask")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn fish(right: bool, pose: usize, dx: usize, dy: usize) -> &'static str {
|
||||
&FISH[usize::from(right) * 16 + pose % 4 * 4 + dx % 2 * 2 + dy % 2]
|
||||
}
|
||||
|
||||
pub(super) fn jelly(pose: usize, dx: usize, dy: usize) -> &'static [String; 3] {
|
||||
&JELLY[pose % 16 * 8 + dx % 2 * 4 + dy % 4]
|
||||
}
|
||||
@@ -220,6 +220,7 @@ fn frame_at(t: u128) -> FrameMarks {
|
||||
LifeDensity::from_area(area),
|
||||
&[],
|
||||
AmbientCursor::default(),
|
||||
true,
|
||||
&mut stats,
|
||||
)
|
||||
}
|
||||
@@ -265,15 +266,41 @@ fn subagent_activity_surfaces_a_whale_pod() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_activity_slows_the_ocean_clock() {
|
||||
// Same scene, twice the elapsed time: the reasoning clock must read like
|
||||
// the deep (0.6x), so position at t under Reasoning equals position at
|
||||
// 0.6t under Baseline.
|
||||
let scaled = AmbientActivity::Reasoning.scaled_time_ms(10_000);
|
||||
assert_eq!(scaled, 6_000);
|
||||
let tools = AmbientActivity::Tools.scaled_time_ms(10_000);
|
||||
assert_eq!(tools, 12_500);
|
||||
assert_eq!(AmbientActivity::Baseline.scaled_time_ms(10_000), 10_000);
|
||||
fn activity_changes_never_resample_creature_geometry() {
|
||||
// Regression: scaling absolute clock age moved the school by tens of
|
||||
// columns at a Reasoning → Tools transition. Same clock means same pose;
|
||||
// activity still owns ink and the explicitly triggered completion cameo.
|
||||
for elapsed in [0, 60_000, 600_000, 3_600_000] {
|
||||
let render = |activity| {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 30));
|
||||
render_ambient_life(
|
||||
buf.area,
|
||||
&mut buf,
|
||||
(Color::Cyan, Color::Blue),
|
||||
&[],
|
||||
elapsed,
|
||||
1.0,
|
||||
AmbientCursor::default(),
|
||||
WhaleCameo::default(),
|
||||
activity,
|
||||
);
|
||||
buf
|
||||
};
|
||||
let baseline = render(AmbientActivity::Baseline);
|
||||
for activity in [
|
||||
AmbientActivity::Reasoning,
|
||||
AmbientActivity::Reading,
|
||||
AmbientActivity::Tools,
|
||||
AmbientActivity::Subagents,
|
||||
AmbientActivity::Verifying,
|
||||
] {
|
||||
assert_eq!(
|
||||
baseline,
|
||||
render(activity),
|
||||
"geometry changed at {elapsed}ms: {activity:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -475,16 +502,24 @@ fn short_transcript_rows_leave_a_safe_ocean_corridor() {
|
||||
buf.set_line(area.x, area.y + row as u16, line, area.width);
|
||||
}
|
||||
|
||||
let stats = render_ambient_life(
|
||||
let mut stats = AmbientFrameStats::default();
|
||||
let frame = build_frame_marks(
|
||||
area,
|
||||
0,
|
||||
LifeDensity::from_area(area),
|
||||
&lines,
|
||||
AmbientCursor::default(),
|
||||
true,
|
||||
&mut stats,
|
||||
);
|
||||
paint_marks(
|
||||
area,
|
||||
&mut buf,
|
||||
(Color::Cyan, Color::Blue),
|
||||
&lines,
|
||||
0,
|
||||
&frame,
|
||||
1.0,
|
||||
AmbientCursor::default(),
|
||||
WhaleCameo::default(),
|
||||
AmbientActivity::Baseline,
|
||||
&mut stats,
|
||||
);
|
||||
let rendered: String = (0..area.height)
|
||||
.map(|row| {
|
||||
@@ -536,6 +571,7 @@ fn a_jellyfish_needs_water_deep_enough_to_hold_it_and_the_school() {
|
||||
LifeDensity::from_area(area),
|
||||
&lines,
|
||||
AmbientCursor::default(),
|
||||
true,
|
||||
&mut stats,
|
||||
);
|
||||
for mark in &frame.marks {
|
||||
@@ -581,6 +617,7 @@ fn sparse_water_gets_a_compact_jellyfish() {
|
||||
LifeDensity::from_area(area),
|
||||
&[],
|
||||
AmbientCursor::default(),
|
||||
true,
|
||||
&mut stats,
|
||||
);
|
||||
for mark in &frame.marks {
|
||||
@@ -634,6 +671,7 @@ fn motion_is_a_deterministic_function_of_elapsed_time() {
|
||||
LifeDensity::from_area(area),
|
||||
&[],
|
||||
AmbientCursor::default(),
|
||||
true,
|
||||
&mut stats,
|
||||
)
|
||||
};
|
||||
@@ -1042,3 +1080,172 @@ fn caustic_brightness_cross_fades_without_80ms_steps() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn native_frame_at(area: Rect, elapsed_ms: u128) -> FrameMarks {
|
||||
build_frame_marks(
|
||||
area,
|
||||
elapsed_ms,
|
||||
LifeDensity::from_area(area),
|
||||
&[],
|
||||
AmbientCursor::default(),
|
||||
false,
|
||||
&mut AmbientFrameStats::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_pose_tables_have_fixed_cell_bounds_and_safe_glyphs() {
|
||||
for right in [false, true] {
|
||||
for pose in 0..4 {
|
||||
for dx in 0..2 {
|
||||
for dy in 0..2 {
|
||||
let row = native_poses::fish(right, pose, dx, dy);
|
||||
assert_eq!(row.width(), 4);
|
||||
assert!(row.chars().all(
|
||||
|c| c == ' ' || crate::tui::glyphs::braille_ascii_fallback(c).is_some()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for pose in 0..16 {
|
||||
for dx in 0..2 {
|
||||
for dy in 0..4 {
|
||||
let rows = native_poses::jelly(pose, dx, dy);
|
||||
assert_eq!(rows.len(), 3);
|
||||
for row in rows {
|
||||
assert_eq!(row.width(), 5);
|
||||
assert!(row.chars().all(
|
||||
|c| c == ' ' || crate::tui::glyphs::braille_ascii_fallback(c).is_some()
|
||||
));
|
||||
}
|
||||
assert!(rows.iter().all(|r| r.chars().count() == 5));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_fish_move_within_the_old_whole_cell_dwell() {
|
||||
let area = Rect::new(0, 0, 100, 30);
|
||||
let a = native_frame_at(area, 190);
|
||||
let b = native_frame_at(area, 380);
|
||||
let (a, b) = (&a.marks[0], &b.marks[0]);
|
||||
assert_eq!(
|
||||
(a.x, a.y),
|
||||
(b.x, b.y),
|
||||
"both samples share the same cell anchor"
|
||||
);
|
||||
assert_ne!(
|
||||
a.glyph, b.glyph,
|
||||
"half-cell travel must change the raster within a cell"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_motion_sampling_is_independent_of_cadence_and_history() {
|
||||
let area = Rect::new(0, 0, 100, 30);
|
||||
let pose = |t| {
|
||||
native_frame_at(area, t)
|
||||
.marks
|
||||
.iter()
|
||||
.map(|mark| (mark.x, mark.y, mark.glyph))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// Deliberately skip, reverse and change the draw cadence before landing
|
||||
// on shared timestamps. A per-paint simulation would make these differ.
|
||||
for target in [500, 1_000, 5_200, 9_400, 56_400, 300_800] {
|
||||
let expected = pose(target);
|
||||
for step in [16, 33, 117] {
|
||||
for t in (0..target as usize).step_by(step) {
|
||||
let _ = pose(t as u128);
|
||||
}
|
||||
assert_eq!(expected, pose(target));
|
||||
}
|
||||
let _ = pose(target + 10_000);
|
||||
assert_eq!(expected, pose(target));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_temporal_sweep_retains_population_and_paint_bounds() {
|
||||
for area in [
|
||||
Rect::new(0, 0, 40, 12),
|
||||
Rect::new(0, 0, 80, 24),
|
||||
Rect::new(0, 0, 140, 40),
|
||||
] {
|
||||
// Cover rise, departure, long absence and next arrival, not just t=0.
|
||||
let mut saw_jelly = false;
|
||||
let mut saw_absence = false;
|
||||
for t in (0..310_000).step_by(250) {
|
||||
let frame = native_frame_at(area, t);
|
||||
let count = frame.marks.iter().filter(|m| m.jellyfish.is_some()).count();
|
||||
assert!(
|
||||
count == 0 || count == 3,
|
||||
"native jelly must be one complete group at {t}"
|
||||
);
|
||||
saw_jelly |= count == 3;
|
||||
saw_absence |= count == 0;
|
||||
assert!(frame.marks.len() <= 13);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut stats = AmbientFrameStats {
|
||||
marks_built: frame.marks.len() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
paint_marks(
|
||||
area,
|
||||
&mut buf,
|
||||
(Color::Cyan, Color::Blue),
|
||||
&[],
|
||||
&frame,
|
||||
1.0,
|
||||
&mut stats,
|
||||
);
|
||||
assert_eq!(
|
||||
stats.marks_built,
|
||||
stats.marks_painted + stats.marks_skipped_text + stats.marks_clipped
|
||||
);
|
||||
assert!(stats.cells_written <= stats.marks_painted * 5);
|
||||
}
|
||||
assert!(
|
||||
saw_jelly && saw_absence,
|
||||
"visit lifecycle missing at {area:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_jelly_stays_whole_as_wide_text_streams_through_its_lane() {
|
||||
let area = Rect::new(0, 0, 80, 24);
|
||||
let mut frame = native_frame_at(area, 0);
|
||||
frame.marks.retain(|mark| mark.jellyfish.is_some());
|
||||
assert_eq!(frame.marks.len(), 3);
|
||||
let y = frame.marks[0].y;
|
||||
for width in 0..=40 {
|
||||
let mut lines = vec![Line::default(); area.height as usize];
|
||||
lines[y as usize] = Line::from("鲸".repeat(width));
|
||||
let mut buf = Buffer::empty(area);
|
||||
buf.set_line(0, y, &lines[y as usize], area.width);
|
||||
let before = buf.clone();
|
||||
let mut stats = AmbientFrameStats {
|
||||
marks_built: 3,
|
||||
..Default::default()
|
||||
};
|
||||
paint_marks(
|
||||
area,
|
||||
&mut buf,
|
||||
(Color::Cyan, Color::Blue),
|
||||
&lines,
|
||||
&frame,
|
||||
1.0,
|
||||
&mut stats,
|
||||
);
|
||||
assert!(
|
||||
stats.marks_painted == 0 || stats.marks_painted == 3,
|
||||
"partial jelly at width {width}"
|
||||
);
|
||||
for x in 0..(width as u16 * 2).min(area.width) {
|
||||
assert_eq!(buf[(x, y)], before[(x, y)], "painted over wide text at {x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,9 @@ use super::mode::MotionPolicy;
|
||||
pub struct FrameRequester {
|
||||
/// Earliest instant a requester wants a frame.
|
||||
next_due: Option<Instant>,
|
||||
/// Whether any widget asked for a frame since the last take.
|
||||
pending: bool,
|
||||
#[cfg(test)]
|
||||
request_count: u64,
|
||||
#[cfg(test)]
|
||||
emit_count: u64,
|
||||
}
|
||||
|
||||
@@ -30,11 +30,6 @@ impl FrameRequester {
|
||||
|
||||
/// Request a frame as soon as the motion policy and frame cap allow.
|
||||
pub fn request_frame(&mut self, now: Instant, policy: MotionPolicy) {
|
||||
if !policy.should_request_animation_frames() {
|
||||
// Reduced/Still: do not schedule decorative frames. State-change
|
||||
// redraws still go through `needs_redraw` directly.
|
||||
return;
|
||||
}
|
||||
self.request_at(now, now, policy);
|
||||
}
|
||||
|
||||
@@ -44,8 +39,10 @@ impl FrameRequester {
|
||||
return;
|
||||
}
|
||||
let capped = earliest.max(now);
|
||||
self.pending = true;
|
||||
self.request_count = self.request_count.saturating_add(1);
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.request_count = self.request_count.saturating_add(1);
|
||||
}
|
||||
self.next_due = Some(match self.next_due {
|
||||
Some(existing) => existing.min(capped),
|
||||
None => capped,
|
||||
@@ -56,17 +53,13 @@ impl FrameRequester {
|
||||
#[must_use]
|
||||
pub fn due_in(&self, now: Instant) -> Option<Duration> {
|
||||
let due = self.next_due?;
|
||||
if !self.pending {
|
||||
return None;
|
||||
}
|
||||
Some(due.saturating_duration_since(now))
|
||||
}
|
||||
|
||||
/// Consume a due frame request. Returns true when the main loop should
|
||||
/// set `needs_redraw` for animation (not for state changes).
|
||||
pub fn take_due(&mut self, now: Instant, policy: MotionPolicy) -> bool {
|
||||
if !self.pending || !policy.should_request_animation_frames() {
|
||||
self.pending = false;
|
||||
if !policy.should_request_animation_frames() {
|
||||
self.next_due = None;
|
||||
return false;
|
||||
}
|
||||
@@ -76,50 +69,25 @@ impl FrameRequester {
|
||||
if now < due {
|
||||
return false;
|
||||
}
|
||||
self.pending = false;
|
||||
self.next_due = None;
|
||||
self.emit_count = self.emit_count.saturating_add(1);
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.emit_count = self.emit_count.saturating_add(1);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply the frame-rate limiter interval so animation requests never beat
|
||||
/// the draw cap.
|
||||
#[allow(dead_code)] // frame-cap bridge for poll-loop hosts (TUI-DOG-008)
|
||||
pub fn clamp_to_frame_cap(&mut self, last_draw_at: Instant, policy: MotionPolicy) {
|
||||
let Some(due) = self.next_due else {
|
||||
return;
|
||||
};
|
||||
let min_allowed = last_draw_at
|
||||
.checked_add(policy.min_frame_interval())
|
||||
.unwrap_or(last_draw_at);
|
||||
if due < min_allowed {
|
||||
self.next_due = Some(min_allowed);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // reset between modal/session boundaries (TUI-DOG-008)
|
||||
pub fn reset(&mut self) {
|
||||
self.pending = false;
|
||||
self.next_due = None;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // telemetry/introspection for motion QA (TUI-DOG-008)
|
||||
#[cfg(test)]
|
||||
pub fn request_count(&self) -> u64 {
|
||||
self.request_count
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // telemetry/introspection for motion QA (TUI-DOG-008)
|
||||
#[cfg(test)]
|
||||
pub fn emit_count(&self) -> u64 {
|
||||
self.emit_count
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // pending probe for poll-loop hosts (TUI-DOG-008)
|
||||
pub fn is_pending(&self) -> bool {
|
||||
self.pending
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -136,7 +104,7 @@ mod tests {
|
||||
for _ in 0..20 {
|
||||
req.request_frame(t0, policy);
|
||||
}
|
||||
assert!(req.is_pending());
|
||||
assert!(req.due_in(t0).is_some());
|
||||
assert_eq!(req.request_count(), 20);
|
||||
assert!(req.take_due(t0, policy));
|
||||
assert_eq!(req.emit_count(), 1);
|
||||
@@ -149,18 +117,19 @@ mod tests {
|
||||
let mut req = FrameRequester::new();
|
||||
let t0 = Instant::now();
|
||||
req.request_frame(t0, policy);
|
||||
assert!(!req.is_pending());
|
||||
assert!(req.due_in(t0).is_none());
|
||||
assert!(!req.take_due(t0, policy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_respects_low_motion_frame_cap() {
|
||||
let policy = MotionPolicy::from_settings(false, true, false);
|
||||
fn turning_motion_off_discards_a_pending_frame() {
|
||||
let full = MotionPolicy::from_settings(false, true, false);
|
||||
let still = MotionPolicy::from_settings(false, false, false);
|
||||
let mut req = FrameRequester::new();
|
||||
let t0 = Instant::now();
|
||||
req.request_frame(t0, policy);
|
||||
req.clamp_to_frame_cap(t0, policy);
|
||||
let due = req.due_in(t0).unwrap();
|
||||
assert!(due >= policy.min_frame_interval() || due.is_zero());
|
||||
let now = Instant::now();
|
||||
req.request_frame(now, full);
|
||||
assert!(!req.take_due(now, still));
|
||||
assert!(req.due_in(now).is_none());
|
||||
assert!(!req.take_due(now, full));
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,4 @@ pub mod frame_requester;
|
||||
pub mod mode;
|
||||
|
||||
pub use frame_requester::FrameRequester;
|
||||
#[allow(unused_imports)] // public API surface for host pickers / widgets
|
||||
pub use mode::{MotionMode, MotionPolicy, SpinnerPresentation};
|
||||
pub use mode::{MotionMode, MotionPolicy};
|
||||
@@ -1,10 +1,6 @@
|
||||
//! Explicit motion modes with semantic (non-animated) fallbacks.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::tui::frame_rate_limiter::{LOW_MOTION_MIN_FRAME_INTERVAL, MIN_FRAME_INTERVAL};
|
||||
use crate::tui::spinner::{BRAILLE_SPINNER_STILL_FRAME, LIVE_STATIC_MARKER};
|
||||
use crate::tui::streaming::DEFAULT_STREAM_COMMIT_INTERVAL;
|
||||
|
||||
/// How the shell presents motion.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -20,7 +16,6 @@ pub enum MotionMode {
|
||||
|
||||
/// Resolved presentation for a live-work marker / spinner cell.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)] // spinner presentation enum for MotionPolicy hosts (TUI-DOG-008)
|
||||
pub enum SpinnerPresentation {
|
||||
/// Use the shared braille animation table.
|
||||
Animate,
|
||||
@@ -61,7 +56,6 @@ impl MotionPolicy {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // mode accessor for hosts that store policy not enum (TUI-DOG-008)
|
||||
pub fn mode(self) -> MotionMode {
|
||||
self.mode
|
||||
}
|
||||
@@ -76,33 +70,13 @@ impl MotionPolicy {
|
||||
matches!(self.mode, MotionMode::Full)
|
||||
}
|
||||
|
||||
/// Frame-cap interval for the existing render loop limiter.
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // used by FrameRequester::clamp_to_frame_cap (TUI-DOG-008)
|
||||
pub fn min_frame_interval(self) -> Duration {
|
||||
if self.constrained_frame_rate || !matches!(self.mode, MotionMode::Full) {
|
||||
LOW_MOTION_MIN_FRAME_INTERVAL
|
||||
} else {
|
||||
MIN_FRAME_INTERVAL
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the render loop should use its 30 FPS compatibility cap.
|
||||
#[must_use]
|
||||
pub fn uses_constrained_frame_rate(self) -> bool {
|
||||
self.constrained_frame_rate || !matches!(self.mode, MotionMode::Full)
|
||||
}
|
||||
|
||||
/// Streaming display-clock interval. Reduced/Still keep the same cadence
|
||||
/// so low motion never becomes an artificial typewriter.
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // stream clock; ui still uses DEFAULT_STREAM_COMMIT_INTERVAL (TUI-DOG-008)
|
||||
pub fn stream_commit_interval(self) -> Duration {
|
||||
DEFAULT_STREAM_COMMIT_INTERVAL
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // spinner policy bridge for history/sidebar cutover (TUI-DOG-008)
|
||||
pub fn spinner_presentation(self, earned_live_marker: bool) -> SpinnerPresentation {
|
||||
match self.mode {
|
||||
MotionMode::Full if earned_live_marker => SpinnerPresentation::Animate,
|
||||
@@ -114,7 +88,6 @@ impl MotionPolicy {
|
||||
|
||||
/// Resolve the glyph a widget should paint for a running marker.
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // prefer over low_motion bool once callers pass MotionPolicy (TUI-DOG-008)
|
||||
pub fn spinner_glyph(
|
||||
self,
|
||||
animated_frame: &'static str,
|
||||
@@ -145,14 +118,10 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reduced_is_semantic_not_slow_typewriter() {
|
||||
fn reduced_uses_calm_markers_without_decorative_motion() {
|
||||
let reduced = MotionPolicy::from_settings(true, true, false);
|
||||
let full = MotionPolicy::from_settings(false, true, false);
|
||||
assert_eq!(
|
||||
reduced.stream_commit_interval(),
|
||||
full.stream_commit_interval(),
|
||||
"reduced motion must not slow the stream clock"
|
||||
);
|
||||
assert!(full.allows_decorative());
|
||||
assert!(!reduced.allows_catch_up_bursts());
|
||||
assert!(!reduced.allows_decorative());
|
||||
assert_eq!(
|
||||
@@ -162,14 +131,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_disables_spin_but_keeps_display_clock() {
|
||||
fn still_disables_animation_frames_and_uses_static_marker() {
|
||||
let still = MotionPolicy::from_settings(false, false, false);
|
||||
assert_eq!(still.mode, MotionMode::Still);
|
||||
assert!(!still.should_request_animation_frames());
|
||||
assert_eq!(
|
||||
still.stream_commit_interval(),
|
||||
DEFAULT_STREAM_COMMIT_INTERVAL
|
||||
);
|
||||
assert_eq!(
|
||||
still.spinner_presentation(true),
|
||||
SpinnerPresentation::StaticChevron
|
||||
@@ -185,7 +150,6 @@ mod tests {
|
||||
assert!(policy.allows_catch_up_bursts());
|
||||
assert!(policy.should_request_animation_frames());
|
||||
assert!(policy.uses_constrained_frame_rate());
|
||||
assert_eq!(policy.min_frame_interval(), LOW_MOTION_MIN_FRAME_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -28,6 +28,7 @@ use ratatui::{
|
||||
widgets::{Paragraph, Widget},
|
||||
};
|
||||
|
||||
use crate::settings::DEFAULT_TUI_THEME;
|
||||
use crate::tui::menu_style;
|
||||
use crate::tui::settings_picker::{
|
||||
PickerNavResult, SettingAvailability, SettingOption, SettingValues, SettingsPickerController,
|
||||
@@ -226,9 +227,8 @@ fn theme_options_with_custom(
|
||||
.help("Pick a theme with live preview")
|
||||
.values(SettingValues::new(
|
||||
Cow::Owned(current.clone()),
|
||||
// A reset returns to the Shoreline default, not a
|
||||
// detected palette that can repaint it.
|
||||
Cow::Borrowed("shoreline"),
|
||||
// Reset uses the same default as fresh terminal settings.
|
||||
Cow::Borrowed(DEFAULT_TUI_THEME),
|
||||
Cow::Borrowed(name),
|
||||
))
|
||||
.availability(SettingAvailability::Available)
|
||||
@@ -255,7 +255,7 @@ fn theme_options_with_custom(
|
||||
.help("Pick a user-authored theme overlay")
|
||||
.values(SettingValues::new(
|
||||
Cow::Owned(current.clone()),
|
||||
Cow::Borrowed("shoreline"),
|
||||
Cow::Borrowed(DEFAULT_TUI_THEME),
|
||||
Cow::Owned(custom.selector.clone()),
|
||||
))
|
||||
.availability(SettingAvailability::Available)
|
||||
|
||||
@@ -151,14 +151,10 @@ fn launch_recent_entries(app: &App) -> (Vec<LaunchRecentEntry>, bool) {
|
||||
&session.updated_at,
|
||||
app.ui_locale,
|
||||
);
|
||||
let count = crate::tui::session_picker::format_message_count(
|
||||
session.message_count,
|
||||
app.ui_locale,
|
||||
);
|
||||
LaunchRecentEntry {
|
||||
id: session.id.clone(),
|
||||
title,
|
||||
detail: format!("{age} · {count}"),
|
||||
detail: age,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -1155,12 +1151,12 @@ pub struct LaunchEmptyState {
|
||||
const LAUNCH_BLOCK_INDENT: usize = 2;
|
||||
/// The card's reading measure: a row is a title with its detail set against
|
||||
/// it, and without a ceiling the detail right-aligns against the terminal's
|
||||
/// far edge. Titles persist at 50 characters, the detail reads ~20.
|
||||
/// far edge. The title is primary; the relative age is secondary.
|
||||
const LAUNCH_CARD_MEASURE: usize = 72;
|
||||
/// Gap between a row's title and its right-aligned detail.
|
||||
const LAUNCH_ROW_GAP: usize = 3;
|
||||
/// Below this the row spends its whole lane on the title and sheds the detail.
|
||||
const LAUNCH_ROW_MIN_TITLE: usize = 24;
|
||||
const LAUNCH_ROW_MIN_TITLE: usize = 28;
|
||||
/// Labels align with their heading; the action cue has its own gutter.
|
||||
/// Blank rows the card spends on rhythm when the pane is tall enough.
|
||||
const LAUNCH_SEPARATORS: usize = 3;
|
||||
@@ -1548,7 +1544,7 @@ fn launch_fit(height: usize, recent: usize, has_more: bool, notice: bool, mcp: u
|
||||
context: true,
|
||||
help: true,
|
||||
notice,
|
||||
heading: true,
|
||||
heading: recent > 0 || has_more,
|
||||
blanks: LAUNCH_SEPARATORS,
|
||||
shown: recent,
|
||||
see_all: has_more,
|
||||
@@ -1627,10 +1623,6 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
|
||||
}
|
||||
|
||||
let (entries, has_more) = launch_recent_entries(app);
|
||||
// Whether this workspace has recent work at all, before any is shed for
|
||||
// height: the heading must not say "no recent sessions" about a list that
|
||||
// only ran out of rows.
|
||||
let had_recent = !entries.is_empty();
|
||||
// Built before the fit ladder runs: how many rows the block wants is a
|
||||
// fact about this workspace's servers, not about the pane.
|
||||
let mcp_block = mcp_launch_lines(app, text_width);
|
||||
@@ -1757,17 +1749,15 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
|
||||
} else {
|
||||
Style::default().fg(theme.text_body)
|
||||
};
|
||||
// Every action shares the same left edge.
|
||||
let indent = 0;
|
||||
if matches!(row.id, crate::tui::app::LaunchRowId::SeeAll) && spacious {
|
||||
for _ in 0..fit.gap {
|
||||
text.push(None);
|
||||
}
|
||||
}
|
||||
let lane = text_width.saturating_sub(indent);
|
||||
let lane = text_width;
|
||||
let detail_width = text_display_width(&row.detail);
|
||||
// Age and message count are context, not the row: when the lane
|
||||
// cannot hold a readable title beside them they are dropped whole
|
||||
// Age is context: when the lane cannot hold a readable title
|
||||
// beside it, drop the age whole
|
||||
// rather than ellipsing the title to a stub. This is also the
|
||||
// fallback for a locale that spends more cells on the same fact.
|
||||
let detail = if row.detail.is_empty()
|
||||
@@ -1785,9 +1775,6 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
|
||||
let label = semantic_truncate(&row.label, label_budget);
|
||||
let label_width = text_display_width(&label);
|
||||
let mut spans = Vec::with_capacity(4);
|
||||
if indent > 0 {
|
||||
spans.push(Span::raw(" ".repeat(indent)));
|
||||
}
|
||||
spans.push(Span::styled(label, style));
|
||||
if !detail.is_empty() {
|
||||
let pad = lane
|
||||
@@ -1808,22 +1795,15 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
|
||||
rows.push((row.id.clone(), text.len()));
|
||||
text.push(Some(Line::from(spans)));
|
||||
if row.prominent && fit.heading {
|
||||
// With no resumable work the heading says so — but only when the
|
||||
// workspace genuinely has none. Sessions the card's filter drops
|
||||
// (empty auto-created shells) still exist in `/resume`, so their
|
||||
// presence earns the honest "Recent" + a see-all row, not a
|
||||
// "no recent sessions" the picker would immediately disprove.
|
||||
let heading = if had_recent || app.launch.has_scoped_sessions {
|
||||
MessageId::LaunchRecentHeading
|
||||
} else {
|
||||
MessageId::LaunchNoRecentSessions
|
||||
};
|
||||
// An empty workspace needs only the invitation and composer.
|
||||
// Real history, including filtered sessions reachable via See all,
|
||||
// still gets a heading; zero counts are not content.
|
||||
if spacious {
|
||||
for _ in 0..fit.gap {
|
||||
text.push(None);
|
||||
}
|
||||
}
|
||||
let label = semantic_truncate(&tr(locale, heading), text_width);
|
||||
let label = semantic_truncate(&tr(locale, MessageId::LaunchRecentHeading), text_width);
|
||||
let remaining = text_width.saturating_sub(text_display_width(&label) + 2);
|
||||
let mut spans = vec![Span::styled(
|
||||
label,
|
||||
@@ -1970,8 +1950,9 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
|
||||
#[cfg(test)]
|
||||
mod launch_card_tests {
|
||||
use super::{
|
||||
LAUNCH_CARD_MEASURE, LaunchAction, launch_empty_state, launch_fit, launch_row_click_action,
|
||||
launch_rows_for_app, refresh_launch_row_hitboxes, run_launch_card_row, text_display_width,
|
||||
LAUNCH_CARD_MEASURE, LaunchAction, launch_empty_state, launch_fit, launch_recent_entries,
|
||||
launch_row_click_action, launch_rows_for_app, refresh_launch_row_hitboxes,
|
||||
run_launch_card_row, text_display_width,
|
||||
};
|
||||
use crate::tui::app::{App, LaunchRecentSession, LaunchRowId};
|
||||
use ratatui::layout::Rect;
|
||||
@@ -2365,6 +2346,18 @@ mod launch_card_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_workspace_omits_recent_section_but_hidden_history_stays_reachable() {
|
||||
let app = app_with_recent(&[], 0);
|
||||
let text = painted(&app, 100, 24).join("\n");
|
||||
assert!(text.contains("New session"));
|
||||
assert!(!text.contains("Recent"));
|
||||
assert!(!text.contains("No recent sessions"));
|
||||
assert!(!launch_fit(24, 0, false, false, 0).heading);
|
||||
assert!(launch_fit(24, 0, true, false, 0).heading);
|
||||
assert!(launch_fit(24, 0, true, false, 0).see_all);
|
||||
}
|
||||
|
||||
// --- the row reads as one object -----------------------------------
|
||||
|
||||
#[test]
|
||||
@@ -2379,14 +2372,26 @@ mod launch_card_tests {
|
||||
text_display_width(row.trim_start()) <= LAUNCH_CARD_MEASURE + 2,
|
||||
"row runs to the terminal edge: {row:?}",
|
||||
);
|
||||
assert!(row.contains("msgs"), "row lost its detail: {row:?}");
|
||||
let (entries, _) = launch_recent_entries(&app);
|
||||
assert!(
|
||||
row.contains(&entries[0].detail),
|
||||
"row lost its age: {row:?}"
|
||||
);
|
||||
assert!(
|
||||
!row.contains("msgs"),
|
||||
"message counts belong in session details: {row:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_narrow_pane_spends_its_lane_on_the_title() {
|
||||
let app = app_with_recent(&["Ship the launch card"], 1);
|
||||
let row = recent_row_title(&app, 40, 24);
|
||||
assert!(!row.contains("msgs"), "detail should have shed: {row:?}");
|
||||
let (entries, _) = launch_recent_entries(&app);
|
||||
assert!(
|
||||
!row.contains(&entries[0].detail),
|
||||
"age should have shed: {row:?}"
|
||||
);
|
||||
assert!(row.starts_with("Ship the launch"), "{row:?}");
|
||||
}
|
||||
|
||||
|
||||
@@ -314,21 +314,17 @@ fn assert_startup_contract(frame: &Frame, rows: u16, cols: u16, size: &str) {
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
// The card sheds rows on narrow stages; the new-session entry holds
|
||||
// last. The sealed harness home has no saved sessions, so wide stages
|
||||
// also paint the empty-workspace note.
|
||||
let needles: &[&str] = if cols < 56 {
|
||||
&["New session"]
|
||||
} else {
|
||||
&["New session", "No recent sessions"]
|
||||
};
|
||||
for needle in needles {
|
||||
assert!(
|
||||
text.contains(needle),
|
||||
"{size}: startup misses {needle:?}\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
// Empty workspaces keep the invitation without an empty history section.
|
||||
assert!(
|
||||
text.contains("New session"),
|
||||
"{size}: startup misses invitation\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
assert!(
|
||||
!text.contains("No recent sessions"),
|
||||
"{size}: empty history adds noise\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
for retired_mark_row in ["▄▄▄▄██▌", "▜████▀▘"] {
|
||||
assert!(
|
||||
!text.contains(retired_mark_row),
|
||||
|
||||
@@ -399,6 +399,45 @@ fn workbench_whale_reveal_visual_evidence() {
|
||||
tui.shutdown();
|
||||
}
|
||||
|
||||
/// Record temporal evidence from the real terminal, including its idle settle.
|
||||
#[test]
|
||||
#[ignore = "opt-in Underwater motion capture; isolated fixture, no provider calls"]
|
||||
fn underwater_motion_visual_evidence() {
|
||||
use std::io::Write;
|
||||
let directory = std::path::PathBuf::from(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").unwrap());
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for (rows, cols) in [(24, 80), (36, 120)] {
|
||||
let (_workspace, mut tui) =
|
||||
start_with_options(rows, cols, false, &[TITLE], Some("underwater"), true, false);
|
||||
let file =
|
||||
std::fs::File::create(directory.join(format!("ocean-{cols}x{rows}.jsonl.gz"))).unwrap();
|
||||
let mut output = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
|
||||
let start = std::time::Instant::now();
|
||||
for index in 0..360u64 {
|
||||
let frame = tui.frame();
|
||||
assert!(
|
||||
frame.contains("New session"),
|
||||
"motion cannot displace the launch action"
|
||||
);
|
||||
serde_json::to_writer(
|
||||
&mut output,
|
||||
&serde_json::json!({
|
||||
"elapsed_ms": start.elapsed().as_millis(),
|
||||
"frame": frame.capture_cells()
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
output.write_all(b"\n").unwrap();
|
||||
let target = Duration::from_millis((index + 1) * 33);
|
||||
if let Some(remaining) = target.checked_sub(start.elapsed()) {
|
||||
std::thread::sleep(remaining);
|
||||
}
|
||||
}
|
||||
output.finish().unwrap();
|
||||
tui.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercise the visible catalog controls and provider search through the
|
||||
/// input decoder. This only browses fixture state; it never applies a route.
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
# TUI redesign — Shoreline / Workbench index
|
||||
# TUI redesign — ocean depth / Workbench index
|
||||
|
||||
## Overview
|
||||
|
||||
Fresh 0.10.0 terminal installs use **Underwater**, the restrained navy ombré.
|
||||
Shoreline remains the warm charcoal alternative; saved theme choices are
|
||||
preserved. `/theme` previews either treatment, Enter saves, and Escape restores
|
||||
the previous choice. The same layout, whale mark and information hierarchy
|
||||
serve both themes; the ocean is a continuous background, not extra chrome.
|
||||
|
||||
Shoreline is the terminal's warm charcoal, ivory and blue visual system,
|
||||
introduced on 2026-09-15 to bring the TUI closer to the GPUI product client.
|
||||
The 2026-09-19 Workbench index pass organizes that system around the next
|
||||
@@ -12,12 +18,12 @@ the previous appearance when it improved the experience.
|
||||
The home screen uses a compact codewhale identity, version metadata aligned
|
||||
opposite it, and one bounded reading lane centered in wide terminals. Actual
|
||||
workspace and branch context make the screen specific to the current work.
|
||||
A filled New session action and ruled Recent heading organize the available
|
||||
choices. A compact canonical braille whale accompanies the identity when
|
||||
A prominent New session action and a Recent heading for real history organize
|
||||
the available choices. A compact canonical braille whale accompanies the identity when
|
||||
space permits, yielding before session titles and actionable rows in short
|
||||
terminals. The canonical brand asset is unchanged. The full-width
|
||||
composer, shared session runtime, permission authority and website design
|
||||
remain their existing systems. `underwater` remains a selectable theme.
|
||||
remain their existing systems. The terminal default does not change app defaults.
|
||||
|
||||
This document describes implemented terminal behavior, not the website's
|
||||
Tidal Folio system. Source owns values and actions; this file records how
|
||||
@@ -25,7 +31,11 @@ they form a coherent interface.
|
||||
|
||||
## Colors
|
||||
|
||||
The source of truth is `crates/palette/src/tokens.rs` and the
|
||||
Underwater's continuous water column is authored by `OceanRamp::for_theme` in
|
||||
`crates/tui/src/tui/ocean.rs`: dark navy at the top, deeper near the composer.
|
||||
Its existing motion policy preserves reduced/still modes and semantic surfaces.
|
||||
|
||||
For the charcoal alternative, the source of truth is `crates/palette/src/tokens.rs` and the
|
||||
`SHORELINE_UI_THEME` / `SHORELINE_LIGHT_UI_THEME` mappings in
|
||||
`crates/palette/src/themes.rs`. The dark palette is unchanged by the
|
||||
Workbench index pass:
|
||||
@@ -39,14 +49,14 @@ Workbench index pass:
|
||||
| Border | `#49424D` | Quiet boundaries and inactive composer |
|
||||
| Body | `#F2ECE5` | Primary ivory text |
|
||||
| Soft / muted / hint / dim | `#D9D2DC` / `#B0A7B2` / `#9A919F` / `#7E7583` | Secondary information by role |
|
||||
| Action / selection background | `#90B9FF` / `#354967` | Affordances and focused rows |
|
||||
| Action / selection background | `#67B8D6` / `#2C4654` | Affordances and focused rows |
|
||||
| Live | `#7FD6C6` | Live activity |
|
||||
| Human | `#F6C453` | Human input and decisions |
|
||||
| Warning / danger / success | `#F0A868` / `#FF8FA8` / `#A3D977` | Semantic state |
|
||||
| Mode ramp | `#7EB4E8` / `#B9DCEC` / `#AD88FF` / `#FF70A0` | Existing mode distinctions |
|
||||
|
||||
Shoreline Light uses warm paper (`#F5F0E9` field) and blue action
|
||||
(`#245BC7`); its full mapping remains in the same source. Neither this pass
|
||||
(`#006684`); its full mapping remains in the same source. Neither this pass
|
||||
nor the Shoreline token family re-inks `web/app/tokens.css`.
|
||||
|
||||
**Focus uses selection ink on selection blue.** Bright action blue is an
|
||||
@@ -68,7 +78,8 @@ The terminal host owns the font, size and rasterization. There is no separate
|
||||
application display face. Hierarchy comes from bold identity and selection,
|
||||
regular body text, secondary metadata, and spacing between groups.
|
||||
|
||||
Session titles take priority over age and message count. Width calculations
|
||||
Home shows session titles and age; message counts stay in session details.
|
||||
An empty workspace omits the Recent section. Titles take priority over age. Width calculations
|
||||
and truncation use terminal display cells, including wide characters; omit
|
||||
metadata before reducing a useful title to a stub. Longer labels receive an
|
||||
explicit truncation marker rather than silently running under a border.
|
||||
|
||||
Reference in new issue
Block a user