Delete D3a: TUI dead code (#10, #21, header_items)

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:
CodeWhale Bot committed 2026-09-17 23:08:50 -07:00
1 parent 4a633a0fa9
commit 07a95e5306
14 files changed
+7 -988

No files matched your search

-78
View File
@@ -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 {
-51
View File
@@ -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");
-4
View File
@@ -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()
};
-25
View File
@@ -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>,
-5
View File
@@ -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,
+2
View File
@@ -137,6 +137,8 @@ mod tests {
("∿", "~"),
("⋯", "."),
("⏱", "@"),
("🐳", "w"),
("🐋", "w"),
] {
assert_eq!(ascii_fallback(rich), Some(safe));
}
+3
View File
@@ -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,
}
-23
View File
@@ -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;
+2 -659
View File
@@ -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
-121
View File
@@ -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"));
}
}
-2
View File
@@ -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 {
-1
View File
@@ -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.
-1
View File
@@ -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`)。