mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
Removes the deleted header widget, dead underwater/phase-strip variants, unused glyph and list-nav helpers, and retired config knobs with docs. -867/+7 across 13 files. TUI check green; underwater (37), phase_strip (35), workflow_panel (36), config (473), glyphs (11) tests pass; clippy clean on touched files.
This commit is contained in:
1 parent
4a633a0fa9
commit
07a95e5306
14 files changed
+7
-988
No files matched your search
@@ -1752,31 +1752,6 @@ where
|
||||
}))
|
||||
}
|
||||
|
||||
/// Deserialize `header_items` tolerantly: skip keys unknown to this build
|
||||
/// instead of failing with an "unknown variant" error.
|
||||
///
|
||||
/// This keeps configuration files forward-compatible. For example, a newer
|
||||
/// CodeWhale build may write a header item that an older build does not yet
|
||||
/// understand; the older build will ignore that item while preserving the
|
||||
/// remaining supported entries.
|
||||
fn deser_header_items<'de, D>(deserializer: D) -> Result<Option<Vec<HeaderItem>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let raw: Option<Vec<String>> = Option::deserialize(deserializer)?;
|
||||
Ok(raw.map(|strings| {
|
||||
strings
|
||||
.into_iter()
|
||||
.filter_map(|s| {
|
||||
HeaderItem::from_key(&s).or_else(|| {
|
||||
tracing::warn!("ignoring unknown header item {s:?} in config");
|
||||
None
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}))
|
||||
}
|
||||
|
||||
/// UI configuration loaded from config files.
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct TuiConfig {
|
||||
@@ -1833,22 +1808,6 @@ pub struct TuiConfig {
|
||||
/// balance and drops the telemetry and the help hint (#5950).
|
||||
#[serde(default)]
|
||||
pub metrics_line: Option<ChromeRowPreset>,
|
||||
/// Ordered list of optional header items the user wants visible.
|
||||
///
|
||||
/// `None` (the field missing from `config.toml`) preserves the built-in
|
||||
/// header unchanged. An empty `Some(vec![])` likewise enables no additional
|
||||
/// header items, while configured entries enable their corresponding
|
||||
/// optional header content.
|
||||
///
|
||||
/// The existing context-utilisation display remains part of the built-in
|
||||
/// header and is not controlled by this list.
|
||||
///
|
||||
/// Unknown items are ignored during deserialization so configurations written
|
||||
/// by newer CodeWhale versions remain loadable by older versions.
|
||||
///
|
||||
/// Persisted to `tui.header_items` in `~/.deepseek/config.toml`.
|
||||
#[serde(default, deserialize_with = "deser_header_items")]
|
||||
pub header_items: Option<Vec<HeaderItem>>,
|
||||
/// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so
|
||||
/// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,
|
||||
/// WezTerm, Alacritty, recent gnome-terminal/konsole) make them clickable
|
||||
@@ -2323,43 +2282,6 @@ pub fn provider_has_balance_api(provider: ApiProvider) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// One configurable header item
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HeaderItem {
|
||||
/// Session token usage: input / cache-hit / output.
|
||||
Tokens,
|
||||
}
|
||||
|
||||
impl HeaderItem {
|
||||
/// Default header composition for the always-on status line. Used when
|
||||
/// `tui.header_items` is missing from `config.toml` so upgraders see a
|
||||
/// concise header by default; diagnostic chips remain available through
|
||||
/// explicit configuration without crowding the main UI.
|
||||
#[must_use]
|
||||
pub fn default_header() -> Vec<HeaderItem> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Stable canonical name used in TOML.
|
||||
#[must_use]
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
HeaderItem::Tokens => "tokens",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a config string while ignoring unknown items.
|
||||
#[must_use]
|
||||
pub fn from_key(key: &str) -> Option<Self> {
|
||||
match key {
|
||||
"tokens" => Some(Self::Tokens),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved retry policy with defaults applied.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryPolicy {
|
||||
|
||||
@@ -304,57 +304,6 @@ fn provider_environment_model_outranks_startup_memory() {
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
struct HeaderItemsTestConfig {
|
||||
#[serde(default, deserialize_with = "deser_header_items")]
|
||||
header_items: Option<Vec<HeaderItem>>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_header_tokens_item() {
|
||||
let config: HeaderItemsTestConfig = toml::from_str(
|
||||
r#"
|
||||
header_items = ["tokens"]
|
||||
"#,
|
||||
)
|
||||
.expect("header_items should parse");
|
||||
|
||||
assert_eq!(config.header_items, Some(vec![HeaderItem::Tokens]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_header_items() {
|
||||
let config: HeaderItemsTestConfig = toml::from_str(
|
||||
r#"
|
||||
header_items = ["tokens", "future_item"]
|
||||
"#,
|
||||
)
|
||||
.expect("unknown header items should not reject the config");
|
||||
|
||||
assert_eq!(config.header_items, Some(vec![HeaderItem::Tokens]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_items_scenario() {
|
||||
// Scenario consolidation of: header_items_round_trip, header_items_are_opt_in_by_default
|
||||
// from header_items_round_trip
|
||||
{
|
||||
let original = HeaderItemsTestConfig {
|
||||
header_items: Some(vec![HeaderItem::Tokens]),
|
||||
};
|
||||
|
||||
let serialized = toml::to_string(&original).expect("config should serialize");
|
||||
let decoded: HeaderItemsTestConfig =
|
||||
toml::from_str(&serialized).expect("serialized config should parse");
|
||||
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
// from header_items_are_opt_in_by_default
|
||||
{
|
||||
assert!(HeaderItem::default_header().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_config_error_omits_secret_contents_and_keys() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
@@ -18791,7 +18791,6 @@ api_key = "test-only-key"
|
||||
osc8_links: None,
|
||||
composer_arrows_scroll: None,
|
||||
notification_condition: None,
|
||||
header_items: None,
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
@@ -18893,7 +18892,6 @@ api_key = "test-only-key"
|
||||
osc8_links: None,
|
||||
composer_arrows_scroll: None,
|
||||
notification_condition: None,
|
||||
header_items: None,
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
@@ -18933,7 +18931,6 @@ api_key = "test-only-key"
|
||||
osc8_links: None,
|
||||
composer_arrows_scroll: None,
|
||||
notification_condition: None,
|
||||
header_items: None,
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
@@ -19027,7 +19024,6 @@ api_key = "test-only-key"
|
||||
osc8_links: None,
|
||||
composer_arrows_scroll: None,
|
||||
notification_condition: None,
|
||||
header_items: None,
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
@@ -913,23 +913,6 @@ impl Default for ComposerState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility name retained for the first Tideline header slice. New
|
||||
/// surfaces register [`crate::tui::tideline::InteractionAction`] directly.
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
pub type HeaderActionTarget = crate::tui::tideline::InteractionAction;
|
||||
|
||||
/// A header target painted in the latest frame.
|
||||
///
|
||||
/// The visible chrome owns placement; input owns dispatch. Keeping the
|
||||
/// rectangular target alongside its typed action gives mouse and keyboard
|
||||
/// routes one shared destination without a second navigation system.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
pub struct HeaderHitbox {
|
||||
pub area: Rect,
|
||||
pub target: HeaderActionTarget,
|
||||
}
|
||||
|
||||
/// Viewport/scroll state — fields related to transcript scrolling and caching.
|
||||
pub struct ViewportState {
|
||||
pub transcript_scroll: TranscriptScroll,
|
||||
@@ -2118,14 +2101,6 @@ pub struct App {
|
||||
/// `compact` keeps the route, context, cost and balance and drops the
|
||||
/// telemetry and the help hint.
|
||||
pub metrics_line: crate::config::ChromeRowPreset,
|
||||
/// Optional header items enabled from `tui.header_items` in `config.toml`
|
||||
/// at startup. Built-in header content remains independent of this list.
|
||||
/// Unread since the classic header was superseded by the Tideline info
|
||||
/// line (2026-08-29): the info line carries the context meter by default and the
|
||||
/// token breakdown lives behind `/cost` (spec §3). The field stays so the
|
||||
/// config surface keeps parsing; its reader returns with the classic
|
||||
/// renderer deletion slice.
|
||||
pub header_items: Vec<crate::config::HeaderItem>,
|
||||
/// Project documentation (AGENTS.md or CLAUDE.md)
|
||||
#[expect(dead_code)]
|
||||
pub project_doc: Option<String>,
|
||||
|
||||
@@ -1021,11 +1021,6 @@ impl App {
|
||||
// once here so every render pass — main cache and full-screen
|
||||
// overlay — shares one effective width; `None` = full width.
|
||||
prose_measure: config.prose_measure(),
|
||||
header_items: config
|
||||
.tui
|
||||
.as_ref()
|
||||
.and_then(|tui| tui.header_items.clone())
|
||||
.unwrap_or_else(crate::config::HeaderItem::default_header),
|
||||
project_doc: None,
|
||||
plan_state,
|
||||
todos,
|
||||
|
||||
@@ -137,6 +137,8 @@ mod tests {
|
||||
("∿", "~"),
|
||||
("⋯", "."),
|
||||
("⏱", "@"),
|
||||
("🐳", "w"),
|
||||
("🐋", "w"),
|
||||
] {
|
||||
assert_eq!(ascii_fallback(rich), Some(safe));
|
||||
}
|
||||
|
||||
@@ -103,6 +103,9 @@ pub fn motion_while_typing(key: &KeyEvent) -> Option<Motion> {
|
||||
KeyCode::Left => Some(Motion::RegionPrev),
|
||||
KeyCode::Right => Some(Motion::RegionNext),
|
||||
KeyCode::BackTab => Some(Motion::RegionPrev),
|
||||
// Some terminals report shift+tab as Tab carrying SHIFT rather than
|
||||
// as BackTab; both are the same "previous region" motion.
|
||||
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Motion::RegionPrev),
|
||||
KeyCode::Tab => Some(Motion::RegionNext),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -274,29 +274,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_marker_uses_the_live_work_status_role() {
|
||||
let mut app = test_app();
|
||||
// Match Terminal intentionally aliases both roles to ANSI Cyan. Use
|
||||
// the branded palette here to prove the renderer selects the working
|
||||
// slot rather than merely observing an equal terminal color.
|
||||
app.ui_theme = codewhale_palette::UI_THEME;
|
||||
assert_eq!(ShellPhase::Working.color(&app), app.ui_theme.status_working);
|
||||
assert_ne!(ShellPhase::Working.color(&app), app.ui_theme.info);
|
||||
assert_eq!(
|
||||
crate::tui::underwater::phase_ink(ShellPhase::Working),
|
||||
ChromeInk::Active
|
||||
);
|
||||
assert_eq!(
|
||||
crate::tui::underwater::phase_ink(ShellPhase::Failed),
|
||||
ChromeInk::Failure
|
||||
);
|
||||
assert_ne!(
|
||||
crate::tui::underwater::phase_ink(ShellPhase::Working).family(),
|
||||
codewhale_palette::SemanticFamily::Failure
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_footer_preserves_unresolved_notice_behind_later_routine_info() {
|
||||
use crate::tui::app::StatusToastLevel;
|
||||
|
||||
@@ -11,26 +11,22 @@ use std::borrow::Cow;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Paragraph, Widget},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::config::HeaderItem;
|
||||
use crate::tui::ui_text::{semantic_truncate, text_display_width};
|
||||
use crate::tui::{
|
||||
app::{App, HeaderActionTarget, HeaderHitbox, OnboardingState},
|
||||
footer_ui::format_token_count_compact,
|
||||
app::{App, OnboardingState},
|
||||
ocean::COMPLETION_BREATH_MS,
|
||||
views::ModalKind,
|
||||
};
|
||||
use codewhale_config::AppMode;
|
||||
use codewhale_execpolicy::ApprovalMode;
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
use codewhale_palette::{ChromeInk, chrome_style};
|
||||
use codewhale_palette::ChromeInk;
|
||||
|
||||
/// Responsive density tier. It changes how much truth is shown, never the
|
||||
/// underlying state grammar.
|
||||
@@ -640,28 +636,6 @@ impl ShellPhase {
|
||||
Self::Failed => tr(locale, MessageId::PhaseFailed),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell
|
||||
// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice.
|
||||
pub fn color(self, app: &App) -> Color {
|
||||
phase_ink(self).color(&app.ui_theme)
|
||||
}
|
||||
}
|
||||
|
||||
/// Status-bar phase ink. Failure red is only `Failed`.
|
||||
#[must_use]
|
||||
pub(crate) fn phase_ink(phase: ShellPhase) -> ChromeInk {
|
||||
match phase {
|
||||
ShellPhase::Idle => ChromeInk::Metadata,
|
||||
ShellPhase::Done => ChromeInk::Outcome,
|
||||
ShellPhase::Typing => ChromeInk::Identity,
|
||||
// Verifying shares the live seafoam hue; the tick-vs-bubble
|
||||
// marker carries the checking/searching distinction.
|
||||
ShellPhase::Working | ShellPhase::Verifying => ChromeInk::Active,
|
||||
ShellPhase::Waiting | ShellPhase::Approval => ChromeInk::Waiting,
|
||||
ShellPhase::Failed => ChromeInk::Failure,
|
||||
}
|
||||
}
|
||||
|
||||
/// Exhaustive on purpose: a new [`AppMode`] must be handed a Policy ink
|
||||
@@ -682,12 +656,6 @@ fn header_permission_ink(mode: ApprovalMode) -> ChromeInk {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell
|
||||
// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice.
|
||||
fn header_fg(app: &App, ink: ChromeInk) -> Style {
|
||||
chrome_style(&app.ui_theme, ink)
|
||||
}
|
||||
|
||||
/// One posture word with its ink — the unit the classic header's lockup was
|
||||
/// made of, now carried as merged-footer chips.
|
||||
pub(crate) type PostureChip = (Cow<'static, str>, ChromeInk);
|
||||
@@ -996,10 +964,6 @@ fn filesystem_scope_notice(app: &App) -> Option<Cow<'static, str>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn span_width(spans: &[Span<'_>]) -> usize {
|
||||
spans.iter().map(|span| span.content.width()).sum()
|
||||
}
|
||||
|
||||
fn truncate_to_width(text: &str, width: usize) -> String {
|
||||
if text.width() <= width {
|
||||
return text.to_string();
|
||||
@@ -1024,431 +988,6 @@ fn truncate_to_width(text: &str, width: usize) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell
|
||||
// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice.
|
||||
fn compact_tokens(tokens: i64) -> String {
|
||||
if tokens >= 1_000_000 {
|
||||
format!("{:.1}M", tokens as f64 / 1_000_000.0)
|
||||
} else if tokens >= 1_000 {
|
||||
format!("{:.0}K", tokens as f64 / 1_000.0)
|
||||
} else {
|
||||
tokens.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// classic header/band renderer: superseded by the Tideline shell
|
||||
// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice.
|
||||
/// The context meter is one measured fact: an exact percentage for scanning,
|
||||
/// a token fraction for auditability when room permits, and a short bar for
|
||||
/// peripheral vision. It is deliberately the final header fact so its rect
|
||||
/// stays stable and can point at the inspector without parsing rendered text.
|
||||
fn header_context_meter(app: &App, tier: ShellTier) -> Option<Span<'static>> {
|
||||
crate::tui::ui::context_usage_snapshot(app).map(|(used, max, percent)| {
|
||||
let filled = ((percent / 100.0) * 5.0).ceil().clamp(0.0, 5.0) as usize;
|
||||
let percentage = format!("{percent:.0}%");
|
||||
let text = match tier {
|
||||
ShellTier::Compact => format!("ctx {percentage}"),
|
||||
ShellTier::Normal | ShellTier::Wide => format!(
|
||||
"context {percentage} {}/{} {}{}",
|
||||
compact_tokens(used),
|
||||
compact_tokens(i64::from(max)),
|
||||
"▰".repeat(filled),
|
||||
"▱".repeat(5usize.saturating_sub(filled)),
|
||||
),
|
||||
};
|
||||
Span::styled(text, header_fg(app, ChromeInk::Info))
|
||||
})
|
||||
}
|
||||
|
||||
/// Return concrete, typed header targets for the latest frame.
|
||||
///
|
||||
/// The context meter is right-aligned and always the final header span, so
|
||||
/// its visible geometry does not depend on optional git/token facts. The
|
||||
/// keyboard route remains `Alt+C`; this gives that same inspectable fact a
|
||||
/// mouse route without inventing another context screen or state owner.
|
||||
// classic header/band renderer: superseded by the Tideline shell
|
||||
// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice.
|
||||
// Its posture-floor guard (a hitbox never claims overlapped cells) is the
|
||||
// discipline `topbar::context_meter_hitbox` carries forward.
|
||||
#[must_use]
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
pub(crate) fn header_hitboxes(area: Rect, app: &App) -> Vec<HeaderHitbox> {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let tier = ShellTier::for_chrome_width(area.width);
|
||||
let Some(meter) = header_context_meter(app, tier) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let width = u16::try_from(span_width(&[meter]))
|
||||
.unwrap_or(area.width)
|
||||
.min(area.width);
|
||||
if width == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
// The posture lockup is the header's guaranteed floor and is never
|
||||
// truncated to make room for the right cluster (see
|
||||
// render_header_with_git_status). At compact widths that floor can run
|
||||
// into the meter's columns, so a hitbox anchored blindly at the right
|
||||
// edge would claim cells the posture actually paints (review finding 5).
|
||||
// Recompute the floor's width with the same spans the renderer composes
|
||||
// and refuse the hitbox when the two would overlap.
|
||||
let mut posture_width = 0usize;
|
||||
if let Some(indicator) = crate::tui::widgets::header_status_indicator_frame(
|
||||
(!app.low_motion && app.fancy_animations)
|
||||
.then_some(app.turn_started_at)
|
||||
.flatten(),
|
||||
&app.status_indicator,
|
||||
) {
|
||||
posture_width += indicator.width() + GROUP_GAP.len();
|
||||
}
|
||||
posture_width += mode_label(app.ui_locale, app.mode).width();
|
||||
posture_width += FIELD_JOIN.len() + permission_label(app).width();
|
||||
if let Some(scope) = filesystem_scope_notice(app) {
|
||||
posture_width += FIELD_JOIN.len() + scope.width();
|
||||
}
|
||||
let meter_start = usize::from(area.width.saturating_sub(width));
|
||||
if meter_start <= posture_width.saturating_add(usize::from(width > 0)) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![HeaderHitbox {
|
||||
area: Rect {
|
||||
x: area.x.saturating_add(area.width.saturating_sub(width)),
|
||||
y: area.y,
|
||||
width,
|
||||
height: 1,
|
||||
},
|
||||
target: HeaderActionTarget::InspectContext,
|
||||
}]
|
||||
}
|
||||
|
||||
fn session_token_breakdown(app: &App) -> Option<Span<'static>> {
|
||||
app.header_items.contains(&HeaderItem::Tokens).then(|| {
|
||||
Span::styled(
|
||||
format!(
|
||||
"{} in · {} cch · {} out",
|
||||
format_token_count_compact(u64::from(app.session.displayed_total_input_tokens())),
|
||||
format_token_count_compact(u64::from(
|
||||
app.session.displayed_total_cache_hit_tokens(),
|
||||
)),
|
||||
format_token_count_compact(u64::from(app.session.displayed_total_output_tokens())),
|
||||
),
|
||||
header_fg(app, ChromeInk::Info),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The header speaks with exactly two separators, and each one means one
|
||||
/// thing.
|
||||
///
|
||||
/// [`FIELD_JOIN`] binds words that qualify one another into a single phrase:
|
||||
/// `work · ask` is one statement of posture, not two facts. [`GROUP_GAP`]
|
||||
/// stands between whole facts — posture, then the goal chip, then the update
|
||||
/// notice; workspace, then the context meter.
|
||||
///
|
||||
/// Before this, every one of those boundaries was the same dotted separator at
|
||||
/// the same dim ink, so the header read as an undifferentiated list and there
|
||||
/// was nothing for the eye to group on. The gap is deliberately wider than the
|
||||
/// visual whitespace inside `" · "` — four blank columns against one — because
|
||||
/// that ratio is the only thing carrying the grouping.
|
||||
#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell
|
||||
// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice.
|
||||
const FIELD_JOIN: &str = " · ";
|
||||
#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell
|
||||
// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice.
|
||||
const GROUP_GAP: &str = " ";
|
||||
|
||||
/// Append one chrome element, inserting the group separator only between
|
||||
/// elements so an absent element never leaves trailing padding.
|
||||
#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell
|
||||
// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice.
|
||||
fn push_chrome(spans: &mut Vec<Span<'static>>, span: Span<'static>) {
|
||||
if !spans.is_empty() {
|
||||
spans.push(Span::raw(GROUP_GAP));
|
||||
}
|
||||
spans.push(span);
|
||||
}
|
||||
|
||||
/// Render the one-line shell header. Immediate operating posture and workspace
|
||||
/// truth live here; quieter route identity lives beside the phase footer.
|
||||
#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell
|
||||
// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice.
|
||||
pub fn render_header(area: Rect, buf: &mut Buffer, app: &App) {
|
||||
let git_status = crate::tui::git_status::cached_status();
|
||||
render_header_with_git_status(area, buf, app, &git_status);
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell
|
||||
// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice.
|
||||
fn render_header_with_git_status(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
app: &App,
|
||||
git_status: &crate::tui::git_status::GitStatusSnapshot,
|
||||
) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let tier = ShellTier::for_chrome_width(area.width);
|
||||
Block::default()
|
||||
.style(Style::default().bg(app.ui_theme.header_bg))
|
||||
.render(area, buf);
|
||||
|
||||
let mode_color = header_mode_ink(app.mode).color(&app.ui_theme);
|
||||
// Match the composer's warm top edge exactly: Ask amber, Auto-Review
|
||||
// Signal Gold, and Full Access coral.
|
||||
let permission_color = header_permission_ink(app.approval_mode).color(&app.ui_theme);
|
||||
let dim = header_fg(app, ChromeInk::MetadataDim);
|
||||
// `status_indicator` owns the single header mark. It used to be filtered
|
||||
// against the literal "cw" because the header also hardcoded a leading
|
||||
// "cw" span, and `header_status_indicator_frame` collapses `cw`, the
|
||||
// legacy `whale` opt-in, and unknown values onto that same mark — so the
|
||||
// filter silently discarded three of the setting's four documented values
|
||||
// and left `off` with nothing to turn off (#5512). There is one mark now,
|
||||
// and this setting decides what occupies it.
|
||||
let status_indicator = crate::tui::widgets::header_status_indicator_frame(
|
||||
(!app.low_motion && app.fancy_animations)
|
||||
.then_some(app.turn_started_at)
|
||||
.flatten(),
|
||||
&app.status_indicator,
|
||||
);
|
||||
// The posture lockup: mark, then mode and permission (and the filesystem
|
||||
// scope when it deviates) joined into one phrase. This is the guaranteed
|
||||
// floor of the header — everything after it is sheddable — so it is built
|
||||
// once and reused by the cramped rebuild below rather than spelled twice.
|
||||
let mut left = Vec::new();
|
||||
if let Some(indicator) = status_indicator {
|
||||
left.push(Span::styled(
|
||||
indicator,
|
||||
header_fg(app, ChromeInk::Identity).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
left.push(Span::raw(GROUP_GAP));
|
||||
}
|
||||
left.push(Span::styled(
|
||||
mode_label(app.ui_locale, app.mode),
|
||||
Style::default().fg(mode_color),
|
||||
));
|
||||
// Permission is safety state, not optional chrome. Compact terminals shed
|
||||
// auxiliary detail, but keep mode and the effective posture.
|
||||
left.push(Span::styled(FIELD_JOIN, dim));
|
||||
left.push(Span::styled(
|
||||
permission_label(app),
|
||||
Style::default().fg(permission_color),
|
||||
));
|
||||
let scope_notice = filesystem_scope_notice(app);
|
||||
if let Some(scope) = scope_notice.clone() {
|
||||
left.push(Span::styled(FIELD_JOIN, dim));
|
||||
left.push(Span::styled(scope, Style::default().fg(permission_color)));
|
||||
}
|
||||
let posture = left.clone();
|
||||
// Active-goal chip (#39): the ocean shell has no sidebar, so the topbar
|
||||
// is the only always-on surface where a goal set via `create_goal` can
|
||||
// live. Objective truncated to a fixed budget; terminal goals render
|
||||
// nothing. The cramped-layout rebuild below keeps the chip in `suffix`.
|
||||
let goal_chip =
|
||||
crate::tui::footer_ui::active_goal_chip_state(app).map(|(objective, paused)| {
|
||||
let budget = if paused { 22 } else { 26 };
|
||||
let flat = objective.trim().replace(['\n', '\r'], " ");
|
||||
let text = if paused {
|
||||
format!("goal paused {}", truncate_to_width(&flat, budget))
|
||||
} else {
|
||||
format!("goal {}", truncate_to_width(&flat, budget))
|
||||
};
|
||||
let color = if paused {
|
||||
ChromeInk::Attention.color(&app.ui_theme)
|
||||
} else {
|
||||
ChromeInk::Active.color(&app.ui_theme)
|
||||
};
|
||||
(text, color)
|
||||
});
|
||||
if let Some((text, color)) = &goal_chip {
|
||||
left.push(Span::raw(GROUP_GAP));
|
||||
left.push(Span::styled(
|
||||
text.clone(),
|
||||
Style::default().fg(*color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
// Workflow-run chip (#5040): the same `WorkflowPanel::top_bar_chip` the
|
||||
// classic header shows, so a collapsed run stays visible on the ocean
|
||||
// shell too. No workflow panel means no chip. The cramped-layout rebuild
|
||||
// below keeps the chip in `suffix` alongside the goal chip.
|
||||
let workflow_chip = app.workflow_panel.as_ref().map(|panel| {
|
||||
let ink = if matches!(
|
||||
panel.lifecycle,
|
||||
crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Degraded
|
||||
) {
|
||||
ChromeInk::Attention
|
||||
} else {
|
||||
ChromeInk::Info
|
||||
};
|
||||
(panel.top_bar_chip(), ink.color(&app.ui_theme))
|
||||
});
|
||||
if let Some((text, color)) = &workflow_chip {
|
||||
left.push(Span::raw(GROUP_GAP));
|
||||
left.push(Span::styled(
|
||||
text.clone(),
|
||||
Style::default().fg(*color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
// Update-available chip (#14): a quiet, persistent affordance set once by
|
||||
// the startup version check. Gets the workflow chip's treatment: last in
|
||||
// the left cluster, the route label yields its budget first, and the chip
|
||||
// drops cleanly when even a minimal chip cannot fit — never a modal,
|
||||
// never mid-chip clipping.
|
||||
let update_chip = app
|
||||
.update_available
|
||||
.as_ref()
|
||||
.map(|label| (label.clone(), ChromeInk::Attention.color(&app.ui_theme)));
|
||||
if let Some((text, color)) = &update_chip {
|
||||
left.push(Span::raw(GROUP_GAP));
|
||||
left.push(Span::styled(
|
||||
text.clone(),
|
||||
Style::default().fg(*color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
|
||||
let context_meter = header_context_meter(app, tier);
|
||||
let token_breakdown = (tier != ShellTier::Compact)
|
||||
.then(|| session_token_breakdown(app))
|
||||
.flatten();
|
||||
// Cached repository/worktree status only — never probe from the render path.
|
||||
// Background refresh is scheduled from the event loop / idle ticks.
|
||||
let git_label = crate::tui::git_status::chrome_label(git_status).map(|label| {
|
||||
let max_width = match tier {
|
||||
ShellTier::Compact => 24,
|
||||
ShellTier::Normal => 36,
|
||||
ShellTier::Wide => 52,
|
||||
};
|
||||
Span::styled(
|
||||
truncate_to_width(&label, max_width),
|
||||
header_fg(app, crate::tui::git_status::chrome_ink()),
|
||||
)
|
||||
});
|
||||
|
||||
// Baseline right-hand chrome: git, then the context meter.
|
||||
//
|
||||
// The build version used to close this cluster. It was already the first
|
||||
// thing the header sacrificed — present only on `Wide`, gone below 110
|
||||
// columns — which is the layout admitting it was never load-bearing. It is
|
||||
// a fact you check deliberately (`codewhale --version`, `codewhale
|
||||
// doctor`, the launch screen) exactly once, and the half of it that *is*
|
||||
// worth reading mid-session — "your build is stale" — already has its own
|
||||
// chip on the left. Fifteen columns of the primary chrome on every screen
|
||||
// forever bought a numeral nobody was reading.
|
||||
let mut right = Vec::new();
|
||||
if let Some(git_label) = git_label.clone() {
|
||||
push_chrome(&mut right, git_label);
|
||||
}
|
||||
if let Some(context_meter) = context_meter.clone() {
|
||||
push_chrome(&mut right, context_meter);
|
||||
}
|
||||
|
||||
// The posture lockup is the header's floor: mark, mode, permission, and a
|
||||
// deviating filesystem scope never yield their columns to anything on the
|
||||
// right. It is measured, not re-derived, so the floor cannot drift away
|
||||
// from what actually gets drawn.
|
||||
let minimum_left_width = span_width(&posture);
|
||||
let available = usize::from(area.width);
|
||||
// The optional token breakdown is the only elidable element: it is added
|
||||
// between the git label and the context meter when the terminal is wide
|
||||
// enough to keep the whole baseline plus the guaranteed-left minimum.
|
||||
if let Some(token_breakdown) = token_breakdown {
|
||||
let mut enhanced_right = Vec::new();
|
||||
if let Some(git_label) = git_label.clone() {
|
||||
push_chrome(&mut enhanced_right, git_label);
|
||||
}
|
||||
push_chrome(&mut enhanced_right, token_breakdown);
|
||||
if let Some(context_meter) = context_meter.clone() {
|
||||
push_chrome(&mut enhanced_right, context_meter);
|
||||
}
|
||||
let enhanced_width = span_width(&enhanced_right);
|
||||
let gap = usize::from(enhanced_width > 0);
|
||||
if minimum_left_width
|
||||
.saturating_add(gap)
|
||||
.saturating_add(enhanced_width)
|
||||
<= available
|
||||
{
|
||||
right = enhanced_right;
|
||||
}
|
||||
}
|
||||
|
||||
let right_width = span_width(&right);
|
||||
let left_budget = available.saturating_sub(right_width + usize::from(right_width > 0));
|
||||
if span_width(&left) > left_budget {
|
||||
// Cramped: keep the posture lockup exactly as composed and re-hang the
|
||||
// chips behind it. Rebuilding the lockup by hand here is how the two
|
||||
// passes used to disagree about what the header guarantees.
|
||||
let mut compact_left = posture.clone();
|
||||
// The goal chip survives cramped layouts too — it is operator state,
|
||||
// not decoration. The route label yields its budget first (down to
|
||||
// nothing, as it always has); below that the goal itself truncates,
|
||||
// and when even a minimal chip cannot fit it drops rather than
|
||||
// clipping mid-word (#39).
|
||||
let base_fixed = span_width(&compact_left);
|
||||
if let Some((text, color)) = &goal_chip {
|
||||
let goal_room = left_budget
|
||||
.saturating_sub(base_fixed)
|
||||
.saturating_sub(GROUP_GAP.len());
|
||||
if goal_room >= 8 {
|
||||
compact_left.push(Span::raw(GROUP_GAP));
|
||||
compact_left.push(Span::styled(
|
||||
truncate_to_width(text, goal_room),
|
||||
Style::default().fg(*color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
}
|
||||
// The workflow chip (#5040) is operator state too, so it gets the
|
||||
// goal chip's treatment: whatever room remains after the chips ahead
|
||||
// of it, clean truncation, and a clean drop when even a minimal chip
|
||||
// cannot fit. The route label still yields its budget first.
|
||||
if let Some((text, color)) = &workflow_chip {
|
||||
let workflow_room = left_budget
|
||||
.saturating_sub(span_width(&compact_left))
|
||||
.saturating_sub(GROUP_GAP.len());
|
||||
if workflow_room >= 8 {
|
||||
compact_left.push(Span::raw(GROUP_GAP));
|
||||
compact_left.push(Span::styled(
|
||||
truncate_to_width(text, workflow_room),
|
||||
Style::default().fg(*color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
}
|
||||
// The update chip (#14) gets the same treatment, last in line: it is
|
||||
// useful, but it yields to every piece of operator state ahead of it.
|
||||
if let Some((text, color)) = &update_chip {
|
||||
let update_room = left_budget
|
||||
.saturating_sub(span_width(&compact_left))
|
||||
.saturating_sub(GROUP_GAP.len());
|
||||
if update_room >= 8 {
|
||||
compact_left.push(Span::raw(GROUP_GAP));
|
||||
compact_left.push(Span::styled(
|
||||
truncate_to_width(text, update_room),
|
||||
Style::default().fg(*color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
}
|
||||
left = compact_left;
|
||||
}
|
||||
let left_width = span_width(&left);
|
||||
let gap = available.saturating_sub(left_width + right_width);
|
||||
left.push(Span::raw(" ".repeat(gap)));
|
||||
left.extend(right);
|
||||
let title_area = Rect { height: 1, ..area };
|
||||
Paragraph::new(Line::from(left)).render(title_area, buf);
|
||||
if area.height > 1 {
|
||||
let rule_area = Rect {
|
||||
y: area.y.saturating_add(1),
|
||||
height: 1,
|
||||
..area
|
||||
};
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
"─".repeat(usize::from(area.width)),
|
||||
Style::default().fg(app.ui_theme.border),
|
||||
)))
|
||||
.render(rule_area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
/// The transcript rows the idle brand mark needs before it will draw at all.
|
||||
///
|
||||
/// Named so the *layout* can honour it before the frame is split. Anything that reserves rows above
|
||||
@@ -2884,202 +2423,6 @@ mod empty_state_caption_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod header_tests {
|
||||
use super::{
|
||||
FIELD_JOIN, GROUP_GAP, filesystem_scope_notice, header_hitboxes,
|
||||
render_header_with_git_status,
|
||||
};
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::widgets::workflow_panel::{WorkflowPanel, WorkflowPanelLifecycle};
|
||||
use codewhale_config::AppMode;
|
||||
use codewhale_execpolicy::ApprovalMode;
|
||||
use codewhale_palette::ChromeInk;
|
||||
use ratatui::{buffer::Buffer, layout::Rect};
|
||||
|
||||
fn app() -> App {
|
||||
let mut app = crate::test_support::test_app_with_options(
|
||||
crate::test_support::test_tui_options(std::env::temp_dir()),
|
||||
);
|
||||
// Enforcement present, so the scope chip reflects the policy rather
|
||||
// than the host's missing backend.
|
||||
app.sandbox_backend = Some(crate::sandbox::SandboxType::None);
|
||||
app.mode = AppMode::Agent;
|
||||
app.approval_mode = ApprovalMode::Suggest;
|
||||
app
|
||||
}
|
||||
|
||||
fn header_line(app: &App, width: u16) -> String {
|
||||
let area = Rect::new(0, 0, width, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_header_with_git_status(
|
||||
area,
|
||||
&mut buf,
|
||||
app,
|
||||
&crate::tui::git_status::GitStatusSnapshot::default(),
|
||||
);
|
||||
(0..width)
|
||||
.map(|x| buf[(x, 0)].symbol())
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_posture_spends_no_columns_on_the_expected_scope() {
|
||||
// `files: workspace` used to be printed on every frame of every
|
||||
// session: seventeen columns of the primary chrome restating the
|
||||
// default. A notice that never turns off cannot warn.
|
||||
let app = app();
|
||||
assert!(filesystem_scope_notice(&app).is_none());
|
||||
let line = header_line(&app, 120);
|
||||
assert!(!line.contains("files:"), "{line:?}");
|
||||
assert!(line.starts_with("codewhale"), "{line:?}");
|
||||
assert!(line.contains("work"), "{line:?}");
|
||||
assert!(line.contains("ask"), "{line:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_access_is_the_disclosure_and_is_not_restated() {
|
||||
// Full disk access is stated once, by the permission chip's own
|
||||
// name. A second `files: full disk` chip beside it said the same
|
||||
// thing twice; the mode name stays prominent and does the work.
|
||||
let mut app = app();
|
||||
app.approval_mode = ApprovalMode::Bypass;
|
||||
app.configured_sandbox_mode = Some("danger-full-access".to_string());
|
||||
assert!(filesystem_scope_notice(&app).is_none());
|
||||
let line = header_line(&app, 120);
|
||||
assert!(!line.contains("files:"), "{line:?}");
|
||||
assert!(
|
||||
line.contains(&*super::tr(
|
||||
app.ui_locale,
|
||||
super::MessageId::ChipPermissionFullAccess
|
||||
)),
|
||||
"{line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_access_never_stands_alone_without_its_scope() {
|
||||
// Bypass clamped to workspace-write: the permission chip says
|
||||
// "Full Access" while writes are in fact confined. That pairing is the
|
||||
// exact misreading the scope chip exists to prevent, so the chip must
|
||||
// speak even though workspace-write is otherwise the quiet default.
|
||||
let mut full = app();
|
||||
full.approval_mode = ApprovalMode::Bypass;
|
||||
full.configured_sandbox_mode = Some("workspace-write".to_string());
|
||||
let notice = filesystem_scope_notice(&full)
|
||||
.expect("Full Access must never appear without a scope beside it");
|
||||
assert_eq!(notice, "files: workspace");
|
||||
let line = header_line(&full, 120);
|
||||
assert!(line.contains("files: workspace"), "{line:?}");
|
||||
|
||||
// And the default posture still stays quiet.
|
||||
let mut quiet = app();
|
||||
quiet.approval_mode = ApprovalMode::Suggest;
|
||||
quiet.configured_sandbox_mode = Some("workspace-write".to_string());
|
||||
assert!(filesystem_scope_notice(&quiet).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_mode_does_not_say_read_only_twice() {
|
||||
let mut app = app();
|
||||
app.mode = AppMode::Plan;
|
||||
assert!(filesystem_scope_notice(&app).is_none());
|
||||
let line = header_line(&app, 120);
|
||||
assert!(line.contains("read only"), "{line:?}");
|
||||
assert!(!line.contains("files: read-only"), "{line:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_build_version_is_not_permanent_chrome() {
|
||||
// It was already `Wide`-only, which is the layout admitting it was
|
||||
// never load-bearing; `codewhale --version`, `codewhale doctor` and
|
||||
// the launch screen are where a version is actually looked up, and
|
||||
// the half worth reading mid-session is the update chip.
|
||||
let app = app();
|
||||
for width in [60u16, 80, 120, 200] {
|
||||
let line = header_line(&app, width);
|
||||
assert!(
|
||||
!line.contains(concat!("v", env!("CODEWHALE_BUILD_VERSION"))),
|
||||
"width {width}: {line:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chips_are_separated_from_posture_by_a_wider_gap_than_the_posture_join() {
|
||||
// One weight per meaning: `" · "` binds words into one phrase, the
|
||||
// group gap stands between whole facts. If a goal chip hangs off the
|
||||
// same dotted separator that joins mode to permission, the header is
|
||||
// an undifferentiated list again.
|
||||
let mut app = app();
|
||||
app.update_available = Some("update 0.9.11".to_string());
|
||||
let line = header_line(&app, 120);
|
||||
assert!(
|
||||
line.contains(&format!("ask{GROUP_GAP}update 0.9.11")),
|
||||
"{line:?}",
|
||||
);
|
||||
assert!(line.contains(&format!("work{FIELD_JOIN}ask")), "{line:?}");
|
||||
assert!(
|
||||
unicode_width::UnicodeWidthStr::width(GROUP_GAP)
|
||||
> unicode_width::UnicodeWidthStr::width(FIELD_JOIN),
|
||||
"the group gap must out-space the phrase join or nothing groups",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_degraded_workflow_chip_uses_attention_ink() {
|
||||
let mut app = app();
|
||||
let mut panel = WorkflowPanel::new("workflow-partial", "review release", 1_000);
|
||||
panel.lifecycle = WorkflowPanelLifecycle::Degraded;
|
||||
panel.expanded = false;
|
||||
panel.completed_at_ms = Some(2_000);
|
||||
app.workflow_panel = Some(panel);
|
||||
|
||||
let width = 200;
|
||||
let area = Rect::new(0, 0, width, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_header_with_git_status(
|
||||
area,
|
||||
&mut buf,
|
||||
&app,
|
||||
&crate::tui::git_status::GitStatusSnapshot::default(),
|
||||
);
|
||||
let text = (0..width).map(|x| buf[(x, 0)].symbol()).collect::<String>();
|
||||
let start = text.find("wf degraded").expect("degraded workflow chip");
|
||||
let expected = ChromeInk::Attention.color(&app.ui_theme);
|
||||
for x in start..start + "wf degraded".len() {
|
||||
assert_eq!(
|
||||
buf[(x as u16, 0)].fg,
|
||||
expected,
|
||||
"collapsed degraded chip must stay amber at column {x}: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_context_meter_states_its_percentage_and_registers_an_inspector_target() {
|
||||
// The percentage is the direct operator question ("how full am I?").
|
||||
// Fraction remains the auditable fact and the bar is the glance.
|
||||
let mut app = app();
|
||||
app.session.total_input_tokens = 3_000;
|
||||
let line = header_line(&app, 120);
|
||||
if line.contains('▱') || line.contains('▰') {
|
||||
assert!(!line.contains('['), "{line:?}");
|
||||
assert!(line.contains("context"), "{line:?}");
|
||||
assert!(line.contains('%'), "{line:?}");
|
||||
let hitboxes = header_hitboxes(Rect::new(0, 0, 120, 1), &app);
|
||||
assert_eq!(hitboxes.len(), 1);
|
||||
assert_eq!(hitboxes[0].area.right(), 120);
|
||||
assert_eq!(
|
||||
hitboxes[0].target,
|
||||
crate::tui::app::HeaderActionTarget::InspectContext
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tideline startup stage — the launch header (shell design §2.0 item 2,
|
||||
// founder direction 2026-09-02: "Claude Code's structure, not a centred hero
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
//! Status-indicator frame resolution for the header chip cluster.
|
||||
//!
|
||||
//! The classic-shell `HeaderWidget` was removed with the classic shell in
|
||||
//! 0.9.4 (rail unification); what remains here is the frame picker shared
|
||||
//! by the underwater header.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
/// Milliseconds between status-indicator frame advances. The original
|
||||
/// `deepseek_squiggle` (v0.3.5 → v0.8.x) used 420 ms; the dot replacement
|
||||
/// used the same cadence. Keep both at 420 ms so the visual rhythm matches
|
||||
/// what long-time users remember.
|
||||
#[allow(dead_code)] // the echolocation animation family (spec §5e, 420 ms cadence) reuses
|
||||
// this picker when its landing slice animates the merged-footer chip;
|
||||
// the shell renders the still frame until then (2026-08-29).
|
||||
const STATUS_INDICATOR_FRAME_MS: u128 = 420;
|
||||
|
||||
/// Geometric replacement frames shipped between v0.8.x and v0.8.29.
|
||||
/// Every frame is one cell wide so the provider/model label never shifts
|
||||
/// while the animation advances.
|
||||
#[allow(dead_code)] // the echolocation animation family (spec §5e, 420 ms cadence) reuses
|
||||
// this picker when its landing slice animates the merged-footer chip;
|
||||
// the shell renders the still frame until then (2026-08-29).
|
||||
const STATUS_INDICATOR_DOT_FRAMES: &[&str] = &["◍", "◉", "◌", "◌", "◉", "◍"];
|
||||
|
||||
/// Resolve the current status-indicator frame to render in the header
|
||||
/// chip cluster.
|
||||
///
|
||||
/// `turn_started_at = None` (no active turn) returns the first frame so the
|
||||
/// chip is *visible* but not animating — it's a chip, not a spinner. As
|
||||
/// soon as a turn starts, the elapsed time keys the cycle.
|
||||
///
|
||||
/// `mode` accepts the canonical names `"cw"`, `"dots"`, `"off"`. The default
|
||||
/// is the plain `Codewhale` wordmark: identity is stated once in the chrome,
|
||||
/// while live state uses the existing semantic rows and receipts. Legacy
|
||||
/// `"whale"` values (still present in persisted settings) normalize to that
|
||||
/// wordmark; unknown values do too. `"off"` returns `None` so the caller can
|
||||
/// hide the chip outright.
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // the echolocation animation family (spec §5e, 420 ms cadence) reuses
|
||||
// this picker when its landing slice animates the merged-footer chip;
|
||||
// the shell renders the still frame until then (2026-08-29).
|
||||
pub fn header_status_indicator_frame(
|
||||
turn_started_at: Option<Instant>,
|
||||
mode: &str,
|
||||
) -> Option<&'static str> {
|
||||
let frames: &[&str] = match mode.trim().to_ascii_lowercase().as_str() {
|
||||
"off" | "none" | "hidden" | "false" => return None,
|
||||
"dots" | "dot" => STATUS_INDICATOR_DOT_FRAMES,
|
||||
// Canonical mark, legacy whale opt-ins, and unknown values all land
|
||||
// on the static wordmark so the header never reintroduces an emoji
|
||||
// chip beside the operational chrome.
|
||||
_ => return Some("codewhale"),
|
||||
};
|
||||
let elapsed_ms = turn_started_at
|
||||
.map(|t| t.elapsed().as_millis())
|
||||
.unwrap_or(0);
|
||||
let idx = (elapsed_ms / STATUS_INDICATOR_FRAME_MS) as usize % frames.len();
|
||||
Some(frames[idx])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn legacy_whale_indicator_settings_normalize_to_the_wordmark() {
|
||||
// The whale emoji chip is retired from the header (2026-07-23):
|
||||
// persisted `status_indicator = "whale"` opt-ins render the plain
|
||||
// wordmark instead, idle or mid-turn.
|
||||
for legacy in ["whale", "🐳", "🐋"] {
|
||||
assert_eq!(
|
||||
super::header_status_indicator_frame(None, legacy),
|
||||
Some("codewhale"),
|
||||
"legacy mode {legacy:?} must normalize to the cw mark"
|
||||
);
|
||||
assert_eq!(
|
||||
super::header_status_indicator_frame(Some(std::time::Instant::now()), legacy),
|
||||
Some("codewhale"),
|
||||
"legacy mode {legacy:?} must stay static mid-turn"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cw_indicator_is_static_wordmark() {
|
||||
assert_eq!(
|
||||
super::header_status_indicator_frame(None, "cw"),
|
||||
Some("codewhale")
|
||||
);
|
||||
assert_eq!(
|
||||
super::header_status_indicator_frame(Some(std::time::Instant::now()), "cw"),
|
||||
Some("codewhale")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dots_indicator_uses_geometric_frames() {
|
||||
let frame = super::header_status_indicator_frame(None, "dots");
|
||||
assert_eq!(frame, Some("\u{25CD}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_indicator_returns_none_so_chip_is_hidden() {
|
||||
assert!(super::header_status_indicator_frame(None, "off").is_none());
|
||||
// Aliases mirror the parser in Settings.
|
||||
assert!(super::header_status_indicator_frame(None, "none").is_none());
|
||||
assert!(super::header_status_indicator_frame(None, "hidden").is_none());
|
||||
assert!(super::header_status_indicator_frame(None, "false").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_indicator_mode_defaults_to_cw() {
|
||||
let frame = super::header_status_indicator_frame(None, "wahel-typo");
|
||||
assert_eq!(frame, Some("codewhale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_glyphs_have_narrow_ascii_fallbacks() {
|
||||
assert_eq!(crate::tui::glyphs::ascii_fallback("🐳"), Some("w"));
|
||||
assert_eq!(crate::tui::glyphs::ascii_fallback("🐋"), Some("w"));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
pub mod agent_card;
|
||||
mod header;
|
||||
pub mod key_hint;
|
||||
pub mod pending_input_preview;
|
||||
mod renderable;
|
||||
pub mod tool_card;
|
||||
pub mod workflow_panel;
|
||||
|
||||
pub use header::header_status_indicator_frame;
|
||||
pub use renderable::Renderable;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -970,24 +970,6 @@ impl WorkflowPanel {
|
||||
truncate_line_to_width(&raw, width.max(1))
|
||||
}
|
||||
|
||||
/// One-chip summary for the top status bar (#5040): lifecycle,
|
||||
/// done/total children, failures, elapsed. Intentionally terse — the
|
||||
/// expanded panel and history card carry the detail.
|
||||
#[must_use]
|
||||
pub fn top_bar_chip(&self) -> String {
|
||||
let (done, total) = self.done_total();
|
||||
let (failed, _cancelled) = self.failure_cancel_counts();
|
||||
let mut chip = format!(
|
||||
"wf {} {done}/{total}",
|
||||
self.lifecycle.display_label(self.locale)
|
||||
);
|
||||
if failed > 0 {
|
||||
chip.push_str(&format!(" · {failed} fail"));
|
||||
}
|
||||
chip.push_str(&format!(" · {}", self.elapsed_label()));
|
||||
chip
|
||||
}
|
||||
|
||||
/// Elapsed label shared with direct sub-agent cards.
|
||||
#[must_use]
|
||||
pub fn elapsed_label(&self) -> String {
|
||||
|
||||
@@ -2375,7 +2375,6 @@ reasoning contract, and all four membership ids omit generic sampling fields.
|
||||
|
||||
- `tui.terminal_probe_timeout_ms` (int, optional): legacy setting, accepted for configuration compatibility but no longer used. Startup sets raw mode directly after checking terminal ownership; worker scheduling delays do not abort startup.
|
||||
- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs <seconds>`; `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.
|
||||
- `tui.header_items` (array of strings, optional, default `[]`): opt-in header chips. Set `header_items = ["tokens"]` under `[tui]` to show the session input, cache-hit, and output token counts. Narrow terminals elide the optional chip; wide terminals show it alongside context utilization.
|
||||
- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.
|
||||
- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (default `1000`); see the Goal loop section below.
|
||||
- `tui.turn_wall_clock_secs` (int, optional, default `3600`): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Clamped to `30..=86400` (24 hours is the documented ceiling); `0` resolves to the default. When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.
|
||||
|
||||
@@ -1146,7 +1146,6 @@ DeepSeek V4 前缀缓存让 token 标签变得重要。这些数量保持分离
|
||||
- `tui.mouse_capture`(bool,可选,非 Windows 终端和备用屏幕活动时的 Windows Terminal/ConEmu/Cmder 上默认 `true`;旧 Windows 控制台和 JetBrains JediTerm 内部——PyCharm/IDEA/CLion 等——为 `false`,那里鼠标事件转义作为乱码文本漏进输入流,见 #878 / #898):启用内部鼠标滚动、转录选择、右键上下文动作和转录滚动条拖动。TUI 拥有的拖拽选择只复制转录文本,从段落中移除视觉换行列断点,保持选择限于转录窗格。设为 `false` 或带 `--no-mouse-capture` 运行使用原始终端选择;设为 `true` 或带 `--mouse-capture` 运行可在任何默认关闭处选择加入。在原始终端选择上,尤其是旧 Windows 控制台或鼠标捕获禁用时,选择可能跨越右侧栏并包含视觉换行,因为选择由终端而不是 TUI 拥有。
|
||||
- `tui.terminal_probe_timeout_ms`(int,可选):兼容旧配置而保留的设置,现已不再使用。启动时在检查终端所有权后直接设置原始模式,不再因工作线程调度延迟而中止启动。
|
||||
- `tui.stream_chunk_timeout_secs`(int,可选,默认 `900`):流式模型响应的每 SSE 块空闲超时。慢的本地或兼容服务器可以用 `/config stream_chunk_timeout_secs <seconds>` 提高;`0` 映射到默认,显式值必须 `1..=3600`。省略该键时旧 `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` 环境变量仍被遵循。
|
||||
- `tui.header_items`(字符串数组,可选,默认 `[]`):选择加入的头部芯片。在 `[tui]` 下设置 `header_items = ["tokens"]` 显示会话输入、缓存命中和输出 token 数。窄终端省略可选芯片;宽终端把它与上下文利用率并排显示。
|
||||
- `tui.osc8_links`(bool,可选,macOS/Linux 默认开启,Windows 默认关闭):在转录输出的 URL 周围发出 OSC 8 转义序列,这样支持的终端(iTerm2、Terminal.app 13+、Ghostty、Kitty、WezTerm、Alacritty、较新的 gnome-terminal/konsole)可以用终端的链接手势打开它们——通常是 macOS 上的 Cmd-click,Linux/Windows 上的 Ctrl-click。没有 OSC 8 支持的终端渲染普通标签并忽略转义。转义带外发出(不在缓冲区单元格内),所以列损坏不是问题;只在终端错误渲染 OSC 8 终止符本身时设 `false`。Windows 旧控制台默认关闭;用 `true` 选择加入。
|
||||
- `transcript.prose_measure`(正整数,可选,默认缺省 = 全宽):实时转录中散文单元格——用户消息、助手回答和推理/思考块——的换行上限,以列为单位(#5436)。缺省(或 `0`)使用全部内容宽度,与工具/状态单元格和 #5322 宽帧决策一致;前 105 列散文栏已移除。在超宽终端上设置正整数(例如 `[transcript]` 下的 `prose_measure = 120`)恢复有界的阅读度量。窄终端总是保持内容宽度——上限只从上方钳制。工具、diff 和状态单元格从不继承这个上限。无效值(负或非整数)在启动时以 `transcript.prose_measure` 配置错误被拒绝。每次渲染遍解析一次,所以主转录缓存和全屏覆盖层总是就有效宽度达成一致。
|
||||
- `hooks`(可选):生命周期 hooks 配置(见 `config.example.toml`)。
|
||||
|
||||
Reference in new issue
Block a user