mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
fix(tui): complete Tideline shell integration
This commit is contained in:
1 parent
927d9a0c25
commit
9c8814e8cf
28 files changed
+710
-1017
No files matched your search
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- TUI: startup no longer presents an approximate ASCII or block-glyph whale as
|
||||
the product mark. It keeps the direct Tideline prompt while exact-raster
|
||||
surfaces remain responsible for the canonical asset.
|
||||
|
||||
- TUI: the active-session composer paints the same three-cell `[↑]` send
|
||||
target as Startup and clicks it through the existing Enter submit
|
||||
dispatcher (#5771). Compact/quiet composers still omit the control.
|
||||
|
||||
@@ -5,9 +5,9 @@ Read the repository guidance first.
|
||||
|
||||
## UI contracts
|
||||
|
||||
- One owner per fact: mode/permission/context in the header; work in the top
|
||||
strip; receipts and the active row in the transcript; phase, route identity,
|
||||
cost, and detail controls in the footer.
|
||||
- One owner per fact: route and context in the Tideline topbar; work in the top
|
||||
strip; receipts and the active row in the transcript; phase, cost, detail,
|
||||
mode, and permission in the merged footer.
|
||||
- Status-bar ink goes through `palette::grammar` (`docs/design/STATUS_BAR_COLOR_GRAMMAR.md`).
|
||||
Do not invent an eighth semantic or spend Failure red on non-failure chrome.
|
||||
- Derive state from typed enums such as `ShellPhase` and `OceanTreatment`.
|
||||
|
||||
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- TUI: startup no longer presents an approximate ASCII or block-glyph whale as
|
||||
the product mark. It keeps the direct Tideline prompt while exact-raster
|
||||
surfaces remain responsible for the canonical asset.
|
||||
|
||||
- TUI: the active-session composer paints the same three-cell `[↑]` send
|
||||
target as Startup and clicks it through the existing Enter submit
|
||||
dispatcher (#5771). Compact/quiet composers still omit the control.
|
||||
|
||||
@@ -117,6 +117,7 @@ impl PendingWork {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[cfg(test)]
|
||||
pub fn count_state(&self, state: PendingItemState) -> usize {
|
||||
self.items.iter().filter(|item| item.state == state).count()
|
||||
}
|
||||
@@ -179,6 +180,7 @@ pub fn pending_work_from_app(app: &App) -> PendingWork {
|
||||
/// Build the complete live-work projection for an active-session status
|
||||
/// surface. Unlike [`pending_work_from_app`], this includes live shell jobs.
|
||||
#[must_use]
|
||||
#[cfg(test)]
|
||||
pub(crate) fn live_work_from_app(app: &App) -> PendingWork {
|
||||
collect_pending_work(app, true)
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@
|
||||
|
||||
|
||||
|
||||
<·> thinking 1m 15s │ $0.42 · 61K tok · act · ask ▁▂▄▆▆∿∿∿∿ 61%
|
||||
<·> thinking 1m 15s │ $0.42 · 61K tok · act · ask ▁▂▄▆▆∿∿∿∿ 61% ? help
|
||||
@@ -1,14 +1,14 @@
|
||||
|
||||
▄▄▄▄██▌
|
||||
▜████▀▘
|
||||
▟██▄▄
|
||||
▟█████▖
|
||||
▐█▟▀█████
|
||||
▐█▜██████
|
||||
|
||||
|
||||
|
||||
What are we working on?
|
||||
welcome back · 4 saved sessions in this workspace
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
⋯ ∼∼∼ ⋯
|
||||
QUICK ACTIONS
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
|
||||
|
||||
▄▄▄▄██▌
|
||||
▜████▀▘
|
||||
▟██▄▄
|
||||
▟█████▖
|
||||
▐█▟▀█████
|
||||
▐█▜██████
|
||||
|
||||
|
||||
|
||||
What are we working on?
|
||||
welcome back · 4 saved sessions in this workspace
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
⋯ ∼∼∼ ⋯
|
||||
QUICK ACTIONS
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
|
||||
|
||||
|
||||
▄▄▄▄██▌
|
||||
▜████▀▘
|
||||
▟██▄▄
|
||||
▟█████▖
|
||||
▐█▟▀█████
|
||||
▐█▜██████
|
||||
|
||||
|
||||
|
||||
What are we working on?
|
||||
welcome back · 4 saved sessions in this workspace
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
⋯ ∼∼∼ ⋯
|
||||
QUICK ACTIONS
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
What are we working on?
|
||||
welcome back · 4 saved sessions in this
|
||||
welcome back · 4 saved sessions in this…
|
||||
|
||||
▸ ⌁ New session Enter ›
|
||||
◌ Chat only C ›
|
||||
↺ Resume last Ctrl+R ›
|
||||
|
||||
⑂ New worktree ◌ Chat only
|
||||
Ctrl+N C
|
||||
⑂ worktree◌ chat ◐ Theme ? Help
|
||||
Ctrl+N C T F1
|
||||
❯ Type a message… ↑
|
||||
@@ -1,12 +1,12 @@
|
||||
▄▄▄▄██▌
|
||||
▜████▀▘
|
||||
▟██▄▄
|
||||
▟█████▖
|
||||
▐█▟▀█████
|
||||
▐█▜██████
|
||||
|
||||
|
||||
|
||||
What are we working on?
|
||||
welcome back · 4 saved sessions in this workspace
|
||||
|
||||
|
||||
|
||||
|
||||
⋯ ∼∼∼ ⋯
|
||||
QUICK ACTIONS
|
||||
|
||||
|
||||
@@ -728,6 +728,25 @@ fn tput(buf: &mut Buffer, x: u16, y: u16, text: &str, style: Style) {
|
||||
buf.set_stringn(x, y, text, text.width(), style);
|
||||
}
|
||||
|
||||
const COMPACT_KEYS_LEGEND: &str = "? help";
|
||||
|
||||
struct TrailingExtra {
|
||||
text: String,
|
||||
ink: ChromeInk,
|
||||
append_help: bool,
|
||||
}
|
||||
|
||||
impl TrailingExtra {
|
||||
fn width(&self, footer: &TidelineFooter<'_>) -> usize {
|
||||
self.text.width()
|
||||
+ if self.append_help {
|
||||
ITEM_SEPARATOR_WIDTH + footer.sym(COMPACT_KEYS_LEGEND).width()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint the merged footer band (spec §5b: `Constraint::Length(1)`).
|
||||
///
|
||||
/// Left half: still-frame echolocation chip, phase word, live detail, the
|
||||
@@ -752,28 +771,15 @@ pub fn render_tideline_footer(area: Rect, buf: &mut Buffer, footer: &TidelineFoo
|
||||
right = format!("{} {right}", footer.sym("▲"));
|
||||
}
|
||||
let right_base_w = right.width() as u16;
|
||||
let warn_text = footer.sym(DEPTH_WARN);
|
||||
let keys = footer.sym(footer.keys_legend);
|
||||
let notice_text = footer.notice.map(|(text, ink)| (footer.sym(text), ink));
|
||||
let depth_ink = footer.depth_ink();
|
||||
|
||||
// Trailing-slot precedence: a live notice outranks the cap warning,
|
||||
// which outranks the posture chips, which outrank the key chorus (the
|
||||
// classic bands' own rule that identity outranks hints). A notice was
|
||||
// clause-fitted at build time, so the whole phrase lands or the band
|
||||
// was too narrow for it anyway.
|
||||
let extra: (&str, crate::palette::ChromeInk) = if let Some((text, ink)) = ¬ice_text {
|
||||
(text.as_str(), *ink)
|
||||
} else if warn {
|
||||
(warn_text.as_str(), crate::palette::ChromeInk::Attention)
|
||||
} else if trailing_extra_width(footer, area.width) > 0 {
|
||||
(keys.as_str(), crate::palette::ChromeInk::MetadataHint)
|
||||
} else {
|
||||
// The chorus stands down so the posture chips fit beside the depth
|
||||
// line; if even that is not enough, the chips stand down below.
|
||||
("", crate::palette::ChromeInk::MetadataHint)
|
||||
};
|
||||
let right_width = right_base_w + 1 + extra.0.width() as u16 + 1;
|
||||
// classic bands' own rule that identity outranks hints). A non-urgent
|
||||
// notice may keep the compact help route when both fit beside the minimum
|
||||
// phase/depth floor; warnings and failures remain undiluted.
|
||||
let extra = trailing_extra(footer, area.width, usize::from(right_base_w));
|
||||
let right_width = right_base_w + 1 + extra.width(footer) as u16 + 1;
|
||||
|
||||
// Left: still-frame echolocation chip + phase word + live detail + cost.
|
||||
let chip = footer.sym("<·>");
|
||||
@@ -857,13 +863,31 @@ pub fn render_tideline_footer(area: Rect, buf: &mut Buffer, footer: &TidelineFoo
|
||||
tput(buf, sx, area.y, &right, tchrome(theme, depth_ink));
|
||||
sx += right.width() as u16 + 1;
|
||||
let budget = (area.x + area.width).saturating_sub(sx) as usize;
|
||||
tput(
|
||||
buf,
|
||||
sx,
|
||||
area.y,
|
||||
&truncate_owned(extra.0, budget),
|
||||
tchrome(theme, extra.1),
|
||||
);
|
||||
let text_budget = budget.saturating_sub(if extra.append_help {
|
||||
ITEM_SEPARATOR_WIDTH + footer.sym(COMPACT_KEYS_LEGEND).width()
|
||||
} else {
|
||||
0
|
||||
});
|
||||
let painted = truncate_owned(&extra.text, text_budget);
|
||||
tput(buf, sx, area.y, &painted, tchrome(theme, extra.ink));
|
||||
sx += painted.width() as u16;
|
||||
if extra.append_help {
|
||||
tput(
|
||||
buf,
|
||||
sx,
|
||||
area.y,
|
||||
ITEM_SEPARATOR,
|
||||
tchrome(theme, ChromeInk::MetadataDim),
|
||||
);
|
||||
sx += ITEM_SEPARATOR_WIDTH as u16;
|
||||
tput(
|
||||
buf,
|
||||
sx,
|
||||
area.y,
|
||||
&footer.sym(COMPACT_KEYS_LEGEND),
|
||||
tchrome(theme, ChromeInk::MetadataHint),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_owned(text: &str, width: usize) -> String {
|
||||
@@ -880,27 +904,20 @@ fn truncate_owned(text: &str, width: usize) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The trailing right-slot's width at this row width, shared by the render
|
||||
/// and the depth hitbox so the two can never disagree. A live notice or the
|
||||
/// cap warning keeps its full width; the key chorus keeps its width only
|
||||
/// while the posture chips still fit beside it — otherwise it stands down
|
||||
/// and the width is zero.
|
||||
fn trailing_extra_width(footer: &TidelineFooter<'_>, area_width: u16) -> usize {
|
||||
/// Pick the full key chorus when it fits without displacing posture. When it
|
||||
/// does not, keep the pinned help route if the minimum phase/depth facts still
|
||||
/// fit; small terminals should not lose every discoverable keyboard action.
|
||||
fn trailing_keys_legend(footer: &TidelineFooter<'_>, area_width: u16) -> String {
|
||||
let pct = footer.context_percent.clamp(0, 100);
|
||||
let keys_w = footer.sym(footer.keys_legend).width();
|
||||
if let Some((text, _)) = footer.notice {
|
||||
return footer.sym(text).width();
|
||||
}
|
||||
if pct >= 80 {
|
||||
return footer.sym(DEPTH_WARN).width();
|
||||
}
|
||||
let keys = footer.sym(footer.keys_legend);
|
||||
let keys_w = keys.width();
|
||||
let posture_w: usize = [footer.mode_chip, footer.permission_chip]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(text, _)| ITEM_SEPARATOR_WIDTH + footer.sym(text).width())
|
||||
.sum();
|
||||
if posture_w == 0 {
|
||||
return keys_w;
|
||||
return keys;
|
||||
}
|
||||
// The left half's standing width before the posture chips: chip, phase
|
||||
// word, live detail, divider, cost.
|
||||
@@ -918,10 +935,72 @@ fn trailing_extra_width(footer: &TidelineFooter<'_>, area_width: u16) -> usize {
|
||||
.saturating_sub(right_base_w + 1 + keys_w + 1)
|
||||
.saturating_sub(1);
|
||||
if prefix_w + posture_w <= available {
|
||||
keys_w
|
||||
} else {
|
||||
0
|
||||
return keys;
|
||||
}
|
||||
|
||||
let compact = footer.sym(COMPACT_KEYS_LEGEND);
|
||||
let compact_floor =
|
||||
chip.width() + 1 + phase.width() + 1 + right_base_w + 1 + compact.width() + 1;
|
||||
if compact_floor <= usize::from(area_width) {
|
||||
compact
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn minimum_footer_width(footer: &TidelineFooter<'_>, right_base_w: usize, extra_w: usize) -> usize {
|
||||
let chip = footer.sym("<·>");
|
||||
let phase = footer.sym(footer.phase_word);
|
||||
let detail_w = footer
|
||||
.live_detail
|
||||
.map(|detail| footer.sym(detail).width() + 1)
|
||||
.unwrap_or(0);
|
||||
let left_floor = chip.width() + 1 + phase.width() + 1 + detail_w;
|
||||
let right = right_base_w + 1 + extra_w + 1;
|
||||
left_floor + 1 + right
|
||||
}
|
||||
|
||||
fn trailing_extra(
|
||||
footer: &TidelineFooter<'_>,
|
||||
area_width: u16,
|
||||
right_base_w: usize,
|
||||
) -> TrailingExtra {
|
||||
if let Some((text, ink)) = footer.notice {
|
||||
let text = footer.sym(text);
|
||||
let help = footer.sym(COMPACT_KEYS_LEGEND);
|
||||
let can_append_help = !matches!(ink, ChromeInk::Attention | ChromeInk::Failure)
|
||||
&& minimum_footer_width(
|
||||
footer,
|
||||
right_base_w,
|
||||
text.width() + ITEM_SEPARATOR_WIDTH + help.width(),
|
||||
) <= usize::from(area_width);
|
||||
return TrailingExtra {
|
||||
text,
|
||||
ink,
|
||||
append_help: can_append_help,
|
||||
};
|
||||
}
|
||||
if footer.context_percent.clamp(0, 100) >= 80 {
|
||||
return TrailingExtra {
|
||||
text: footer.sym(DEPTH_WARN),
|
||||
ink: ChromeInk::Attention,
|
||||
append_help: false,
|
||||
};
|
||||
}
|
||||
TrailingExtra {
|
||||
text: trailing_keys_legend(footer, area_width),
|
||||
ink: ChromeInk::MetadataHint,
|
||||
append_help: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The trailing right-slot's width at this row width, shared by the render
|
||||
/// and the depth hitbox so the two can never disagree.
|
||||
fn trailing_extra_width(footer: &TidelineFooter<'_>, area_width: u16) -> usize {
|
||||
let pct = footer.context_percent.clamp(0, 100);
|
||||
let depth = footer.depth_cells();
|
||||
let right_base_w = format!("{depth} {pct}%").width();
|
||||
trailing_extra(footer, area_width, right_base_w).width(footer)
|
||||
}
|
||||
|
||||
/// Depth-segment hitbox → context inspector (spec §6). Returns the rect
|
||||
|
||||
@@ -92,6 +92,17 @@ fn footer_merges_phase_cost_left_and_depth_keys_right() {
|
||||
assert!(band.ends_with(KEYS), "keys legend right: {band}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_keeps_help_discoverable_when_the_full_legend_sheds() {
|
||||
let footer = thinking_footer().widget(&UI_THEME);
|
||||
let text = draw(80, 24, &footer);
|
||||
assert!(text.contains("? help"), "{text}");
|
||||
assert!(
|
||||
!text.contains("Enter send"),
|
||||
"compact footer sheds the chorus: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// §3: the old header's mode/permission chips move into the footer's left
|
||||
/// half. A chip that cannot fit whole stands down rather than clipping —
|
||||
/// the classic header's own rule for posture words.
|
||||
@@ -131,6 +142,17 @@ fn footer_notice_owns_the_trailing_slot_over_the_keys() {
|
||||
assert!(text.contains("61%"), "the depth line stays: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonurgent_notice_keeps_compact_help_when_the_floor_fits() {
|
||||
let mut fixture = thinking_footer();
|
||||
fixture.phase_word = "draft";
|
||||
fixture.live_detail = None;
|
||||
fixture.notice = Some(("Auto-compaction enabled", ChromeInk::Info));
|
||||
let text = draw(60, 16, &fixture.widget(&UI_THEME));
|
||||
assert!(text.contains("Auto-compaction enabled"), "{text}");
|
||||
assert!(text.contains("? help"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_depth_line_is_a_hand_built_ramp_not_a_gauge() {
|
||||
let footer = TidelineFooter::new(&UI_THEME, "idle", ChromeInk::Metadata, "$0.00", 61, KEYS);
|
||||
|
||||
@@ -6,22 +6,20 @@
|
||||
//! caller owns facts; this module owns cells.
|
||||
//!
|
||||
//! The brand lockup is the `CODEWHALE` wordmark alone, in the sanctioned
|
||||
//! whale-mark gold. There is no glyph before it by founder decree: terminal
|
||||
//! marks must be generated from the brand master path, the generated mark is
|
||||
//! 12x6+ cells, and every attempt to shrink it into one row was judged
|
||||
//! illegible — a one-row topbar cannot carry it. The hand-drawn crown glyph
|
||||
//! (and its ASCII projection) was ordered deleted everywhere and is gone
|
||||
//! from this module.
|
||||
//! whale-mark gold. There is no glyph before it by founder decree: the
|
||||
//! canonical mark is a raster asset with no approved ASCII or block-glyph
|
||||
//! substitute, and a one-row topbar cannot carry it faithfully. The retired
|
||||
//! hand-drawn crown glyph is absent from this module.
|
||||
//!
|
||||
//! Segment grammar (left → right): brand lockup, then contextual segments as
|
||||
//! `label value` pairs joined by `│`, then the pinned right side — context
|
||||
//! meter and clock. Segments shed in a declared order as width drops; brand,
|
||||
//! context meter, and clock are the guaranteed floor (spec §5b shed order).
|
||||
//!
|
||||
//! Clickability: every segment records a `Rect` hitbox so `mouse_ui` can map
|
||||
//! a click to its action (spec §5a). Hitboxes are collected through
|
||||
//! [`render_topbar`] into any sink — the live shell stores them on
|
||||
//! `ViewportState` exactly like `last_workflow_panel_area`.
|
||||
//! Interaction: segment geometry is recorded for parity tests, but only the
|
||||
//! effective model/route segment and the pinned context meter advertise an
|
||||
//! action in the live shell. Status-only facts do not brighten on hover or
|
||||
//! pretend to be controls.
|
||||
//!
|
||||
//! Color: semantic ink only ([`ChromeInk`]); no hex, per the status-bar color
|
||||
//! grammar. ASCII-safe mode substitutes every glyph through
|
||||
@@ -49,23 +47,23 @@ const METER_CELLS: usize = 5;
|
||||
/// module docs). Pure ASCII, so it never widens under ascii-safe mode.
|
||||
const WORDMARK: &str = "CODEWHALE";
|
||||
|
||||
/// Identity of a clickable topbar segment. One variant per action the
|
||||
/// approved screens expose (spec §5a: every segment has an owner action).
|
||||
/// Identity of a topbar segment. Most variants are status facts; the live
|
||||
/// shell currently registers an action only for [`Self::Model`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TopbarSegmentId {
|
||||
/// The brand lockup — click opens the product menu.
|
||||
/// The brand lockup — status-only until a product menu exists.
|
||||
Brand,
|
||||
/// Workspace path (startup) — click opens the workspace details.
|
||||
/// Workspace path (startup).
|
||||
Workspace,
|
||||
/// Current run (work screen) — click opens the run dashboard.
|
||||
/// Current run (work screen).
|
||||
Run,
|
||||
/// Active pod (work screen) — click opens the pod ledger.
|
||||
/// Active pod (work screen).
|
||||
Pod,
|
||||
/// Whale capacity `n/m` (work screen) — click opens the pod roster.
|
||||
/// Whale capacity `n/m` (work screen).
|
||||
Whales,
|
||||
/// Effective model / route — click opens the provider inspector.
|
||||
Model,
|
||||
/// Theme name (startup) — click opens the theme picker.
|
||||
/// Theme name (startup).
|
||||
Theme,
|
||||
/// Settings breadcrumb (settings screen) — click walks up one category.
|
||||
/// Not constructed by the main shell yet: the settings screen is a
|
||||
@@ -137,7 +135,8 @@ pub struct Topbar<'a> {
|
||||
pub context_percent: u8,
|
||||
/// Contextual segments in display order.
|
||||
pub segments: &'a [TopbarSegment],
|
||||
/// Segment under the mouse (hover affordance: value brightens).
|
||||
/// Actionable segment under the mouse. Only [`TopbarSegmentId::Model`]
|
||||
/// currently advertises hover feedback in the live shell.
|
||||
pub hovered: Option<TopbarSegmentId>,
|
||||
/// ASCII-safe / NO_COLOR mode: every glyph goes through
|
||||
/// [`glyphs::ascii_fallback`].
|
||||
@@ -368,7 +367,8 @@ impl Widget for Topbar<'_> {
|
||||
);
|
||||
x += SEGMENT_JOIN.width();
|
||||
}
|
||||
let hovered = self.hovered == Some(segment.id);
|
||||
let hovered = segment.id == TopbarSegmentId::Model
|
||||
&& self.hovered == Some(TopbarSegmentId::Model);
|
||||
let mut style = chrome(theme, segment.ink);
|
||||
if hovered {
|
||||
style = style
|
||||
@@ -428,9 +428,8 @@ pub struct TopbarHitbox {
|
||||
|
||||
/// Compute the hitbox `Rect` for each kept segment. Must be called with the
|
||||
/// same inputs as the render so the rects match the painted cells exactly.
|
||||
/// The brand lockup and the pinned right block are hitboxes too (brand opens
|
||||
/// the product menu; context opens the context inspector) — returned first
|
||||
/// and last respectively.
|
||||
/// The brand lockup is included as recorded geometry, though it is status-only
|
||||
/// in the live shell. The context meter has its own exact hitbox helper.
|
||||
#[must_use]
|
||||
pub fn topbar_hitboxes(topbar: &Topbar<'_>, area: Rect) -> Vec<TopbarHitbox> {
|
||||
let mut out = Vec::new();
|
||||
@@ -438,31 +437,37 @@ pub fn topbar_hitboxes(topbar: &Topbar<'_>, area: Rect) -> Vec<TopbarHitbox> {
|
||||
return out;
|
||||
}
|
||||
let brand_w = brand_width();
|
||||
out.push(TopbarHitbox {
|
||||
id: TopbarSegmentId::Brand,
|
||||
area: Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: brand_w as u16,
|
||||
height: 1,
|
||||
},
|
||||
});
|
||||
if brand_w <= usize::from(area.width) {
|
||||
out.push(TopbarHitbox {
|
||||
id: TopbarSegmentId::Brand,
|
||||
area: Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: brand_w as u16,
|
||||
height: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
let mut x = area.x as usize + brand_w + BRAND_GAP.width();
|
||||
let join_w = SEGMENT_JOIN.width();
|
||||
for (index, segment) in topbar.segments.iter().enumerate() {
|
||||
let shed = shed_pass(topbar, area);
|
||||
let right_start = usize::from(area.x + area.width).saturating_sub(shed.right_width);
|
||||
for (index, segment) in shed.kept.iter().enumerate() {
|
||||
if index > 0 {
|
||||
x += join_w;
|
||||
}
|
||||
let w = segment.rendered_width();
|
||||
out.push(TopbarHitbox {
|
||||
id: segment.id,
|
||||
area: Rect {
|
||||
x: x as u16,
|
||||
y: area.y,
|
||||
width: w as u16,
|
||||
height: 1,
|
||||
},
|
||||
});
|
||||
if x + w <= right_start && x + w <= usize::from(area.x + area.width) {
|
||||
out.push(TopbarHitbox {
|
||||
id: segment.id,
|
||||
area: Rect {
|
||||
x: x as u16,
|
||||
y: area.y,
|
||||
width: w as u16,
|
||||
height: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
x += w;
|
||||
}
|
||||
out
|
||||
|
||||
@@ -241,6 +241,33 @@ fn topbar_hitboxes_match_painted_cells() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topbar_hitboxes_follow_the_same_shed_pass_as_paint() {
|
||||
let segments = work_segments();
|
||||
for width in [120u16, 80, 60, 44, 30, 20] {
|
||||
let topbar = Topbar::new(&UI_THEME, CLOCK, 61, &segments);
|
||||
let hitboxes = super::topbar_hitboxes(&topbar, ratatui::layout::Rect::new(0, 0, width, 1));
|
||||
let cells = render_cells(&UI_THEME, width, &segments, 61);
|
||||
for hitbox in hitboxes {
|
||||
assert!(
|
||||
hitbox.area.right() <= width,
|
||||
"{width}: hitbox {:?} escapes the row: {:?}",
|
||||
hitbox.id,
|
||||
hitbox.area
|
||||
);
|
||||
let text: String = (hitbox.area.x..hitbox.area.right())
|
||||
.filter_map(|x| cells.get(usize::from(x)))
|
||||
.cloned()
|
||||
.collect();
|
||||
assert!(
|
||||
!text.trim().is_empty(),
|
||||
"{width}: hitbox {:?} covers unpainted cells",
|
||||
hitbox.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-cell symbols of one rendered row (the golden dump, before joining).
|
||||
fn render_cells(theme: &UiTheme, width: u16, segments: &[TopbarSegment], pct: u8) -> Vec<String> {
|
||||
let backend = TestBackend::new(width, 1);
|
||||
|
||||
@@ -157,7 +157,12 @@ fn render_topbar_row(f: &mut Frame, app: &mut App, area: Rect) -> TopbarInteract
|
||||
app.viewport
|
||||
.last_topbar_hitboxes
|
||||
.iter()
|
||||
.find(|hb| hb.area.x <= mx && mx < hb.area.right() && hb.area.y == my)
|
||||
.find(|hb| {
|
||||
hb.id == TopbarSegmentId::Model
|
||||
&& hb.area.x <= mx
|
||||
&& mx < hb.area.right()
|
||||
&& hb.area.y == my
|
||||
})
|
||||
.map(|hb| hb.id)
|
||||
});
|
||||
let topbar = Topbar::new(
|
||||
@@ -1223,7 +1228,7 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
|
||||
let composer_slot = 6;
|
||||
let footer_slot = 7;
|
||||
|
||||
let (legacy_work_chat_area, side_work_area) = if mini && !mini_cfg.keep_sidebar {
|
||||
let (work_chat_area, side_work_area) = if mini && !mini_cfg.keep_sidebar {
|
||||
// Mini mode without the side rail: the transcript takes the whole
|
||||
// chat row. split_chat is skipped so the rail never reserves columns.
|
||||
(body_chunks[1], None)
|
||||
@@ -1231,44 +1236,11 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
|
||||
crate::tui::work_surface::split_chat(app, body_chunks[1], rail_min_chat_width(idle_empty))
|
||||
};
|
||||
|
||||
// The legacy work surface continues to own its configured placement and
|
||||
// detailed rows. In a normal active session, add the compact Tideline
|
||||
// state rail beside the transcript even when the normal default Left
|
||||
// surface has no rows to reserve. A populated legacy side surface wins,
|
||||
// so the two rails can never overlap or duplicate each other. The summary
|
||||
// is deliberately passive until its controls have registered keyboard +
|
||||
// mouse behavior.
|
||||
let tideline_rail_width = if mini {
|
||||
0
|
||||
} else {
|
||||
crate::tui::work_surface::tideline::active_session_tideline_rail_width(
|
||||
app,
|
||||
legacy_work_chat_area,
|
||||
side_work_area.is_some(),
|
||||
)
|
||||
};
|
||||
let (tideline_live_rail_area, work_chat_area) = if tideline_rail_width == 0 {
|
||||
(None, legacy_work_chat_area)
|
||||
} else {
|
||||
let [rail_area, chat_area] =
|
||||
Layout::horizontal([Constraint::Length(tideline_rail_width), Constraint::Min(1)])
|
||||
.areas(legacy_work_chat_area);
|
||||
(Some(rail_area), chat_area)
|
||||
};
|
||||
|
||||
if top_work_strip_height > 0 {
|
||||
crate::tui::work_surface::render(f, body_chunks[0], app);
|
||||
} else if let Some(work_area) = side_work_area {
|
||||
crate::tui::work_surface::render(f, work_area, app);
|
||||
}
|
||||
if let Some(rail_area) = tideline_live_rail_area {
|
||||
crate::tui::work_surface::tideline::render_active_session_tideline_rail(
|
||||
rail_area,
|
||||
f.buffer_mut(),
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
// The Tideline topbar owns the header slot (spec §3: it replaces
|
||||
// `underwater::render_header`). One row: brand wordmark, contextual
|
||||
// segments, then the pinned context meter + clock. The route identity
|
||||
@@ -1492,9 +1464,6 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
|
||||
if let Some(side_area) = side_work_area {
|
||||
column.paint_matching(side_area, f.buffer_mut(), app.ui_theme.surface_bg);
|
||||
}
|
||||
if let Some(rail_area) = tideline_live_rail_area {
|
||||
column.paint_matching(rail_area, f.buffer_mut(), app.ui_theme.surface_bg);
|
||||
}
|
||||
column.paint_matching(work_chat_area, f.buffer_mut(), app.ui_theme.surface_bg);
|
||||
column.paint_matching(body_chunks[2], f.buffer_mut(), app.ui_theme.surface_bg);
|
||||
column.paint_matching(body_chunks[3], f.buffer_mut(), app.ui_theme.surface_bg);
|
||||
|
||||
@@ -23076,7 +23076,6 @@ fn backtrack_cut_index_skips_tool_result_user_messages() {
|
||||
/// name the same filter reaches them: `tui::ui::tests::work_surface::…`.
|
||||
mod work_surface {
|
||||
use super::*;
|
||||
use crate::test_support::{EnvVarGuard, lock_test_env};
|
||||
use crate::tui::ui::{rail_min_chat_width, rail_row_budget};
|
||||
use crate::tui::underwater::AMBIENT_MIN_CHAT_WIDTH;
|
||||
use crate::tui::widgets::should_render_empty_state;
|
||||
@@ -23105,182 +23104,6 @@ mod work_surface {
|
||||
app
|
||||
}
|
||||
|
||||
fn active_tideline_app() -> App {
|
||||
let mut app = idle_rail_app(RailPanel::Tasks);
|
||||
app.launch.visible = false;
|
||||
app.current_session_id = Some("tideline-live-rail".to_string());
|
||||
assert!(
|
||||
app.todos
|
||||
.try_lock()
|
||||
.expect("todos lock")
|
||||
.snapshot()
|
||||
.is_empty(),
|
||||
"the active-session rail must not depend on a checklist item"
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_session_tideline_rail_uses_the_width_ladder_without_fake_controls() {
|
||||
let mut narrow = active_tideline_app();
|
||||
let narrow_text = render_underwater_test_app(&mut narrow, 80, 32);
|
||||
assert!(
|
||||
!narrow_text.contains("RUNS"),
|
||||
"the Tideline rail must yield below 100 columns:\n{narrow_text}"
|
||||
);
|
||||
assert_eq!(
|
||||
narrow
|
||||
.viewport
|
||||
.last_transcript_area
|
||||
.expect("80-column transcript")
|
||||
.x,
|
||||
0,
|
||||
"80 columns retain the existing compact transcript layout"
|
||||
);
|
||||
|
||||
let mut medium = active_tideline_app();
|
||||
let medium_text = render_underwater_test_app(&mut medium, 100, 32);
|
||||
for label in ["RUNS", "WHALES", "POD", "WORK", "CONTEXT"] {
|
||||
assert!(
|
||||
medium_text.contains(label),
|
||||
"100-column rail misses {label}:\n{medium_text}"
|
||||
);
|
||||
}
|
||||
assert!(medium_text.contains("idle"), "{medium_text}");
|
||||
assert!(
|
||||
!medium_text.contains("« collapse") && !medium_text.contains("? help"),
|
||||
"passive state summaries must not paint inert controls:\n{medium_text}"
|
||||
);
|
||||
assert_eq!(
|
||||
medium
|
||||
.viewport
|
||||
.last_transcript_area
|
||||
.expect("100-column transcript")
|
||||
.x,
|
||||
16,
|
||||
"100–119 columns reserve the compact 16-column rail"
|
||||
);
|
||||
|
||||
let mut wide = active_tideline_app();
|
||||
let wide_text = render_underwater_test_app(&mut wide, 120, 32);
|
||||
assert!(wide_text.contains("RUNS"), "{wide_text}");
|
||||
assert_eq!(
|
||||
wide.viewport
|
||||
.last_transcript_area
|
||||
.expect("120-column transcript")
|
||||
.x,
|
||||
22,
|
||||
"120+ columns reserve the full 22-column rail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_left_active_session_uses_tideline_when_legacy_surface_is_empty() {
|
||||
// Exercise the real Settings default through App initialization rather
|
||||
// than forcing a placement in the fixture. The process-wide env lock
|
||||
// keeps the isolated CODEWHALE_HOME from racing other settings tests.
|
||||
let _env_lock = lock_test_env();
|
||||
let settings_root = tempfile::tempdir().expect("isolated settings root");
|
||||
let default_home = settings_root.path().join("codewhale-home");
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", &default_home);
|
||||
assert_eq!(
|
||||
crate::settings::Settings::load_persisted()
|
||||
.expect("load default settings")
|
||||
.work_surface_placement,
|
||||
"left",
|
||||
"the app-level regression must start from the shipped Settings default"
|
||||
);
|
||||
|
||||
for (width, expected_x) in [(100, 16), (120, 22)] {
|
||||
let mut app = App::new(
|
||||
crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
|
||||
&Config::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
app.work_surface.placement,
|
||||
WorkSurfacePlacement::Left,
|
||||
"App must retain the shipped default placement at {width} columns"
|
||||
);
|
||||
app.launch.visible = false;
|
||||
app.ui_locale = crate::localization::Locale::En;
|
||||
app.current_session_id = Some(format!("tideline-default-left-{width}"));
|
||||
assert!(
|
||||
app.todos
|
||||
.try_lock()
|
||||
.expect("todos lock")
|
||||
.snapshot()
|
||||
.is_empty(),
|
||||
"the default Left rail must not need an active checklist"
|
||||
);
|
||||
|
||||
let rendered = render_underwater_test_app(&mut app, width, 32);
|
||||
for label in ["RUNS", "WHALES", "POD", "WORK", "CONTEXT"] {
|
||||
assert!(
|
||||
rendered.contains(label),
|
||||
"default Left rail misses {label} at {width} columns:\n{rendered}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
app.viewport
|
||||
.last_transcript_area
|
||||
.expect("default Left transcript")
|
||||
.x,
|
||||
expected_x,
|
||||
"the default Left layout must use the Tideline width ladder"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupied_left_work_surface_wins_over_the_tideline_summary() {
|
||||
let mut app = active_tideline_app();
|
||||
app.work_surface.placement = WorkSurfacePlacement::Left;
|
||||
app.todos.try_lock().expect("todos lock").add(
|
||||
"legacy side work owns this column".to_string(),
|
||||
crate::tools::todo::TodoStatus::InProgress,
|
||||
);
|
||||
|
||||
let rendered = render_underwater_test_app(&mut app, 120, 32);
|
||||
assert!(
|
||||
rendered.contains("legacy side work"),
|
||||
"the existing detailed work surface must still render:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("RUNS"),
|
||||
"an occupied legacy Left surface must not receive a duplicate Tideline rail:\n{rendered}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.viewport
|
||||
.last_transcript_area
|
||||
.expect("legacy Left transcript")
|
||||
.x,
|
||||
app.work_surface.side_width,
|
||||
"the legacy side surface, not the compact Tideline width, owns this layout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_right_and_off_do_not_enable_the_tideline_summary() {
|
||||
for placement in [WorkSurfacePlacement::Right, WorkSurfacePlacement::Off] {
|
||||
let mut app = active_tideline_app();
|
||||
app.work_surface.placement = placement;
|
||||
|
||||
let rendered = render_underwater_test_app(&mut app, 120, 32);
|
||||
assert!(
|
||||
!rendered.contains("RUNS"),
|
||||
"explicit {placement:?} must not be replaced by the Tideline rail:\n{rendered}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.viewport
|
||||
.last_transcript_area
|
||||
.expect("explicit placement transcript")
|
||||
.x,
|
||||
0,
|
||||
"explicit {placement:?} retains the full transcript when its legacy surface is empty"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The nickname the seeded sub-agent renders under. A marker the test owns,
|
||||
/// rather than chrome the shell might stop drawing.
|
||||
const AGENT_MARK: &str = "railprobe";
|
||||
|
||||
@@ -3011,55 +3011,6 @@ use ratatui::layout::{Constraint, Layout};
|
||||
|
||||
use crate::palette::UiTheme;
|
||||
|
||||
/// The current Codewhale diving-whale mark — the generated 10x6 cell
|
||||
/// rendition from `designs/brand/20260829-codewhale-mark/TUI_GLYPHS.md`,
|
||||
/// produced by `build-tui-glyph.py`; never hand-drawn. Its wave is the `>`
|
||||
/// shell-prompt motif. The retired fluke-ring proposal is deliberately absent
|
||||
/// from this startup path. The ASCII-safe projection maps each quadrant block
|
||||
/// through its declared `glyphs::ascii_fallback` — a legible silhouette, not a
|
||||
/// smear.
|
||||
const CURRENT_MARK_BLOCK: [&str; 6] = [
|
||||
"▄▄▄▄██▌",
|
||||
"▜████▀▘",
|
||||
" ▟██▄▄",
|
||||
" ▟█████▖",
|
||||
" ▐█▟▀█████",
|
||||
" ▐█▜██████",
|
||||
];
|
||||
/// The generated glyph has a seven-cell first row, but all rows share this
|
||||
/// ten-cell canvas. Use the canvas for centering and hitboxes so later rows do
|
||||
/// not shift or escape their recorded area.
|
||||
const CURRENT_MARK_WIDTH: u16 = 10;
|
||||
|
||||
/// Rows of current-mark ink, one `String` per terminal row. `ascii_safe`
|
||||
/// projects each cell through the declared fallbacks (spec §2: every
|
||||
/// authored glyph has one).
|
||||
fn current_mark_rows(ascii_safe: bool) -> Vec<String> {
|
||||
CURRENT_MARK_BLOCK
|
||||
.iter()
|
||||
.map(|row| current_mark_row(row, ascii_safe))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Single-row projection: the unicode row verbatim, or each block through
|
||||
/// its declared ASCII fallback.
|
||||
fn current_mark_row(row: &str, ascii_safe: bool) -> String {
|
||||
if !ascii_safe {
|
||||
return row.to_string();
|
||||
}
|
||||
row.chars()
|
||||
.map(|ch| {
|
||||
if ch == ' ' {
|
||||
ch.to_string()
|
||||
} else {
|
||||
crate::tui::glyphs::ascii_fallback(&ch.to_string())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| ch.to_string())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Static wave rule between the hero and the quick actions (spec §5b). Dim,
|
||||
/// never animated — decoration is opt-in and this is not decoration that
|
||||
/// carries state.
|
||||
@@ -3244,10 +3195,6 @@ impl<'a> TidelineStartup<'a> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn current_mark(&self) -> Vec<String> {
|
||||
current_mark_rows(self.ascii_safe)
|
||||
}
|
||||
}
|
||||
|
||||
fn chrome(theme: &UiTheme, ink: ChromeInk) -> Style {
|
||||
@@ -3288,24 +3235,28 @@ struct StartupLayout {
|
||||
quick_rows_start: u16,
|
||||
/// Row within `hero` where the centered hero block starts.
|
||||
hero_top: u16,
|
||||
/// Whether the 10x6 current mark fits (the fixed canvas, heading, and
|
||||
/// subtitle).
|
||||
current_mark_shown: bool,
|
||||
/// Option-strip column count: 4 tiles need ~14 cells each to name
|
||||
/// themselves whole, so below 56 stage columns the strip sheds to 2
|
||||
/// (§5b shed ⑩) rather than truncate every label mid-word.
|
||||
/// Row within `strip` where labels start. Medium-height stages reclaim the
|
||||
/// purely decorative top padding so the compact composer can keep its hint.
|
||||
strip_content_start: u16,
|
||||
/// The four launch routes stay discoverable at every supported width;
|
||||
/// narrow stages shorten their labels instead of dropping actions.
|
||||
strip_columns: u16,
|
||||
}
|
||||
|
||||
fn startup_layout(stage: Rect) -> StartupLayout {
|
||||
let quick_len: u16 = if stage.height >= 15 { 3 + 2 } else { 3 };
|
||||
let rule_len: u16 = if stage.height >= 11 { 1 } else { 0 };
|
||||
let strip_len: u16 = if (11..18).contains(&stage.height) {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
};
|
||||
let [hero, rule_a, quick, rule_b, strip, tail] = Layout::vertical([
|
||||
Constraint::Percentage(38),
|
||||
Constraint::Length(rule_len),
|
||||
Constraint::Length(quick_len),
|
||||
Constraint::Length(rule_len),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(strip_len),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.areas(stage);
|
||||
@@ -3319,14 +3270,7 @@ fn startup_layout(stage: Rect) -> StartupLayout {
|
||||
height: dock_h,
|
||||
..tail
|
||||
};
|
||||
let current_mark_h = CURRENT_MARK_BLOCK.len() as u16;
|
||||
let current_mark_shown =
|
||||
hero.width >= CURRENT_MARK_WIDTH && hero.height >= current_mark_h.saturating_add(2);
|
||||
let block_h = if current_mark_shown {
|
||||
current_mark_h + 2
|
||||
} else {
|
||||
u16::min(hero.height, 2)
|
||||
};
|
||||
let block_h = u16::min(hero.height, 2);
|
||||
StartupLayout {
|
||||
hero,
|
||||
rule_a,
|
||||
@@ -3336,8 +3280,8 @@ fn startup_layout(stage: Rect) -> StartupLayout {
|
||||
dock,
|
||||
quick_rows_start: quick.height.saturating_sub(3),
|
||||
hero_top: hero.height.saturating_sub(block_h) / 2,
|
||||
current_mark_shown,
|
||||
strip_columns: if strip.width < 56 { 2 } else { 4 },
|
||||
strip_content_start: u16::from(strip_len > 2),
|
||||
strip_columns: 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3350,23 +3294,11 @@ pub fn render_tideline_startup(stage: Rect, buf: &mut Buffer, startup: &Tideline
|
||||
let theme = startup.theme;
|
||||
let layout = startup_layout(stage);
|
||||
|
||||
// Hero: the generated current mark (when the cell budget admits it), the
|
||||
// heading, and one dim subtitle (first-run vs returning) as one vertically
|
||||
// centered block. Each mark row is left-aligned on the same fixed-width
|
||||
// canvas, rather than individually centered by its visible ink width.
|
||||
let mut hero_row = layout.hero_top;
|
||||
if layout.current_mark_shown {
|
||||
let mark_x = layout.hero.x + layout.hero.width.saturating_sub(CURRENT_MARK_WIDTH) / 2;
|
||||
for row in startup.current_mark() {
|
||||
set_span(
|
||||
buf,
|
||||
mark_x,
|
||||
layout.hero.y + hero_row,
|
||||
&Span::styled(row, chrome(theme, ChromeInk::Attention)),
|
||||
);
|
||||
hero_row = hero_row.saturating_add(1);
|
||||
}
|
||||
}
|
||||
// Hero: one direct heading and one dim subtitle (first-run vs returning)
|
||||
// as a vertically centered block. The terminal does not approximate the
|
||||
// canonical raster mark; surfaces capable of rendering that exact asset
|
||||
// own the brand image.
|
||||
let hero_row = layout.hero_top;
|
||||
let heading = "What are we working on?";
|
||||
centered(
|
||||
buf,
|
||||
@@ -3394,7 +3326,10 @@ pub fn render_tideline_startup(stage: Rect, buf: &mut Buffer, startup: &Tideline
|
||||
buf,
|
||||
layout.hero,
|
||||
hero_row.saturating_add(1),
|
||||
&Span::styled(subtitle, chrome(theme, ChromeInk::MetadataHint)),
|
||||
&Span::styled(
|
||||
truncate_to_width(&subtitle, usize::from(layout.hero.width)),
|
||||
chrome(theme, ChromeInk::MetadataHint),
|
||||
),
|
||||
);
|
||||
|
||||
// Static wave rules.
|
||||
@@ -3474,8 +3409,9 @@ pub fn render_tideline_startup(stage: Rect, buf: &mut Buffer, startup: &Tideline
|
||||
);
|
||||
}
|
||||
|
||||
// Option strip: 4 columns × 2 rows (label over key). Tiles shed 4→2
|
||||
// below the 40-column floor so the 80-wide blocker still reads.
|
||||
// Option strip: 4 columns × 2 rows (label over key). At narrow widths the
|
||||
// labels compact to one word; actions never disappear merely because the
|
||||
// terminal is small.
|
||||
let options = startup.options();
|
||||
let columns = layout.strip_columns;
|
||||
let column_w = layout.strip.width / columns.max(1);
|
||||
@@ -3491,19 +3427,32 @@ pub fn render_tideline_startup(stage: Rect, buf: &mut Buffer, startup: &Tideline
|
||||
if selected {
|
||||
label_style = label_style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
let label = format!("{} {}", startup.sym(option.icon), option.label);
|
||||
let budget = column_w.saturating_sub(1) as usize;
|
||||
let option_label = if layout.strip.width < 56 {
|
||||
match index {
|
||||
0 => "worktree",
|
||||
1 => "chat",
|
||||
_ => option.label,
|
||||
}
|
||||
} else {
|
||||
option.label
|
||||
};
|
||||
let label = format!("{} {option_label}", startup.sym(option.icon));
|
||||
let budget = if layout.strip.width < 56 {
|
||||
usize::from(column_w)
|
||||
} else {
|
||||
usize::from(column_w.saturating_sub(1))
|
||||
};
|
||||
let label = truncate_to_width(&label, budget);
|
||||
set_span(
|
||||
buf,
|
||||
x,
|
||||
layout.strip.y + 1,
|
||||
layout.strip.y + layout.strip_content_start,
|
||||
&Span::styled(label, label_style),
|
||||
);
|
||||
set_span(
|
||||
buf,
|
||||
x,
|
||||
layout.strip.y + 2,
|
||||
layout.strip.y + layout.strip_content_start + 1,
|
||||
&Span::styled(option.key, chrome(theme, ChromeInk::MetadataHint)),
|
||||
);
|
||||
}
|
||||
@@ -3559,8 +3508,7 @@ pub fn render_tideline_startup(stage: Rect, buf: &mut Buffer, startup: &Tideline
|
||||
|
||||
/// Recorded interactive hitboxes for the startup stage (spec §6): each quick
|
||||
/// action row and option-strip tile, plus the docked composer's focus and send
|
||||
/// targets. The hero brand mark is deliberately decorative; it has no action
|
||||
/// or hitbox until the product defines one with keyboard parity.
|
||||
/// targets. The hero copy is deliberately non-interactive and has no hitbox.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TidelineStartupHitboxes {
|
||||
pub actions: Vec<Rect>,
|
||||
@@ -3607,7 +3555,7 @@ pub fn tideline_startup_hitboxes_with_composer(
|
||||
out.options = (0..columns)
|
||||
.map(|index| Rect {
|
||||
x: layout.strip.x + index * column_w,
|
||||
y: layout.strip.y + 1,
|
||||
y: layout.strip.y + layout.strip_content_start,
|
||||
width: column_w,
|
||||
height: 2,
|
||||
})
|
||||
|
||||
@@ -8,8 +8,8 @@ use ratatui::layout::Rect;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use super::{
|
||||
CURRENT_MARK_BLOCK, CURRENT_MARK_WIDTH, LaunchAction, TidelineStartup, handle_launch_key,
|
||||
render_tideline_startup, tideline_startup_hitboxes,
|
||||
LaunchAction, TidelineStartup, handle_launch_key, render_tideline_startup,
|
||||
tideline_startup_hitboxes,
|
||||
};
|
||||
use crate::palette::UI_THEME;
|
||||
use crate::tui::golden_harness::{BLOCKER_SIZES, assert_matches_golden, render_golden_text};
|
||||
@@ -86,23 +86,25 @@ fn startup_matches_goldens_at_blocker_sizes() {
|
||||
fn startup_matches_golden_at_the_40x12_terminal_floor() {
|
||||
// §5b shed order proven at the floor. A 40x12 terminal leaves the stage
|
||||
// 10 rows after the topbar and merged footer: the QUICK ACTIONS label
|
||||
// row and the wave rules collapse, the hero keeps heading + subtitle
|
||||
// (the 10x6 current mark needs its 8-row budget), and the strip sheds to 2
|
||||
// columns so tile labels stay whole.
|
||||
// row and the wave rules collapse, the hero keeps heading + subtitle, and
|
||||
// all four routes keep compact labels.
|
||||
let fixture = returning();
|
||||
let startup = fixture.widget(&UI_THEME);
|
||||
let text = draw(40, 10, &startup);
|
||||
assert_matches_golden("startup_40x10", &text);
|
||||
assert!(text.contains("What are we working on?"), "{text}");
|
||||
assert!(
|
||||
text.contains("New worktree"),
|
||||
"2-column strip keeps tile 1: {text}"
|
||||
text.lines()
|
||||
.any(|line| line.contains("welcome back") && line.contains('…')),
|
||||
"compact subtitle must end deliberately: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Chat only"),
|
||||
"2-column strip keeps tile 2: {text}"
|
||||
text.contains("worktree"),
|
||||
"compact strip keeps worktree: {text}"
|
||||
);
|
||||
assert!(!text.contains("Theme"), "tiles 3/4 shed: {text}");
|
||||
assert!(text.contains("chat"), "compact strip keeps chat: {text}");
|
||||
assert!(text.contains("Theme"), "compact strip keeps theme: {text}");
|
||||
assert!(text.contains("Help"), "compact strip keeps help: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -123,30 +125,13 @@ fn startup_hero_states_first_run_vs_returning() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_hero_paints_the_generated_current_mark_on_its_fixed_canvas_and_sheds_it_at_short_stages()
|
||||
{
|
||||
// The generated 10x6 current mark: each row starts on the same ten-cell
|
||||
// canvas, above the heading. It sheds below its 8-row budget instead of
|
||||
// being clipped mid-mark.
|
||||
let startup = TidelineStartup::new(&UI_THEME, 4, true);
|
||||
let wide = draw(80, 24, &startup);
|
||||
for row in CURRENT_MARK_BLOCK {
|
||||
let expected = format!(
|
||||
"{}{row}",
|
||||
" ".repeat((80 - usize::from(CURRENT_MARK_WIDTH)) / 2)
|
||||
);
|
||||
assert!(
|
||||
wide.lines().any(|line| line.starts_with(&expected)),
|
||||
"current mark row {row:?} must paint on its fixed canvas at 80x24:\n{wide}"
|
||||
);
|
||||
}
|
||||
// 60x16: stage 14 rows -> hero 5 < 8, mark sheds, heading stays.
|
||||
let short = draw(60, 16, &startup);
|
||||
assert!(short.contains("What are we working on?"), "{short}");
|
||||
assert!(
|
||||
!short.contains(CURRENT_MARK_BLOCK[0]),
|
||||
"the current mark must not clip mid-mark at short stages:\n{short}"
|
||||
);
|
||||
fn medium_startup_keeps_the_compact_composer_hint() {
|
||||
// A 60x16 terminal gives the startup stage 14 rows after the one-row
|
||||
// topbar and footer. The option strip yields only decorative padding so
|
||||
// the compact composer can preserve both its input and focus hint.
|
||||
let fixture = returning();
|
||||
let text = draw(60, 14, &fixture.widget(&UI_THEME));
|
||||
assert!(text.contains("Tab to type"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -271,43 +256,23 @@ fn startup_disabled_rows_render_dimmer_set_not_hidden() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_option_strip_sheds_to_two_columns_when_narrow() {
|
||||
fn startup_option_strip_keeps_all_four_routes_when_narrow() {
|
||||
let mut startup = TidelineStartup::new(&UI_THEME, 4, true);
|
||||
startup.selected_option = Some(1);
|
||||
let wide = draw(80, 24, &startup);
|
||||
for tile in ["New worktree", "Chat only", "Theme", "Help"] {
|
||||
assert!(wide.contains(tile), "80 cols shows all four tiles: {wide}");
|
||||
}
|
||||
let narrow = draw(30, 20, &startup);
|
||||
assert!(narrow.contains("New worktree"), "narrow keeps tile 1");
|
||||
assert!(narrow.contains("Chat only"), "narrow keeps tile 2");
|
||||
assert!(!narrow.contains("Theme\n"), "narrow sheds tiles 3/4");
|
||||
let narrow = draw(40, 20, &startup);
|
||||
for tile in ["worktree", "chat", "Theme", "Help"] {
|
||||
assert!(narrow.contains(tile), "narrow keeps {tile}: {narrow}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_ascii_safe_has_no_wide_or_unsupported_glyphs() {
|
||||
let startup = TidelineStartup::new(&UI_THEME, 4, true).ascii_safe(true);
|
||||
let text = draw(100, 30, &startup);
|
||||
// The generated current mark projects through the declared quadrant-block
|
||||
// fallbacks — a legible silhouette, not a hand-made ASCII substitute.
|
||||
for row in CURRENT_MARK_BLOCK {
|
||||
let projected: String = row
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch == ' ' {
|
||||
ch.to_string()
|
||||
} else {
|
||||
crate::tui::glyphs::ascii_fallback(&ch.to_string())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| ch.to_string())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
text.lines().any(|line| line.contains(projected.trim_end())),
|
||||
"ascii current-mark row {projected:?} must paint:\n{text}"
|
||||
);
|
||||
}
|
||||
assert!(text.contains(". ~~~ ."), "wave rule projects to ASCII");
|
||||
assert!(text.contains("Enter >"), "chevron projects to >");
|
||||
for ch in text.chars() {
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
//! Also hosts the Tideline work-stage composite (`rail │ receipt stream`)
|
||||
//! whose golden buffers are `work_{w}x{h}`.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
@@ -19,22 +16,9 @@ use ratatui::{
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::localization::{Locale, MessageId, tr};
|
||||
use crate::palette::{ChromeInk, UiTheme, chrome_style};
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::background_indicator::{
|
||||
PendingItemKind, PendingItemState, PendingWork, live_work_from_app,
|
||||
};
|
||||
use crate::tui::history::TidelineStream;
|
||||
|
||||
use super::WorkSurfacePlacement;
|
||||
|
||||
/// A compact live rail needs one row for each of its five labels and facts.
|
||||
/// When the transcript slot is shorter than this, leave the current compact
|
||||
/// work-surface behavior intact instead of reserving a column that cannot
|
||||
/// truthfully show its full state.
|
||||
const LIVE_TIDELINE_MIN_HEIGHT: u16 = 10;
|
||||
|
||||
/// Rail width ladder (spec §5b): 22 at ≥120, 16 at ≥100, hidden below.
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // translation scaffolding: wired by the landing slice
|
||||
@@ -52,7 +36,7 @@ pub fn tideline_rail_width(host_width: u16) -> u16 {
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // translation scaffolding: wired by the landing slice
|
||||
pub struct TidelineRailGroup {
|
||||
pub label: Cow<'static, str>,
|
||||
pub label: &'static str,
|
||||
/// (fact line, ink) pairs, already summarized by the caller.
|
||||
pub lines: Vec<(String, ChromeInk)>,
|
||||
}
|
||||
@@ -68,13 +52,6 @@ pub struct TidelineRail<'a> {
|
||||
/// Focused (keyboard Tab target per §6).
|
||||
pub focused: bool,
|
||||
pub ascii_safe: bool,
|
||||
/// Whether the caller has registered real keyboard and mouse behavior for
|
||||
/// the rail's help/settings/collapse affordances. Passive summaries omit
|
||||
/// those controls rather than painting inert UI.
|
||||
interactive: bool,
|
||||
/// Use a dense label/fact cadence. The live summary uses this to keep all
|
||||
/// five factual groups visible in a normal terminal-height chat slot.
|
||||
compact: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // translation scaffolding: builder methods feed tests + the landing slice
|
||||
@@ -87,8 +64,6 @@ impl<'a> TidelineRail<'a> {
|
||||
collapsed: false,
|
||||
focused: false,
|
||||
ascii_safe: false,
|
||||
interactive: true,
|
||||
compact: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,19 +85,6 @@ impl<'a> TidelineRail<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Render the rail as a passive state summary.
|
||||
///
|
||||
/// The live landing slice has no registered rail input targets yet, so it
|
||||
/// deliberately shows no `? help`, settings, or collapse glyph. This
|
||||
/// preserves the mockup's information architecture without implying that
|
||||
/// an unimplemented control can be clicked or focused.
|
||||
#[must_use]
|
||||
pub fn summary(mut self) -> Self {
|
||||
self.interactive = false;
|
||||
self.compact = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn sym(&self, glyph: &str) -> String {
|
||||
if !self.ascii_safe {
|
||||
return glyph.to_string();
|
||||
@@ -178,7 +140,7 @@ pub fn render_tideline_rail(area: Rect, buf: &mut Buffer, rail: &TidelineRail<'_
|
||||
ChromeInk::MetadataDim
|
||||
};
|
||||
|
||||
if rail.interactive && rail.collapsed {
|
||||
if rail.collapsed {
|
||||
// Expander spine: `»` at the top, dim focus edge down the column.
|
||||
rput(
|
||||
buf,
|
||||
@@ -191,23 +153,21 @@ pub fn render_tideline_rail(area: Rect, buf: &mut Buffer, rail: &TidelineRail<'_
|
||||
}
|
||||
|
||||
let width = area.width as usize;
|
||||
let controls_height = if rail.interactive { 2 } else { 0 };
|
||||
let content_bottom = area.y + area.height.saturating_sub(controls_height);
|
||||
let mut y = area.y;
|
||||
for group in rail.groups {
|
||||
if y >= content_bottom {
|
||||
if y >= area.y + area.height.saturating_sub(2) {
|
||||
break;
|
||||
}
|
||||
rput(
|
||||
buf,
|
||||
area.x,
|
||||
y,
|
||||
&rtruncate(group.label.as_ref(), width),
|
||||
&rtruncate(group.label, width),
|
||||
rchrome(theme, ChromeInk::MetadataDim).add_modifier(Modifier::BOLD),
|
||||
);
|
||||
y += 1;
|
||||
for (line, ink) in &group.lines {
|
||||
if y >= content_bottom {
|
||||
if y >= area.y + area.height.saturating_sub(2) {
|
||||
break;
|
||||
}
|
||||
rput(
|
||||
@@ -219,13 +179,11 @@ pub fn render_tideline_rail(area: Rect, buf: &mut Buffer, rail: &TidelineRail<'_
|
||||
);
|
||||
y += 1;
|
||||
}
|
||||
if !rail.compact {
|
||||
y += 1;
|
||||
}
|
||||
y += 1;
|
||||
}
|
||||
|
||||
// Meta rows then the collapse toggle.
|
||||
if rail.interactive && area.height >= 3 {
|
||||
if area.height >= 3 {
|
||||
// One row above the collapse toggle.
|
||||
let bottom = area.y + area.height - 2;
|
||||
rput(
|
||||
@@ -236,7 +194,7 @@ pub fn render_tideline_rail(area: Rect, buf: &mut Buffer, rail: &TidelineRail<'_
|
||||
rchrome(theme, ChromeInk::MetadataHint),
|
||||
);
|
||||
}
|
||||
if rail.interactive && area.height >= 2 {
|
||||
if area.height >= 2 {
|
||||
rput(
|
||||
buf,
|
||||
area.x,
|
||||
@@ -247,222 +205,6 @@ pub fn render_tideline_rail(area: Rect, buf: &mut Buffer, rail: &TidelineRail<'_
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the Tideline reservation for a real active session.
|
||||
///
|
||||
/// `work_surface` remains the owner of placement and detailed work rows. The
|
||||
/// default Left placement gets this summary only when its legacy side surface
|
||||
/// has nothing to render; an occupied legacy rail is never overlaid or
|
||||
/// duplicated. Top also receives the summary, while explicit Right and Off
|
||||
/// keep their existing behavior.
|
||||
#[must_use]
|
||||
pub(crate) fn active_session_tideline_rail_width(
|
||||
app: &App,
|
||||
chat_area: Rect,
|
||||
legacy_side_rail_visible: bool,
|
||||
) -> u16 {
|
||||
if app.launch.visible
|
||||
|| app.current_session_id.is_none()
|
||||
|| legacy_side_rail_visible
|
||||
|| !matches!(
|
||||
app.work_surface.placement,
|
||||
WorkSurfacePlacement::Top | WorkSurfacePlacement::Left
|
||||
)
|
||||
|| chat_area.height < LIVE_TIDELINE_MIN_HEIGHT
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
tideline_rail_width(chat_area.width)
|
||||
}
|
||||
|
||||
/// Paint the non-interactive active-session Tideline summary and its visual
|
||||
/// divider. The rail owns no hitboxes until its controls have keyboard and
|
||||
/// pointer parity; all facts are read from existing App projections.
|
||||
pub(crate) fn render_active_session_tideline_rail(area: Rect, buf: &mut Buffer, app: &App) {
|
||||
if area.width < 2 || area.height < LIVE_TIDELINE_MIN_HEIGHT {
|
||||
return;
|
||||
}
|
||||
|
||||
let theme = &app.ui_theme;
|
||||
let blank = " ".repeat(usize::from(area.width));
|
||||
for y in area.y..area.y.saturating_add(area.height) {
|
||||
rput(
|
||||
buf,
|
||||
area.x,
|
||||
y,
|
||||
&blank,
|
||||
Style::default().bg(theme.surface_bg),
|
||||
);
|
||||
}
|
||||
|
||||
let content_area = Rect {
|
||||
width: area.width.saturating_sub(1),
|
||||
..area
|
||||
};
|
||||
let groups = active_session_tideline_rail_groups(app);
|
||||
let rail = TidelineRail::new(theme, &groups)
|
||||
.summary()
|
||||
.ascii_safe(crate::tui::color_compat::ascii_safe_enabled());
|
||||
render_tideline_rail(content_area, buf, &rail);
|
||||
|
||||
let divider = rail.sym("│");
|
||||
let divider_x = area.x.saturating_add(area.width.saturating_sub(1));
|
||||
for y in area.y..area.y.saturating_add(area.height) {
|
||||
rput(
|
||||
buf,
|
||||
divider_x,
|
||||
y,
|
||||
÷r,
|
||||
rchrome(theme, ChromeInk::MetadataDim).bg(theme.surface_bg),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the five groups from the existing App state. This is intentionally
|
||||
/// a read-only projection: no independent Tideline store, catalog, or async
|
||||
/// refresh path is introduced here.
|
||||
#[must_use]
|
||||
pub(crate) fn active_session_tideline_rail_groups(app: &App) -> Vec<TidelineRailGroup> {
|
||||
let live_work = live_work_from_app(app);
|
||||
let running_whales = live_work.count(PendingItemKind::Agent);
|
||||
// A progress event may precede the cache snapshot. The denominator is the
|
||||
// deduped union of both authoritative snapshots, rather than `max`ing
|
||||
// their counts and silently losing a known member.
|
||||
let pod_total = known_pod_member_count(app);
|
||||
let whale_capacity = app.max_subagents.max(1);
|
||||
|
||||
let run_label = if app.is_loading || app.dispatch_in_flight {
|
||||
tr(app.ui_locale, MessageId::AutomationRunStatusRunning).into_owned()
|
||||
} else if let Some(panel) = app
|
||||
.workflow_panel
|
||||
.as_ref()
|
||||
.filter(|panel| panel.lifecycle.is_running())
|
||||
{
|
||||
panel.top_bar_chip()
|
||||
} else {
|
||||
live_work_status_line(app.ui_locale, &live_work)
|
||||
};
|
||||
let whales = format!("{running_whales}/{whale_capacity}");
|
||||
let pod_label = format!("{running_whales}/{pod_total}");
|
||||
let work_line = active_session_work_line(app, &live_work);
|
||||
|
||||
localized_tideline_rail_groups(
|
||||
app.ui_locale,
|
||||
&run_label,
|
||||
&whales,
|
||||
&pod_label,
|
||||
&[work_line.as_str()],
|
||||
crate::tui::phase_strip::context_percent_from_app(app),
|
||||
)
|
||||
}
|
||||
|
||||
fn known_pod_member_count(app: &App) -> usize {
|
||||
let mut ids: HashSet<&str> = app
|
||||
.subagent_cache
|
||||
.iter()
|
||||
.map(|agent| agent.agent_id.as_str())
|
||||
.collect();
|
||||
ids.extend(app.agent_progress.keys().map(String::as_str));
|
||||
ids.len()
|
||||
}
|
||||
|
||||
fn has_foreground_activity(app: &App) -> bool {
|
||||
app.is_loading
|
||||
|| app.dispatch_in_flight
|
||||
|| app
|
||||
.workflow_panel
|
||||
.as_ref()
|
||||
.is_some_and(|panel| panel.lifecycle.is_running())
|
||||
}
|
||||
|
||||
fn live_work_status_line(locale: Locale, work: &PendingWork) -> String {
|
||||
let queued = work.count_state(PendingItemState::Queued);
|
||||
let running = work.count_state(PendingItemState::Running);
|
||||
let mut states = Vec::new();
|
||||
if queued > 0 {
|
||||
states.push(
|
||||
tr(locale, MessageId::AgentRailQueuedCount).replace("{count}", &queued.to_string()),
|
||||
);
|
||||
}
|
||||
if running > 0 {
|
||||
states.push(
|
||||
tr(locale, MessageId::TidelineRunningCount).replace("{count}", &running.to_string()),
|
||||
);
|
||||
}
|
||||
if states.is_empty() {
|
||||
tr(locale, MessageId::PhaseIdle).into_owned()
|
||||
} else {
|
||||
states.join(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
fn active_session_work_line(app: &App, work: &PendingWork) -> String {
|
||||
let has_activity = !work.is_empty() || has_foreground_activity(app);
|
||||
let work_status = if work.is_empty() && has_foreground_activity(app) {
|
||||
tr(app.ui_locale, MessageId::AutomationRunStatusRunning).into_owned()
|
||||
} else {
|
||||
live_work_status_line(app.ui_locale, work)
|
||||
};
|
||||
let Ok(todos) = app.todos.try_lock() else {
|
||||
return if has_activity {
|
||||
work_status
|
||||
} else {
|
||||
"?".to_string()
|
||||
};
|
||||
};
|
||||
let snapshot = todos.snapshot();
|
||||
if !snapshot.items.is_empty() {
|
||||
let total = snapshot.items.len();
|
||||
let open = snapshot
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| !item.status.is_settled())
|
||||
.count();
|
||||
let checklist = format!("{open}/{total}");
|
||||
return if has_activity {
|
||||
format!("{work_status} · {checklist}")
|
||||
} else {
|
||||
checklist
|
||||
};
|
||||
}
|
||||
|
||||
if has_activity {
|
||||
work_status
|
||||
} else {
|
||||
tr(app.ui_locale, MessageId::PhaseIdle).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn localized_tideline_rail_groups(
|
||||
locale: Locale,
|
||||
run_label: &str,
|
||||
whales: &str,
|
||||
pod_label: &str,
|
||||
work_lines: &[&str],
|
||||
context_percent: u8,
|
||||
) -> Vec<TidelineRailGroup> {
|
||||
tideline_rail_groups_with_labels(
|
||||
[
|
||||
tideline_heading(locale, MessageId::TidelineRuns),
|
||||
tideline_heading(locale, MessageId::TidelineWhales),
|
||||
tideline_heading(locale, MessageId::ConfigCategoryPod),
|
||||
tideline_heading(locale, MessageId::ConfigCategoryWork),
|
||||
tideline_heading(locale, MessageId::CtxInspContext),
|
||||
],
|
||||
run_label,
|
||||
whales,
|
||||
pod_label,
|
||||
work_lines,
|
||||
context_percent,
|
||||
)
|
||||
}
|
||||
|
||||
/// Rail group names use an all-caps display treatment in the approved
|
||||
/// Tideline chrome. Translation owns the words; this helper owns only that
|
||||
/// shared visual treatment.
|
||||
fn tideline_heading(locale: Locale, message_id: MessageId) -> Cow<'static, str> {
|
||||
Cow::Owned(tr(locale, message_id).to_uppercase())
|
||||
}
|
||||
|
||||
/// The five-group fixture projection used by goldens and the preview pane:
|
||||
/// RUNS / WHALES / POD / WORK / CONTEXT in display order.
|
||||
#[must_use]
|
||||
@@ -473,30 +215,6 @@ pub fn tideline_rail_groups(
|
||||
pod_label: &str,
|
||||
work_lines: &[&str],
|
||||
context_percent: u8,
|
||||
) -> Vec<TidelineRailGroup> {
|
||||
tideline_rail_groups_with_labels(
|
||||
[
|
||||
Cow::Borrowed("RUNS"),
|
||||
Cow::Borrowed("WHALES"),
|
||||
Cow::Borrowed("POD"),
|
||||
Cow::Borrowed("WORK"),
|
||||
Cow::Borrowed("CONTEXT"),
|
||||
],
|
||||
run_label,
|
||||
whales,
|
||||
pod_label,
|
||||
work_lines,
|
||||
context_percent,
|
||||
)
|
||||
}
|
||||
|
||||
fn tideline_rail_groups_with_labels(
|
||||
[runs, whales_label, pod, work, context]: [Cow<'static, str>; 5],
|
||||
run_label: &str,
|
||||
whales: &str,
|
||||
pod_label: &str,
|
||||
work_lines: &[&str],
|
||||
context_percent: u8,
|
||||
) -> Vec<TidelineRailGroup> {
|
||||
let meter_cells = 5usize;
|
||||
let filled = (usize::from(context_percent) * meter_cells / 100).min(meter_cells);
|
||||
@@ -505,26 +223,26 @@ fn tideline_rail_groups_with_labels(
|
||||
.collect();
|
||||
vec![
|
||||
TidelineRailGroup {
|
||||
label: runs,
|
||||
label: "RUNS",
|
||||
lines: vec![(run_label.to_string(), ChromeInk::Identity)],
|
||||
},
|
||||
TidelineRailGroup {
|
||||
label: whales_label,
|
||||
label: "WHALES",
|
||||
lines: vec![(whales.to_string(), ChromeInk::Info)],
|
||||
},
|
||||
TidelineRailGroup {
|
||||
label: pod,
|
||||
label: "POD",
|
||||
lines: vec![(pod_label.to_string(), ChromeInk::Active)],
|
||||
},
|
||||
TidelineRailGroup {
|
||||
label: work,
|
||||
label: "WORK",
|
||||
lines: work_lines
|
||||
.iter()
|
||||
.map(|line| (line.to_string(), ChromeInk::MetadataValue))
|
||||
.collect(),
|
||||
},
|
||||
TidelineRailGroup {
|
||||
label: context,
|
||||
label: "CONTEXT",
|
||||
lines: vec![(
|
||||
format!("{meter} {context_percent}%"),
|
||||
if context_percent >= 80 {
|
||||
@@ -578,9 +296,6 @@ pub fn render_tideline_work_stage(area: Rect, buf: &mut Buffer, stage: &Tideline
|
||||
#[allow(dead_code)] // translation scaffolding: wired by the landing slice
|
||||
pub fn tideline_rail_hitboxes(area: Rect, rail: &TidelineRail<'_>) -> Vec<Rect> {
|
||||
let mut out = Vec::new();
|
||||
if !rail.interactive {
|
||||
return out;
|
||||
}
|
||||
if area.width < 2 || area.height < 2 || rail.collapsed {
|
||||
out.push(Rect {
|
||||
x: area.x,
|
||||
|
||||
@@ -10,11 +10,10 @@ use ratatui::layout::Rect;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use super::{
|
||||
TidelineRail, TidelineWorkStage, active_session_tideline_rail_groups, render_tideline_rail,
|
||||
render_tideline_work_stage, tideline_rail_groups, tideline_rail_hitboxes, tideline_rail_width,
|
||||
TidelineRail, TidelineWorkStage, render_tideline_rail, render_tideline_work_stage,
|
||||
tideline_rail_groups, tideline_rail_hitboxes, tideline_rail_width,
|
||||
};
|
||||
use crate::palette::UI_THEME;
|
||||
use crate::tui::app::{TaskPanelEntry, TaskPanelEntryKind};
|
||||
use crate::tui::golden_harness::{BLOCKER_SIZES, assert_matches_golden, render_golden_text};
|
||||
use crate::tui::history::tideline_exports::{
|
||||
TidelineReceiptState, TidelineStream, TidelineStreamEvent, render_tideline_stream,
|
||||
@@ -71,171 +70,6 @@ fn groups() -> Vec<super::TidelineRailGroup> {
|
||||
)
|
||||
}
|
||||
|
||||
fn active_rail_app() -> crate::tui::app::App {
|
||||
let options = crate::test_support::test_tui_options(std::path::PathBuf::from("."));
|
||||
let mut app = crate::test_support::test_app_with_options(options);
|
||||
app.launch.visible = false;
|
||||
app.current_session_id = Some("tideline-live-rail-facts".to_string());
|
||||
app.ui_locale = crate::localization::Locale::En;
|
||||
app
|
||||
}
|
||||
|
||||
fn pending_task(id: &str, status: &str) -> TaskPanelEntry {
|
||||
TaskPanelEntry {
|
||||
id: id.to_string(),
|
||||
status: status.to_string(),
|
||||
prompt_summary: "durable work".to_string(),
|
||||
duration_ms: None,
|
||||
kind: TaskPanelEntryKind::Background,
|
||||
stale: false,
|
||||
elapsed_since_output_ms: None,
|
||||
owner_agent_id: None,
|
||||
owner_agent_name: None,
|
||||
current_tool: None,
|
||||
role: None,
|
||||
files_touched: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn rail_fact<'a>(groups: &'a [super::TidelineRailGroup], label: &str) -> &'a str {
|
||||
groups
|
||||
.iter()
|
||||
.find(|group| group.label == label)
|
||||
.and_then(|group| group.lines.first())
|
||||
.map(|(line, _)| line.as_str())
|
||||
.unwrap_or_else(|| panic!("missing {label} fact"))
|
||||
}
|
||||
|
||||
fn subagent(
|
||||
id: &str,
|
||||
status: crate::tools::subagent::SubAgentStatus,
|
||||
) -> crate::tools::subagent::SubAgentResult {
|
||||
crate::tools::subagent::SubAgentResult {
|
||||
name: id.to_string(),
|
||||
agent_id: id.to_string(),
|
||||
context_mode: "fresh".to_string(),
|
||||
fork_context: false,
|
||||
workspace: None,
|
||||
git_branch: None,
|
||||
agent_type: crate::tools::subagent::FleetRole::Worker,
|
||||
assignment: crate::tools::subagent::SubAgentAssignment {
|
||||
objective: format!("objective-{id}"),
|
||||
role: Some("worker".to_string()),
|
||||
},
|
||||
model: "deepseek-v4-flash".to_string(),
|
||||
nickname: None,
|
||||
status,
|
||||
worker_status: None,
|
||||
runtime_permissions: None,
|
||||
parent_run_id: None,
|
||||
spawn_depth: 0,
|
||||
child_route: None,
|
||||
result: None,
|
||||
steps_taken: 0,
|
||||
checkpoint: None,
|
||||
needs_input: None,
|
||||
duration_ms: 0,
|
||||
started_at: None,
|
||||
from_prior_session: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_rail_reports_pending_durable_tasks_as_a_run() {
|
||||
let mut app = active_rail_app();
|
||||
app.task_panel.push(pending_task("durable-1", "queued"));
|
||||
|
||||
let groups = active_session_tideline_rail_groups(&app);
|
||||
assert_eq!(rail_fact(&groups, "RUNS"), "1 queued");
|
||||
assert_eq!(rail_fact(&groups, "WORK"), "1 queued");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_rail_keeps_pending_work_visible_alongside_a_completed_checklist() {
|
||||
let mut app = active_rail_app();
|
||||
app.todos.try_lock().expect("todos lock").add(
|
||||
"already done".to_string(),
|
||||
crate::tools::todo::TodoStatus::Completed,
|
||||
);
|
||||
app.task_panel.push(pending_task("durable-1", "running"));
|
||||
|
||||
let groups = active_session_tideline_rail_groups(&app);
|
||||
assert_eq!(rail_fact(&groups, "RUNS"), "1 running");
|
||||
assert_eq!(rail_fact(&groups, "WORK"), "1 running · 0/1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_rail_dedupes_cached_and_progress_only_pod_members() {
|
||||
let mut app = active_rail_app();
|
||||
app.subagent_cache = vec![
|
||||
subagent(
|
||||
"agent-running",
|
||||
crate::tools::subagent::SubAgentStatus::Running,
|
||||
),
|
||||
subagent(
|
||||
"agent-completed",
|
||||
crate::tools::subagent::SubAgentStatus::Completed,
|
||||
),
|
||||
];
|
||||
app.agent_progress
|
||||
.insert("agent-running".to_string(), "working".to_string());
|
||||
app.agent_progress
|
||||
.insert("agent-progress-only".to_string(), "planning".to_string());
|
||||
|
||||
let groups = active_session_tideline_rail_groups(&app);
|
||||
assert_eq!(rail_fact(&groups, "RUNS"), "2 running");
|
||||
assert_eq!(rail_fact(&groups, "POD"), "2/3");
|
||||
assert_eq!(
|
||||
rail_fact(&groups, "WHALES"),
|
||||
format!("2/{}", app.max_subagents.max(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_rail_keeps_runs_and_work_consistent_for_a_foreground_turn() {
|
||||
let mut app = active_rail_app();
|
||||
app.is_loading = true;
|
||||
|
||||
let groups = active_session_tideline_rail_groups(&app);
|
||||
assert_eq!(rail_fact(&groups, "RUNS"), "running");
|
||||
assert_eq!(rail_fact(&groups, "WORK"), "running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_rail_counts_a_running_shell_without_calling_it_idle() {
|
||||
let mut app = active_rail_app();
|
||||
app.task_panel.push(TaskPanelEntry {
|
||||
id: "shell_a1b2c3d4".to_string(),
|
||||
status: "running".to_string(),
|
||||
prompt_summary: "shell: cargo test -p codewhale-tui".to_string(),
|
||||
duration_ms: Some(42_000),
|
||||
kind: TaskPanelEntryKind::Background,
|
||||
stale: false,
|
||||
elapsed_since_output_ms: None,
|
||||
owner_agent_id: None,
|
||||
owner_agent_name: None,
|
||||
current_tool: None,
|
||||
role: None,
|
||||
files_touched: 0,
|
||||
});
|
||||
|
||||
let groups = active_session_tideline_rail_groups(&app);
|
||||
assert_eq!(rail_fact(&groups, "RUNS"), "1 running");
|
||||
assert_eq!(rail_fact(&groups, "WORK"), "1 running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_rail_uses_the_selected_locale_for_its_group_labels() {
|
||||
let mut app = active_rail_app();
|
||||
app.ui_locale = crate::localization::Locale::Ja;
|
||||
|
||||
let labels = active_session_tideline_rail_groups(&app)
|
||||
.into_iter()
|
||||
.map(|group| group.label.into_owned())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(labels, ["実行", "クジラ", "POD", "作業", "コンテキスト"]);
|
||||
}
|
||||
|
||||
/// Render the stage directly (the fixture borrows cannot outlive the fn).
|
||||
fn draw_stage(width: u16, height: u16) -> String {
|
||||
render_golden_text(width, height, |buf| {
|
||||
@@ -287,26 +121,6 @@ fn rail_shows_five_groups_help_and_collapse() {
|
||||
assert!(text.contains("« collapse"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_summary_shows_five_groups_without_inert_controls_or_hitboxes() {
|
||||
let groups = groups();
|
||||
let rail = TidelineRail::new(&UI_THEME, &groups).summary();
|
||||
let area = Rect::new(0, 0, 16, 10);
|
||||
let text = render_golden_text(area.width, area.height, |buf| {
|
||||
render_tideline_rail(area, buf, &rail);
|
||||
});
|
||||
|
||||
for label in ["RUNS", "WHALES", "POD", "WORK", "CONTEXT"] {
|
||||
assert!(text.contains(label), "missing {label}: {text}");
|
||||
}
|
||||
assert!(!text.contains("? help"), "{text}");
|
||||
assert!(!text.contains("« collapse"), "{text}");
|
||||
assert!(
|
||||
tideline_rail_hitboxes(area, &rail).is_empty(),
|
||||
"passive visual state must not advertise a clickable target"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rail_collapsed_leaves_only_the_expander() {
|
||||
let groups = groups();
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Real-PTY regression for the active composer's painted `[↑]` submit
|
||||
//! affordance (#5773, TUI-UX-01 active-work half).
|
||||
//!
|
||||
//! The session is driven through the real onboarding flow into deterministic
|
||||
//! offline-explore mode, so bare submit resolves to Queue: no provider, no
|
||||
//! credentials, no network. The user types a draft and clicks the painted
|
||||
//! send cell with SGR mouse down/up. The click must reach the same queued
|
||||
//! keyboard-submit path as Enter — proven by the offline queue receipt toast
|
||||
//! and the durable `Queued #1:` pending-input preview, which renders directly
|
||||
//! from `app.queued_messages` — rather than leaving the draft untouched as a
|
||||
//! no-op click.
|
||||
|
||||
#![cfg(all(unix, feature = "long-running-tests"))]
|
||||
|
||||
#[path = "../support/qa_harness/mod.rs"]
|
||||
mod qa_harness;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use qa_harness::Frame;
|
||||
use qa_harness::harness::{Harness, make_sealed_workspace};
|
||||
use qa_harness::keys;
|
||||
use qa_harness::modes::mode;
|
||||
|
||||
/// (rows, cols): the release acceptance matrix for compact through wide terminals.
|
||||
const SIZES: [(u16, u16); 5] = [(12, 40), (16, 60), (24, 80), (32, 100), (40, 140)];
|
||||
|
||||
const STARTUP_WAIT: Duration = Duration::from_secs(15);
|
||||
const SETTLE_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Offline-queue receipt toast (MessageId::ToastQueuedOffline, en).
|
||||
const QUEUE_RECEIPT_TOAST: &str = "Saved for later. Connect a provider to send.";
|
||||
/// Compact-width prefix of that toast: the footer truncates the tail when the
|
||||
/// row cannot hold the full sentence next to the posture chips (deterministic
|
||||
/// layout boundary, observed painted as "Saved for later." at 40 and 60
|
||||
/// cols).
|
||||
const QUEUE_RECEIPT_TOAST_COMPACT: &str = "Saved for later.";
|
||||
|
||||
/// The receipt needle this width can actually paint: full toast where the
|
||||
/// footer row fits it, its unwrapped prefix on compact layouts.
|
||||
fn queue_receipt_needle(cols: u16) -> &'static str {
|
||||
if cols >= 100 {
|
||||
QUEUE_RECEIPT_TOAST
|
||||
} else {
|
||||
QUEUE_RECEIPT_TOAST_COMPACT
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_composer_pointer_submit_queues_without_provider() {
|
||||
for (rows, cols) in SIZES {
|
||||
run_pointer_submit_case(rows, cols);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_pointer_submit_case(rows: u16, cols: u16) {
|
||||
let size = format!("{cols}x{rows}");
|
||||
let workspace = make_sealed_workspace().expect("sealed workspace");
|
||||
// Seed only the two first-run markers: prior onboarding completed
|
||||
// ($HOME/.codewhale/.onboarded) and this workspace is trusted
|
||||
// (<workspace>/.deepseek/trusted). No provider, key, or route is seeded.
|
||||
std::fs::write(workspace.home().join(".codewhale/.onboarded"), "").expect("onboarded marker");
|
||||
let trust_dir = workspace.workspace().join(".deepseek");
|
||||
std::fs::create_dir_all(&trust_dir).expect("workspace trust dir");
|
||||
std::fs::write(trust_dir.join("trusted"), "").expect("workspace trust marker");
|
||||
|
||||
let mut tui = Harness::builder(Harness::cargo_bin("codewhale-tui"))
|
||||
.cwd(workspace.workspace())
|
||||
.clear_env()
|
||||
.seal_home(workspace.home())
|
||||
.env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
|
||||
.env("CODEWHALE_NO_UPDATE_CHECK", "1")
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("RUST_LOG", "warn")
|
||||
.args([
|
||||
"--workspace",
|
||||
workspace.workspace().to_str().expect("workspace UTF-8"),
|
||||
"--no-project-config",
|
||||
"--fresh",
|
||||
"--mouse-capture",
|
||||
])
|
||||
.size(rows, cols)
|
||||
.spawn()
|
||||
.expect("start distributed TUI binary");
|
||||
|
||||
// Walk the real onboarding: provider choice → Explore Offline (Ctrl+O)
|
||||
// → ready screen → Enter into Tideline Startup → New session.
|
||||
wait_or_panic(
|
||||
&mut tui,
|
||||
"Choose your model provider",
|
||||
STARTUP_WAIT,
|
||||
&format!("{size}: onboarding provider choice"),
|
||||
);
|
||||
tui.send(keys::key::ctrl('o'))
|
||||
.expect("choose Explore Offline");
|
||||
wait_or_panic(
|
||||
&mut tui,
|
||||
"You're ready.",
|
||||
SETTLE_WAIT,
|
||||
&format!("{size}: offline explore ready"),
|
||||
);
|
||||
tui.send(keys::key::enter()).expect("leave onboarding");
|
||||
wait_or_panic(
|
||||
&mut tui,
|
||||
"What are we working on?",
|
||||
STARTUP_WAIT,
|
||||
&format!("{size}: show Tideline Startup"),
|
||||
);
|
||||
tui.pump();
|
||||
assert_startup_contract(tui.frame(), rows, cols, &size);
|
||||
tui.send(keys::key::ch('w'))
|
||||
.expect("choose Startup New session");
|
||||
if tui
|
||||
.wait_for(
|
||||
|frame| !frame.text().contains("What are we working on?"),
|
||||
STARTUP_WAIT,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
panic!(
|
||||
"{size}: Startup New session did not enter the live shell\n{}",
|
||||
tui.diagnostics()
|
||||
);
|
||||
}
|
||||
tui.wait_for_idle(Duration::from_millis(300), SETTLE_WAIT)
|
||||
.expect("session shell settles");
|
||||
// Prove the steady shell before clearing the onboarding seed. The clear
|
||||
// action intentionally emits a transient `Ctrl+Z restores` receipt, and
|
||||
// live receipts outrank the footer's key hint while present.
|
||||
tui.pump();
|
||||
assert_live_shell_contract(tui.frame(), cols, &size);
|
||||
|
||||
// Clear the onboarding seed so the unique pointer-test draft starts from
|
||||
// an empty composer.
|
||||
tui.send(keys::key::ctrl('u')).expect("clear seeded input");
|
||||
tui.wait_for_idle(Duration::from_millis(200), SETTLE_WAIT)
|
||||
.expect("composer cleared");
|
||||
|
||||
// Mouse capture must be live before the click, or the SGR bytes would
|
||||
// test nothing. Readiness comes from the control stream, not the frame.
|
||||
wait_for_mouse_capture(&mut tui, &size);
|
||||
|
||||
// Type a unique draft and let the composer settle before recording
|
||||
// coordinates, so no late layout shift moves the cells under us.
|
||||
let draft = format!("qa pointer draft {size}");
|
||||
tui.send(keys::key::text(&draft)).expect("type draft");
|
||||
wait_or_panic(
|
||||
&mut tui,
|
||||
&draft,
|
||||
SETTLE_WAIT,
|
||||
&format!("{size}: draft echo"),
|
||||
);
|
||||
tui.wait_for_idle(Duration::from_millis(200), SETTLE_WAIT)
|
||||
.expect("composer settles with draft");
|
||||
|
||||
// The paste-burst window treats input arriving within ~150ms of the
|
||||
// keystrokes as paste; stay clearly outside it before clicking.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
|
||||
tui.pump();
|
||||
let (send_row, send_col) = tui.frame().find_text("[↑]").unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{size}: [↑] submit affordance not painted\n{}",
|
||||
tui.diagnostics()
|
||||
)
|
||||
});
|
||||
// Baseline: no queue receipt and no queued preview exist before the
|
||||
// click, so both signals below can only be produced by this gesture.
|
||||
let receipt = queue_receipt_needle(cols);
|
||||
let preview = format!("Queued #1: {draft}");
|
||||
tui.pump();
|
||||
let before = normalized_text(tui.frame());
|
||||
assert!(
|
||||
!before.contains(receipt) && !before.contains("Queued #1:"),
|
||||
"{size}: queue receipt/preview already visible before any submit\n{}",
|
||||
tui.diagnostics()
|
||||
);
|
||||
|
||||
// Click the middle cell of the three-cell `[↑]` affordance: SGR down,
|
||||
// settle, SGR up — the sequence a real terminal sends for one click.
|
||||
tui.send(keys::mouse::down(send_row, send_col + 1))
|
||||
.expect("SGR mouse down on [↑]");
|
||||
tui.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))
|
||||
.expect("down settles");
|
||||
tui.send(keys::mouse::up(send_row, send_col + 1))
|
||||
.expect("SGR mouse up on [↑]");
|
||||
|
||||
// Distinguishing assertion: a real submit — the pointer click or keyboard
|
||||
// Enter alike — consumes the draft into the deterministic offline queue
|
||||
// and paints the queue receipt toast. A no-op click paints no receipt
|
||||
// and times out here.
|
||||
if tui
|
||||
.wait_for(
|
||||
|frame| normalized_text(frame).contains(receipt),
|
||||
SETTLE_WAIT,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
panic!(
|
||||
"{size}: click on [↑] at ({send_row},{}) produced no queue receipt \
|
||||
{receipt:?} — pointer submit did not reach the keyboard-submit \
|
||||
dispatch path\n{}",
|
||||
send_col + 1,
|
||||
tui.diagnostics()
|
||||
);
|
||||
}
|
||||
|
||||
// Durable queue proof at every size: the pending-input preview renders
|
||||
// straight from `app.queued_messages`, so `Queued #1:` plus the unique
|
||||
// draft is the real queue state — no slash-menu navigation needed.
|
||||
tui.pump();
|
||||
let after = normalized_text(tui.frame());
|
||||
assert!(
|
||||
after.contains(&preview),
|
||||
"{size}: queued preview {preview:?} not visible after pointer submit\n{}",
|
||||
tui.diagnostics()
|
||||
);
|
||||
|
||||
let modes = tui.terminal_modes();
|
||||
assert_eq!(
|
||||
modes.state(mode::MOUSE_SGR),
|
||||
Some(true),
|
||||
"{size}: SGR mouse encoding must be enabled for the click to be meaningful\n{}",
|
||||
modes.debug_dump()
|
||||
);
|
||||
assert!(
|
||||
modes.was_ever_enabled(mode::MOUSE_BUTTON),
|
||||
"{size}: mouse button tracking was never enabled\n{}",
|
||||
modes.debug_dump()
|
||||
);
|
||||
|
||||
let _ = tui.shutdown();
|
||||
}
|
||||
|
||||
/// Frame text with rows joined on single spaces, so assertions survive a
|
||||
/// narrow-terminal wrap of the needle across two painted rows.
|
||||
fn normalized_text(frame: &Frame) -> String {
|
||||
frame
|
||||
.text()
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn assert_startup_contract(frame: &Frame, rows: u16, cols: u16, size: &str) {
|
||||
let text = frame.text();
|
||||
for needle in ["CODEWHALE", "What are we working on?", "Theme", "Help"] {
|
||||
assert!(
|
||||
text.contains(needle),
|
||||
"{size}: startup misses {needle:?}\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
for needle in if cols < 56 {
|
||||
["worktree", "chat"]
|
||||
} else {
|
||||
["New worktree", "Chat only"]
|
||||
} {
|
||||
assert!(
|
||||
text.contains(needle),
|
||||
"{size}: startup misses {needle:?}\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
for retired_mark_row in ["▄▄▄▄██▌", "▜████▀▘"] {
|
||||
assert!(
|
||||
!text.contains(retired_mark_row),
|
||||
"{size}: startup still paints the retired approximate mark\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
assert_eq!(frame.rows(), rows, "{size}: PTY row count drift");
|
||||
assert_eq!(frame.cols(), cols, "{size}: PTY column count drift");
|
||||
assert!(
|
||||
frame.max_row_width() <= usize::from(cols),
|
||||
"{size}: startup row overflow\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_live_shell_contract(frame: &Frame, cols: u16, size: &str) {
|
||||
let text = frame.text();
|
||||
assert!(
|
||||
text.contains("CODEWHALE"),
|
||||
"{size}: live shell misses wordmark\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
assert!(
|
||||
text.contains("? help"),
|
||||
"{size}: live shell hides help\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
assert!(
|
||||
!text.contains("RUNS") || cols < 100,
|
||||
"{size}: passive duplicate Tideline rail is still visible\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
assert!(
|
||||
frame.max_row_width() <= usize::from(cols),
|
||||
"{size}: live-shell row overflow\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
|
||||
fn wait_or_panic(tui: &mut Harness, needle: &str, timeout: Duration, label: &str) {
|
||||
if tui.wait_for_text(needle, timeout).is_err() {
|
||||
panic!("{label}: {needle:?} not visible\n{}", tui.diagnostics());
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_mouse_capture(tui: &mut Harness, size: &str) {
|
||||
let deadline = Instant::now() + qa_harness::harness::ci_scaled(STARTUP_WAIT);
|
||||
loop {
|
||||
tui.pump();
|
||||
let modes = tui.terminal_modes();
|
||||
if modes.state(mode::MOUSE_SGR) == Some(true)
|
||||
&& modes.state(mode::MOUSE_BUTTON) == Some(true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
panic!(
|
||||
"{size}: mouse capture never enabled in the control stream\n{}",
|
||||
tui.diagnostics()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
//! tool_lifecycle` etc. filtering via the module path while cutting six link
|
||||
//! jobs to one. See `crates/tui/tests/README.md`.
|
||||
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod active_composer_pointer_pty;
|
||||
mod core_session_command_extraction;
|
||||
mod directory_listing_acceptance;
|
||||
mod epic_acceptance_harness;
|
||||
|
||||
@@ -21,8 +21,7 @@ Cell-inventory read of the references (startup, work+pod, settings/appearance):
|
||||
segments (`run …`, `pod …`, `3/4 whales`, `model …`, `theme …`,
|
||||
`Settings / Appearance`, `folder …`); pinned right = `context NN% ▰▰▱▱▱` +
|
||||
full clock. Segment set varies per screen; brand/meter/clock never move.
|
||||
- **Startup.** Centered hero: the current diving-whale mark (whose wave forms
|
||||
a `>` prompt), "What are we working on?", one dim
|
||||
- **Startup.** Centered hero: "What are we working on?", one dim
|
||||
subtitle; `QUICK ACTIONS` band with 3 rows (icon · label · description ·
|
||||
command + `›`); a 4-column option strip (New worktree / Chat only / Theme /
|
||||
Help); whale-outline composer; footer with route · cost · keys.
|
||||
@@ -42,7 +41,7 @@ Cell-inventory read of the references (startup, work+pod, settings/appearance):
|
||||
|
||||
| In the reference | Why it cannot ship | Decision |
|
||||
|---|---|---|
|
||||
| Composer drawn as a stroked whale outline | No bezier strokes; only box-drawing glyphs | **Rounded border.** `╭─╮│╰╯` border (dim at rest, Info on focus); the old fluke cap is retired and no brand glyph is hand-drawn into the composer. The send `↑` becomes a 3-cell hitbox `[↑]` right-aligned inside the border. The approved mark appears only where its full generated terminal projection fits. |
|
||||
| Composer drawn as a stroked whale outline | No bezier strokes; only box-drawing glyphs | **Rounded border.** `╭─╮│╰╯` border (dim at rest, Info on focus); the old fluke cap is retired and no brand glyph is hand-drawn into the composer. The send `↑` becomes a 3-cell hitbox `[↑]` right-aligned inside the border. The terminal never approximates the canonical raster mark. |
|
||||
| Translucent whale silhouettes behind text (Deepsea preview) | No alpha; painting over text destroys it | **Empty-cells-only compositing**, the `ambient_life.rs` rule verbatim: write only cells that are open water (`is_open_water` + `TEXT_CLEARANCE_ROWS = 1` clearance from `occupied_text_bounds`). Eviction order when water is scarce: bubbles first, then fish school, then jellyfish; the whale cameo is evicted last (highest identity value). Caustic-style tinting stays bg-only on `cell.symbol() == " "`. Deepsea ambient runs only under `MotionMode::Full`. |
|
||||
| SVG icons (plug, clock, folder, palette, chat) | Not renderable | One glyph per action, added to `glyphs.rs` with declared ASCII fallbacks via `ascii_fallback`: plug `⌁`→`+`, resume `↺`→`<`, folder `▤`→`=`, palette `◐`→`*`, chat `◌`→`o`, worktree `⑂`→`y`, help `?`→`?` (identity). Each is 1 cell, no wide glyphs. |
|
||||
| Ledger cells wrapping to two lines | Table columns are exact integer cells | Fixed column widths + per-column truncation: WHALE 10 (never truncates — names are short by contract), ASSIGNMENT = remainder (truncate with `…`, never wrap), STATE 12 (glyph + word), ELAPSED 8, RECEIPTS 8, LAST UPDATE 8 (`HH:MM:SS`). **At 80 columns** the rail is hidden and ledger sheds to `WHALE │ ASSIGNMENT │ STATE` — ELAPSED, RECEIPTS, LAST UPDATE drop in that order before ASSIGNMENT loses cells. |
|
||||
@@ -110,7 +109,7 @@ where the `Rect` is stored for `mouse_ui` (existing pattern:
|
||||
| Component | What it does | States | Data source | Replaces | Owning file | Keys | Mouse hitbox | Golden name |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Topbar | One-row status surface | per-screen segment set; hover; shed | `effective_route_identity_display()`, run/pod summaries, `context_budget` pct, injected clock | `underwater::render_header` | `tui/topbar.rs` ✅ | Tab⇄, Enter activate | brand/menu + per-segment rects → `viewport.last_topbar_hitboxes` | `topbar_{startup,work,settings}_{w}x{h}` ✅ |
|
||||
| Hero (startup) | Centered current mark + prompt | first-run vs returning | `LaunchState`, `workspace_session_count` | `render_launch_screen` | `tui/underwater.rs` | — | decorative identity; no action until keyboard parity exists | `startup_{w}x{h}` |
|
||||
| Hero (startup) | Centered prompt + subtitle | first-run vs returning | `LaunchState`, `workspace_session_count` | `render_launch_screen` | `tui/underwater.rs` | — | none | `startup_{w}x{h}` |
|
||||
| Quick actions | 3 command rows | selected/hover/disabled (no model) | `LaunchAction`, provider state | launch menu rows | `tui/underwater.rs` + `mouse_ui.rs:441` | ↑/↓, Enter, Esc | row rects (exists) | `startup_*` |
|
||||
| Option strip | 4 columns (worktree/chat/theme/help) | hover/selected | `LaunchState` | launch options row | same | Tab, Enter | 4 col rects | `startup_*` |
|
||||
| Rail | Left column, 5 groups + collapse | expanded/collapsed/focused | `WorkSurfaceState`, `subagent_cache`, run list, git status | work strip + `sidebar` remnants | `tui/work_surface/` (#5699 territory) | Tab, ↑/↓, Enter, `«` | `WorkHitbox{WorkRowId,row_y}` (exists) | `work_{w}x{h}` |
|
||||
@@ -142,7 +141,7 @@ Startup stage:
|
||||
|
||||
```rust
|
||||
let [hero, rule_a, quick, rule_b, strip, spacer] = Layout::vertical([
|
||||
Constraint::Percentage(38), // hero: current mark + heading + subtitle
|
||||
Constraint::Percentage(38), // hero: heading + subtitle
|
||||
Constraint::Length(1), // wave rule `⋯ ∼∼∼ ⋯` (dim, static)
|
||||
Constraint::Length(3 + 2), // QUICK ACTIONS: label row + 3 rows + margins
|
||||
Constraint::Length(1), // wave rule
|
||||
@@ -221,7 +220,7 @@ families obey `STATUS_BAR_COLOR_GRAMMAR.md`:
|
||||
|
||||
| Element | ChromeInk | Family |
|
||||
|---|---|---|
|
||||
| Current mark + wordmark | `Attention` (gold) | Cognition — the sanctioned whale-mark gold |
|
||||
| Wordmark | `Attention` (gold) | Cognition — restrained text identity |
|
||||
| Segment labels / separators / clock | `Metadata` / `MetadataDim` / `MetadataHint` | Metadata |
|
||||
| Route · model · run | `Identity` | Identity |
|
||||
| Pod (live) / `3/4` / context meter / theme name | `Active` / `Info` | Active / Identity |
|
||||
|
||||
@@ -5,11 +5,11 @@ gives every agent role a species-led whale and every whale one of six runtime
|
||||
states. This document is the contract for how that identity appears in the
|
||||
Codewhale terminal UI. The implementation is `crates/tui/src/tui/whales.rs`.
|
||||
|
||||
> **Status — runtime badges current; portrait reference retired.** The current
|
||||
> Codewhale product mark is the generated diving whale whose wave forms a
|
||||
> `>` prompt, sourced from `designs/brand/20260829-codewhale-mark/`. The old
|
||||
> hand-drawn crown/fluke treatment described in the historical portrait record
|
||||
> below is not a current product mark and does not render in `underwater.rs`.
|
||||
> **Status — runtime badges current; portrait reference retired.** The
|
||||
> canonical Codewhale product mark is a raster asset outside this terminal-art
|
||||
> vocabulary. Neither the old hand-drawn crown/fluke treatment nor an
|
||||
> approximate block-glyph silhouette is a current product mark, and neither
|
||||
> renders in `underwater.rs`.
|
||||
|
||||
The source artwork (six species × six states × four colorways of 384×192
|
||||
rasters, plus concept boards and the visual brief) lives in the CWC repository.
|
||||
@@ -98,11 +98,10 @@ accents only and must never be used for status, mode, or permission.
|
||||
|
||||
## Art
|
||||
|
||||
The current startup mark is intentionally separate from Whale Teams artwork:
|
||||
it is the approved generated diving-whale terminal projection in
|
||||
`underwater.rs`. [`WHALE_TEAMS_TUI_ART.md`](WHALE_TEAMS_TUI_ART.md), its text
|
||||
preview, and its generator are retained as an **archived portrait reference**
|
||||
for removed artwork; they must not be used as a product-mark source.
|
||||
The current startup intentionally uses direct copy rather than translating the
|
||||
canonical raster into terminal glyph art. [`WHALE_TEAMS_TUI_ART.md`](WHALE_TEAMS_TUI_ART.md),
|
||||
its text preview, and its generator are retained as an **archived portrait
|
||||
reference** for removed artwork; they must not be used as a product-mark source.
|
||||
|
||||
The live runtime uses two-cell badges: a species feature glyph in the role
|
||||
accent plus a body cell. With `CODEWHALE_ASCII_SAFE=1` the seven badges stay
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
> **Status — retired reference, not current brand authority.** This record
|
||||
> preserves the removed hand-drawn portrait/crown treatment for historical
|
||||
> comparison. Do not use its `▚△▞` treatment as a Codewhale mark or infer that
|
||||
> `underwater.rs` renders it. The current approved terminal mark is the
|
||||
> generated diving whale from `designs/brand/20260829-codewhale-mark/`, whose
|
||||
> wave forms a `>` prompt.
|
||||
> `underwater.rs` renders it. The canonical mark is a raster asset and has no
|
||||
> approved ASCII or block-glyph substitute.
|
||||
|
||||
How the former Signal Cut mascots became terminal glyph art. The remaining
|
||||
live runtime contract (role → species, state → evidence, inks) is
|
||||
@@ -34,8 +33,8 @@ Read off the matrix and the working keyframes:
|
||||
## (a) Historical empty-state portrait
|
||||
|
||||
Former `crates/tui/src/tui/underwater.rs` idle-art constants. The current
|
||||
startup hero no longer uses this artwork; it renders the approved generated
|
||||
diving-whale mark instead.
|
||||
startup hero no longer uses this artwork and does not approximate the canonical
|
||||
raster mark.
|
||||
|
||||
```
|
||||
˚
|
||||
|
||||
@@ -208,7 +208,6 @@
|
||||
/* Startup: a quiet first choice, not a blank terminal. */
|
||||
.startup { min-height: 650px; display: grid; grid-template-rows: 1fr auto; padding: 0 10%; }
|
||||
.startup-center { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 72px 0 38px; }
|
||||
.startup-mark { min-width: 10ch; min-height: 6lh; color: var(--gold); font: 700 13px/1 var(--mono); letter-spacing: 0; white-space: pre; }
|
||||
.startup h2 { margin: 20px 0 7px; font-size: 23px; letter-spacing: -.025em; font-weight: 650; }
|
||||
.startup p { margin: 0; color: var(--t-dim); }
|
||||
.quick-actions { border-top: 1px solid var(--t-faint); padding: 13px 0 0; }
|
||||
@@ -276,7 +275,7 @@
|
||||
.doc-section { margin-top: 70px; }.doc-section h2 { margin: 0 0 7px; font-size: 24px; letter-spacing: -.025em; }.doc-section > p { max-width: 870px; margin: 0; color: var(--page-dim); }.rule-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; margin-top: 17px; }.rule { padding: 14px; border: 1px solid var(--card-edge); border-radius: 10px; background: var(--card); }.rule b { color: var(--accent); }.rule p { margin: 6px 0 0; color: var(--page-dim); font-size: 13px; }
|
||||
.spec-table { width: 100%; margin-top: 17px; border-collapse: collapse; font-size: 12px; }.spec-table th, .spec-table td { padding: 9px 10px; border-bottom: 1px solid var(--card-edge); text-align: left; vertical-align: top; }.spec-table th { color: var(--page-dim); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }.spec-table code { color: var(--accent); font-size: 11px; }.status-existing { color: var(--ok); }.status-proposed { color: var(--gold); }.status-guard { color: var(--warn); }
|
||||
.manifest-note { margin-top: 13px; padding: 12px 14px; border-left: 2px solid var(--accent2); background: color-mix(in srgb, var(--accent) 5%, var(--card)); color: var(--page-dim); font-size: 13px; }.manifest-note b { color: var(--page-fg); }
|
||||
.glyph-grid { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: 10px; margin-top: 17px; }.glyph-card { padding: 12px; border: 1px solid var(--card-edge); background: var(--card); }.glyph-sample { color: var(--accent2); font: 700 20px/1 var(--mono); }.glyph-sample.terminal-mark { font-size: 11px; line-height: 1; white-space: pre; }.glyph-card:nth-child(2) .glyph-sample { color: var(--gold); }.glyph-card:nth-child(3) .glyph-sample { color: var(--accent); }.glyph-card:nth-child(4) .glyph-sample { color: var(--reason); }.glyph-card:nth-child(5) .glyph-sample { color: var(--ok); }.glyph-card b { display: block; margin-top: 8px; }.glyph-card p { margin: 3px 0 0; color: var(--page-dim); font-size: 12px; }
|
||||
.glyph-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin-top: 17px; }.glyph-card { padding: 12px; border: 1px solid var(--card-edge); background: var(--card); }.glyph-sample { color: var(--accent2); font: 700 20px/1 var(--mono); }.glyph-card:nth-child(2) .glyph-sample { color: var(--gold); }.glyph-card:nth-child(3) .glyph-sample { color: var(--accent); }.glyph-card:nth-child(4) .glyph-sample { color: var(--reason); }.glyph-card b { display: block; margin-top: 8px; }.glyph-card p { margin: 3px 0 0; color: var(--page-dim); font-size: 12px; }
|
||||
.acceptance { margin-top: 15px; columns: 2; column-gap: 32px; }.acceptance label { display: block; break-inside: avoid; margin: 0 0 9px; color: var(--page-dim); font-size: 13px; }.acceptance input { margin-right: 8px; accent-color: var(--accent); }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
@@ -420,12 +419,6 @@
|
||||
<div class="screen" id="screen-startup">
|
||||
<div class="startup">
|
||||
<div class="startup-center">
|
||||
<div class="startup-mark" role="img" aria-label="Codewhale diving-whale mark">▄▄▄▄██▌
|
||||
▜████▀▘
|
||||
▟██▄▄
|
||||
▟█████▖
|
||||
▐█▟▀█████
|
||||
▐█▜██████</div>
|
||||
<h2>What are we working on?</h2>
|
||||
<p>Folder and tool access are asked when needed.</p>
|
||||
</div>
|
||||
@@ -542,7 +535,7 @@
|
||||
|
||||
<section class="doc-section" id="principles">
|
||||
<h2>What makes it Codewhale</h2>
|
||||
<p>It should feel as legible as Codex, Claude Code, or a sober native workbench before it feels branded. The brand arrives through timing, language, a small current of cyan, and the approved diving-whale mark at moments of authorship or return.</p>
|
||||
<p>It should feel as legible as Codex, Claude Code, or a sober native workbench before it feels branded. The terminal uses timing, language, a small current of cyan, and a restrained wordmark; surfaces that can render the canonical raster use that exact asset.</p>
|
||||
<div class="rule-grid">
|
||||
<article class="rule"><b>Host first</b><p>Match Terminal, Dark, and Light use <code>Color::Reset</code> for the background. Their foreground palette adapts; the user’s terminal remains the surface.</p></article>
|
||||
<article class="rule"><b>Conversation first</b><p>The transcript is the default lens. The rail and ledger can fold; receipts are quiet until a person asks for evidence.</p></article>
|
||||
@@ -555,14 +548,8 @@
|
||||
|
||||
<section class="doc-section" id="glyphs">
|
||||
<h2>Small whale grammar</h2>
|
||||
<p>The current diving-whale mark, whose wave forms a <code>></code> prompt, appears at the startup hero and other sparse identity moments—not in every button or table row. The rest of the glyphs are working notation. All retain an ASCII fallback in the terminal projection.</p>
|
||||
<p>The canonical raster mark has no approved terminal-glyph substitute. These glyphs are working notation rather than brand assets, and all retain an ASCII fallback.</p>
|
||||
<div class="glyph-grid">
|
||||
<article class="glyph-card"><div class="glyph-sample terminal-mark">▄▄▄▄██▌
|
||||
▜████▀▘
|
||||
▟██▄▄
|
||||
▟█████▖
|
||||
▐█▟▀█████
|
||||
▐█▜██████</div><b>Current mark</b><p>Identity at startup and other sparse moments. This is the approved generated terminal projection, not a hand-made substitute.</p></article>
|
||||
<article class="glyph-card"><div class="glyph-sample">⌁</div><b>Pod formation</b><p>Parallel work beginning. Never a generic button icon.</p></article>
|
||||
<article class="glyph-card"><div class="glyph-sample">◌</div><b>Wake / live work</b><p>Active receipt or a running worker. Static in low-motion and ANSI.</p></article>
|
||||
<article class="glyph-card"><div class="glyph-sample">◆</div><b>Spout / attention</b><p>A real user decision is needed. Gold and words; not an error.</p></article>
|
||||
@@ -606,7 +593,7 @@
|
||||
<label><input type="checkbox"> Settings show current Z.ai / GLM-5.3 separately from saved startup defaults.</label>
|
||||
<label><input type="checkbox"> Banner, in-app history, and audio settings do not masquerade as one switch.</label>
|
||||
<label><input type="checkbox"> low_motion and fancy_animations both settle immediately and preserve the final state.</label>
|
||||
<label><input type="checkbox"> The generated current diving-whale terminal projection matches the approved source at all approved sizes; no hand-drawn substitute ships.</label>
|
||||
<label><input type="checkbox"> No ASCII, block-glyph, or hand-drawn substitute is presented as the canonical raster mark.</label>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -621,8 +608,8 @@
|
||||
"deepsea": "explicit OceanTreatment::Deepsea surface"
|
||||
},
|
||||
"targets": {
|
||||
"brand.primary": {"title":"Primary Codewhale wordmark","owner":"glyphs.rs + underwater.rs","state":"BrandMark::Primary","action":"open product menu","hitbox":"header brand Rect","notes":"One-row header preserves readable wordmark; the full approved mark belongs where its cell projection fits."},
|
||||
"brand.startup": {"title":"Startup diving-whale mark","owner":"underwater.rs","state":"OnboardingState","action":"decorative identity","hitbox":"none","notes":"The approved generated 10x6 terminal projection, used at a sparse identity placement. It becomes interactive only with a defined keyboard-parity action."},
|
||||
"brand.primary": {"title":"Primary Codewhale wordmark","owner":"glyphs.rs + underwater.rs","state":"BrandMark::Primary","action":"open product menu","hitbox":"header brand Rect","notes":"One-row terminal wordmark; it is not a substitute for the canonical raster mark."},
|
||||
"brand.startup": {"title":"Startup prompt","owner":"underwater.rs","state":"OnboardingState","action":"none","hitbox":"none","notes":"Direct heading and subtitle; no approximate brand asset."},
|
||||
"header.workspace": {"title":"Workspace identity","owner":"underwater.rs","state":"GitStatusSnapshot","action":"open workspace details","hitbox":"header workspace Rect","notes":"Metadata family; truncate before wrapping."},
|
||||
"header.route": {"title":"Route","owner":"underwater.rs","state":"route identity","action":"open provider/route inspector","hitbox":"header route Rect","notes":"Identity-blue, not a status light."},
|
||||
"header.model": {"title":"Effective model","owner":"underwater.rs + App","state":"effective provider/model route","action":"open provider inspector","hitbox":"header model Rect","notes":"Must come from current App route, not persisted config."},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ARCHIVED REFERENCE — removed portrait artwork, not the current Codewhale mark.
|
||||
For the approved startup identity, use `designs/brand/20260829-codewhale-mark/`:
|
||||
the generated diving whale whose wave forms a `>` prompt.
|
||||
The canonical mark is a raster asset with no approved ASCII or block-glyph
|
||||
substitute; the terminal startup intentionally uses direct copy instead.
|
||||
|
||||
Former Codewhale empty-state whale portrait — CURRENT (main) vs NEW (Signal Cut)
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
"""Preview archived Codewhale empty-state portrait art: before vs after.
|
||||
|
||||
This script is historical comparison evidence only. It is not a preview of the
|
||||
current Codewhale mark: that mark is the generated diving whale from
|
||||
`designs/brand/20260829-codewhale-mark/`, whose wave forms a `>` prompt.
|
||||
The historical rows are held below as a fixed record because the live
|
||||
current Codewhale mark: the canonical mark is a raster asset with no approved
|
||||
ASCII or block-glyph substitute. The historical rows are held below as a fixed record because the live
|
||||
`underwater.rs` constants were intentionally deleted with the old artwork.
|
||||
|
||||
python3 scripts/preview-whale-art.py # ANSI true colour
|
||||
|
||||
Reference in new issue
Block a user