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:
CodeWhale Bot committed 2026-09-20 02:35:45 -07:00
1 parent 9426d193ed
commit ad20493c0e
14 files changed
+605 -244

No files matched your search

+3 -3
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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!(
+102 -53
View File
@@ -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]
}
+221 -14
View File
@@ -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}");
}
}
}
+23 -54
View File
@@ -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));
}
}
+1 -2
View File
@@ -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};
+3 -39
View File
@@ -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]
+4 -4
View File
@@ -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)
+40 -35
View File
@@ -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]
+19 -8
View File
@@ -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.