返回 CodeWhale
underwater.rs
根目录 / crates / tui / src / tui / underwater.rs
1 //! Coherent shell grammar for the underwater TUI.
2 //!
3 //! This module owns phase, responsive density, the empty-state composition,
4 //! and the compact header/footer fact budget. Product data still belongs to
5 //! [`App`]; this is only its terminal projection. Keeping these decisions in
6 //! one place prevents the default UI from drifting back into a header +
7 //! sidebar + dashboard + footer composition with four owners for one fact.
8
9 use std::borrow::Cow;
10
11 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
12 use ratatui::{
13 buffer::Buffer,
14 layout::Rect,
15 style::{Color, Modifier, Style},
16 text::{Line, Span},
17 widgets::{Block, Paragraph, Widget},
18 };
19 use unicode_width::UnicodeWidthStr;
20
21 use crate::config::HeaderItem;
22 use crate::localization::{Locale, MessageId, tr};
23 use crate::tui::{
24 app::{App, AppMode, OnboardingState},
25 approval::ApprovalMode,
26 footer_ui::format_token_count_compact,
27 views::ModalKind,
28 };
29
30 /// Responsive density tier. It changes how much truth is shown, never the
31 /// underlying state grammar.
32 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
33 pub enum ShellTier {
34 Compact,
35 Normal,
36 Wide,
37 }
38
39 const LAUNCH_ROWS: [(MessageId, &str); 5] = [
40 (MessageId::LaunchMenuNewSession, "Enter"),
41 (MessageId::LaunchMenuNewWorktree, "Ctrl+N"),
42 (MessageId::LaunchMenuResumeSession, "Ctrl+R"),
43 (MessageId::LaunchMenuChangelog, "Ctrl+L"),
44 (MessageId::LaunchMenuQuit, "Ctrl+Q"),
45 ];
46
47 #[derive(Debug, Clone, PartialEq, Eq)]
48 pub enum LaunchAction {
49 None,
50 NewSession,
51 CreateWorktree(String),
52 Resume,
53 Changelog,
54 Quit,
55 }
56
57 /// Translate launch-menu input into one product action. Direct reliable keys
58 /// and row navigation share this path, so the printed key column cannot drift
59 /// away from the handler.
60 pub fn handle_launch_key(
61 launch: &mut crate::tui::app::LaunchState,
62 key: KeyEvent,
63 locale: Locale,
64 ) -> LaunchAction {
65 if let Some(input) = launch.worktree_input.as_mut() {
66 return match key.code {
67 KeyCode::Esc => {
68 launch.worktree_input = None;
69 launch.status = None;
70 LaunchAction::None
71 }
72 KeyCode::Enter => {
73 let name = input.trim().to_string();
74 launch.worktree_input = None;
75 LaunchAction::CreateWorktree(name)
76 }
77 KeyCode::Backspace => {
78 input.pop();
79 LaunchAction::None
80 }
81 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
82 launch.worktree_input = None;
83 launch.status = None;
84 LaunchAction::None
85 }
86 KeyCode::Char(ch)
87 if !key.modifiers.intersects(
88 KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
89 ) =>
90 {
91 input.push(ch);
92 LaunchAction::None
93 }
94 _ => LaunchAction::None,
95 };
96 }
97
98 let direct = match key.code {
99 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(1),
100 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(2),
101 KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(3),
102 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(4),
103 _ => None,
104 };
105 if let Some(selected) = direct {
106 launch.selected = selected;
107 } else {
108 match key.code {
109 KeyCode::Up | KeyCode::Char('k') => {
110 launch.selected = launch.selected.saturating_sub(1);
111 return LaunchAction::None;
112 }
113 KeyCode::Down | KeyCode::Char('j') => {
114 launch.selected = (launch.selected + 1).min(LAUNCH_ROWS.len() - 1);
115 return LaunchAction::None;
116 }
117 KeyCode::Enter => {}
118 _ => return LaunchAction::None,
119 }
120 }
121
122 match launch.selected {
123 0 => LaunchAction::NewSession,
124 1 if launch.worktree_available => {
125 launch.worktree_input = Some(String::new());
126 launch.status = Some(tr(locale, MessageId::LaunchWorktreePrompt).into_owned());
127 LaunchAction::None
128 }
129 1 => {
130 launch.status = Some(tr(locale, MessageId::LaunchWorktreeNeedsGit).into_owned());
131 LaunchAction::None
132 }
133 2 => LaunchAction::Resume,
134 3 => LaunchAction::Changelog,
135 4 => LaunchAction::Quit,
136 _ => LaunchAction::None,
137 }
138 }
139
140 impl ShellTier {
141 #[must_use]
142 pub fn for_area(area: Rect) -> Self {
143 if area.width < 60 || area.height < 16 {
144 Self::Compact
145 } else if area.width < 110 || area.height < 30 {
146 Self::Normal
147 } else {
148 Self::Wide
149 }
150 }
151
152 #[must_use]
153 pub fn for_chrome_width(width: u16) -> Self {
154 if width < 60 {
155 Self::Compact
156 } else if width < 110 {
157 Self::Normal
158 } else {
159 Self::Wide
160 }
161 }
162 }
163
164 /// Perceptual session phase. Every treatment reads from this same enum so a
165 /// footer cannot say `idle` while the transcript is asking for approval.
166 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
167 pub enum ShellPhase {
168 Idle,
169 Typing,
170 Working,
171 /// A live verification pass (tests/checks/lints). Same clock family as
172 /// `Working` but rendered as the metered braille tick — checking, not
173 /// searching (ocean state model).
174 Verifying,
175 Waiting,
176 Approval,
177 Done,
178 Failed,
179 }
180
181 /// The one truthful verb shown while a turn is live. This deliberately stays
182 /// smaller than the tool taxonomy: the phase strip only needs to distinguish
183 /// hidden reasoning, read-shaped exploration, other tool use, verification,
184 /// and generic model work. It never exposes reasoning text or tool arguments.
185 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
186 pub(crate) enum LiveActivityKind {
187 Working,
188 Reasoning,
189 Reading,
190 UsingTool,
191 Verifying,
192 }
193
194 /// Bounded projection of live turn activity. Completed entries are ignored,
195 /// so an `ActiveCell` retained until `TurnComplete` cannot keep the shell in a
196 /// false working state.
197 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
198 pub(crate) struct LiveActivity {
199 kind: LiveActivityKind,
200 running_tools: usize,
201 }
202
203 impl LiveActivity {
204 #[must_use]
205 pub(crate) fn from_app(app: &App) -> Self {
206 let tools = running_tool_facts(app);
207 let kind = if tools.verifying {
208 LiveActivityKind::Verifying
209 } else if tools.count > 0 && tools.all_reading {
210 LiveActivityKind::Reading
211 } else if tools.count > 0 {
212 LiveActivityKind::UsingTool
213 } else if app.streaming_thinking_active_entry.is_some() {
214 LiveActivityKind::Reasoning
215 } else {
216 LiveActivityKind::Working
217 };
218 Self {
219 kind,
220 running_tools: tools.count,
221 }
222 }
223
224 #[must_use]
225 pub(crate) fn kind(self) -> LiveActivityKind {
226 self.kind
227 }
228
229 #[must_use]
230 pub(crate) fn running_tool_count(self) -> usize {
231 self.running_tools
232 }
233
234 #[must_use]
235 fn is_explicit(self) -> bool {
236 !matches!(self.kind, LiveActivityKind::Working)
237 }
238
239 #[must_use]
240 fn label(self, locale: Locale) -> Cow<'static, str> {
241 match self.kind {
242 LiveActivityKind::Working => tr(locale, MessageId::PhaseWorking),
243 LiveActivityKind::Reasoning => tr(locale, MessageId::PhaseReasoning),
244 LiveActivityKind::Reading => tr(locale, MessageId::PhaseReading),
245 LiveActivityKind::UsingTool => tr(locale, MessageId::PhaseUsingTool),
246 LiveActivityKind::Verifying => tr(locale, MessageId::PhaseVerifying),
247 }
248 }
249 }
250
251 #[derive(Debug, Clone, Copy)]
252 struct RunningToolFacts {
253 count: usize,
254 all_reading: bool,
255 verifying: bool,
256 }
257
258 impl Default for RunningToolFacts {
259 fn default() -> Self {
260 Self {
261 count: 0,
262 all_reading: true,
263 verifying: false,
264 }
265 }
266 }
267
268 impl RunningToolFacts {
269 fn observe(&mut self, reading: bool, verifying: bool) {
270 self.count = self.count.saturating_add(1);
271 self.all_reading &= reading;
272 self.verifying |= verifying;
273 }
274 }
275
276 const WORKING_BUBBLE_FRAMES: [&str; 8] = ["⠀", "⢀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣿"];
277 const COMPLETION_BREATH_MS: u128 = 800;
278 const COMPLETION_RELEASE_MS: u128 = 560;
279 const IDLE_WHALE_SPOUT_ROW: &str = " ˚";
280 const IDLE_WHALE_ROWS: [&str; 3] = [
281 " ▗▄▄▄▄▄▄▄▄▄▄▄▖ ▚▞",
282 " ▐██·███████████▙━━━━▞",
283 " ▝▀▀▀▀▀▀▀▀▀▀▀▀▘",
284 ];
285
286 const UWU_IDLE_WHALE_SPOUT_ROW: &str = " ˚✦";
287 const UWU_IDLE_WHALE_ROWS: [&str; 3] = [
288 " ▗▄▄▄▄▄▄▄▄▄▄▄▖ ▚▞",
289 "▐█░·░█████████▙▄▄▞",
290 " ▝▀▀▀▀▀▀▀▀▀▀▀▘",
291 ];
292
293 const IDLE_SHIMMER_CYCLE_MS: u128 = 4_000;
294 const IDLE_SHIMMER_SWEEP_FRACTION: f32 = 0.32;
295 const IDLE_SHIMMER_BAND_HALF_WIDTH: f32 = 0.38;
296 const IDLE_SHIMMER_STRENGTH: f32 = 0.33;
297
298 /// The build-version string the header renders. Since #5245 an unstamped
299 /// local build reports `0.9.4 (dev)` while CI/release carries a sha, so the
300 /// header's width choreography (which lengths of version stamp fit at which
301 /// terminal width) is environment-dependent. Tests that assert on those
302 /// width breakpoints override this to a fixed value so they measure the
303 /// layout, not the ambient build's sha length.
304 fn shell_build_version() -> Cow<'static, str> {
305 #[cfg(test)]
306 {
307 if let Some(version) = tests::build_version_override() {
308 return Cow::Owned(version);
309 }
310 }
311 Cow::Borrowed(env!("DEEPSEEK_BUILD_VERSION"))
312 }
313
314 impl ShellPhase {
315 #[must_use]
316 pub fn from_app(app: &App) -> Self {
317 Self::from_app_with_activity(app, LiveActivity::from_app(app))
318 }
319
320 #[must_use]
321 pub(crate) fn from_app_with_activity(app: &App, activity: LiveActivity) -> Self {
322 if matches!(
323 app.view_stack.top_kind(),
324 Some(ModalKind::Approval | ModalKind::Elevation | ModalKind::UserInput)
325 ) {
326 return Self::Approval;
327 }
328 if app.turn_error_posted
329 || matches!(app.runtime_turn_status.as_deref(), Some("failed" | "error"))
330 {
331 return Self::Failed;
332 }
333 if app.pending_user_input_prompt.is_some()
334 || app
335 .task_panel
336 .iter()
337 .any(|task| matches!(task.status.as_str(), "waiting" | "needs_user"))
338 {
339 return Self::Waiting;
340 }
341 if app.is_loading
342 || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
343 || activity.is_explicit()
344 {
345 if activity.kind() == LiveActivityKind::Verifying {
346 return Self::Verifying;
347 }
348 return Self::Working;
349 }
350 if !app.input.is_empty() {
351 return Self::Typing;
352 }
353 if matches!(app.runtime_turn_status.as_deref(), Some("completed")) {
354 return Self::Done;
355 }
356 Self::Idle
357 }
358
359 #[must_use]
360 pub fn label(self, locale: Locale) -> Cow<'static, str> {
361 match self {
362 Self::Idle => tr(locale, MessageId::PhaseIdle),
363 Self::Typing => tr(locale, MessageId::PhaseDraft),
364 Self::Working => tr(locale, MessageId::PhaseWorking),
365 Self::Verifying => tr(locale, MessageId::PhaseVerifying),
366 Self::Waiting | Self::Approval => tr(locale, MessageId::PhaseWaitingOnYou),
367 Self::Done => tr(locale, MessageId::PhaseDone),
368 Self::Failed => tr(locale, MessageId::PhaseFailed),
369 }
370 }
371
372 #[must_use]
373 pub fn color(self, app: &App) -> Color {
374 match self {
375 Self::Idle => app.ui_theme.text_muted,
376 Self::Done => app.ui_theme.success,
377 Self::Typing => app.ui_theme.accent_primary,
378 // Verifying shares the live seafoam hue; the tick-vs-bubble
379 // marker carries the checking/searching distinction.
380 Self::Working | Self::Verifying => app.ui_theme.status_working,
381 Self::Waiting | Self::Approval => app.ui_theme.accent_action,
382 Self::Failed => app.ui_theme.error_fg,
383 }
384 }
385 }
386
387 /// Summarize only tools whose lifecycle is actually `Running`. A read label
388 /// is earned only when every running entry is read/exploration-shaped; mixed
389 /// work stays the neutral `using tool`. Verification wins because it is the
390 /// existing stronger promise made by the phase strip.
391 fn running_tool_facts(app: &App) -> RunningToolFacts {
392 use crate::tui::history::{HistoryCell, ToolCell, ToolStatus};
393 use crate::tui::widgets::tool_card::{ToolFamily, tool_family_for_name};
394
395 let mut facts = RunningToolFacts::default();
396 let Some(active) = app.active_cell.as_ref() else {
397 return facts;
398 };
399 for cell in active.entries() {
400 let HistoryCell::Tool(tool) = cell else {
401 continue;
402 };
403 match tool {
404 ToolCell::Exec(exec) if exec.status == ToolStatus::Running => {
405 facts.observe(false, exec_is_verification(&exec.command));
406 }
407 ToolCell::Generic(generic) if generic.status == ToolStatus::Running => {
408 let family = tool_family_for_name(&generic.name);
409 facts.observe(
410 matches!(family, ToolFamily::Read | ToolFamily::Find),
411 family == ToolFamily::Verify || generic.name == "read_lints",
412 );
413 }
414 ToolCell::Exploring(exploring) => {
415 for entry in &exploring.entries {
416 if entry.status == ToolStatus::Running {
417 facts.observe(true, false);
418 }
419 }
420 }
421 ToolCell::WebSearch(search) if search.status == ToolStatus::Running => {
422 facts.observe(true, false);
423 }
424 other if other.status() == Some(ToolStatus::Running) => {
425 facts.observe(false, false);
426 }
427 _ => {}
428 }
429 }
430 facts
431 }
432
433 fn exec_is_verification(command: &str) -> bool {
434 let trimmed = command.trim_start();
435 let mut tokens = trimmed.split_whitespace();
436 let first = tokens.next().unwrap_or("");
437 let second = tokens.next().unwrap_or("");
438 match first {
439 "cargo" => matches!(second, "test" | "check" | "clippy" | "nextest"),
440 "go" => matches!(second, "test" | "vet"),
441 "npm" | "pnpm" | "yarn" | "bun" => matches!(second, "test" | "lint" | "check"),
442 "make" => matches!(second, "test" | "check" | "lint"),
443 "python" | "python3" => trimmed.contains("-m pytest") || trimmed.contains("-m unittest"),
444 "pytest" | "jest" | "vitest" | "tsc" | "eslint" | "ruff" | "mypy" | "clippy-driver"
445 | "golangci-lint" | "shellcheck" => true,
446 _ => false,
447 }
448 }
449
450 fn completion_elapsed_ms(app: &App) -> Option<u128> {
451 if !app.motion_policy().allows_decorative() {
452 return None;
453 }
454 app.ocean_completion_started_at
455 .map(|started| started.elapsed().as_millis())
456 .filter(|elapsed| *elapsed < COMPLETION_BREATH_MS)
457 }
458
459 #[cfg(test)]
460 pub(crate) fn phase_marker(app: &App, phase: ShellPhase) -> (&'static str, Cow<'static, str>) {
461 phase_marker_with_activity(app, phase, LiveActivity::from_app(app))
462 }
463
464 /// Truthful window-title activity verb for the OSC-0 whale animation.
465 ///
466 /// Uses short English fragments (with fixed-width ellipsis) so alt-tabbed
467 /// sessions stay legible without depending on the full localized phase strip.
468 #[must_use]
469 pub(crate) fn title_activity_verb(app: &App) -> &'static str {
470 let activity = LiveActivity::from_app(app);
471 let phase = ShellPhase::from_app_with_activity(app, activity);
472 match phase {
473 ShellPhase::Waiting | ShellPhase::Approval => "waiting on you…",
474 ShellPhase::Verifying => "verifying…",
475 ShellPhase::Done => "done",
476 ShellPhase::Failed => "failed",
477 ShellPhase::Typing => "drafting…",
478 ShellPhase::Idle => "idle",
479 ShellPhase::Working => match activity.kind() {
480 LiveActivityKind::Reasoning => "reasoning…",
481 LiveActivityKind::Reading => "reading…",
482 LiveActivityKind::UsingTool => "using tool…",
483 LiveActivityKind::Verifying => "verifying…",
484 LiveActivityKind::Working => "working…",
485 },
486 }
487 }
488
489 /// Push the current shell phase into the terminal title whale animation.
490 pub(crate) fn sync_title_activity(app: &App) {
491 crate::tui::notifications::set_title_motion_enabled(
492 app.motion_policy().allows_decorative() && app.status_indicator != "off",
493 );
494 if app.is_loading
495 || matches!(
496 ShellPhase::from_app(app),
497 ShellPhase::Working
498 | ShellPhase::Verifying
499 | ShellPhase::Waiting
500 | ShellPhase::Approval
501 | ShellPhase::Typing
502 )
503 {
504 crate::tui::notifications::set_title_activity_verb(title_activity_verb(app));
505 }
506 }
507
508 pub(crate) fn phase_marker_with_activity(
509 app: &App,
510 phase: ShellPhase,
511 activity: LiveActivity,
512 ) -> (&'static str, Cow<'static, str>) {
513 let locale = app.ui_locale;
514 match phase {
515 ShellPhase::Idle => ("·", phase.label(locale)),
516 ShellPhase::Typing => ("›", phase.label(locale)),
517 ShellPhase::Working => {
518 // The footer and the live tool card share one wall-clock cadence,
519 // so the two primary liveness marks never look like unrelated
520 // spinners. The shared helper also preserves the 400ms
521 // "motion is earned" delay and reduced/still fallback.
522 let policy = app.motion_policy();
523 let animated = crate::tui::spinner::braille_spinner_frame(app.turn_started_at, false);
524 let earned = app.turn_started_at.is_none_or(|started| {
525 started.elapsed().as_millis()
526 >= u128::from(crate::tui::spinner::LIVE_MARKER_DELAY_MS)
527 });
528 let frame = policy.spinner_glyph(animated, earned);
529 (frame, activity.label(locale))
530 }
531 ShellPhase::Verifying => {
532 // Metered braille tick on the shared live clock — checking, not
533 // searching. Reduced motion holds the legible mid frame.
534 let policy = app.motion_policy();
535 let animated = crate::tui::spinner::verification_tick_frame(app.turn_started_at, false);
536 let earned = app.turn_started_at.is_none_or(|started| {
537 started.elapsed().as_millis()
538 >= u128::from(crate::tui::spinner::LIVE_MARKER_DELAY_MS)
539 });
540 let frame = policy.spinner_glyph(animated, earned);
541 (frame, phase.label(locale))
542 }
543 ShellPhase::Waiting | ShellPhase::Approval => ("◆", phase.label(locale)),
544 ShellPhase::Done => match completion_elapsed_ms(app) {
545 Some(elapsed) if elapsed < COMPLETION_RELEASE_MS => {
546 let index = ((elapsed / 140) as usize + 4).min(WORKING_BUBBLE_FRAMES.len() - 1);
547 (
548 WORKING_BUBBLE_FRAMES[index],
549 tr(locale, MessageId::PhaseFinishing),
550 )
551 }
552 _ => (crate::tui::glyphs::DONE, phase.label(locale)),
553 },
554 ShellPhase::Failed => (crate::tui::glyphs::FAILED, phase.label(locale)),
555 }
556 }
557
558 fn mode_label(locale: Locale, mode: AppMode) -> Cow<'static, str> {
559 match mode {
560 AppMode::Agent | AppMode::Auto | AppMode::Yolo => tr(locale, MessageId::ChipModeAct),
561 AppMode::Plan => tr(locale, MessageId::ChipModePlan),
562 AppMode::Operate => tr(locale, MessageId::ChipModeOperate),
563 }
564 }
565
566 /// Permission chip words. This maps from the typed [`ApprovalMode`] state —
567 /// never from the English `permission_chip_label()` strings — so localizing
568 /// (or rewording) the upstream chip labels can never silently break the chip.
569 fn permission_label(app: &App) -> Cow<'static, str> {
570 let locale = app.ui_locale;
571 if app.mode == AppMode::Plan {
572 return tr(locale, MessageId::ChipPermissionReadOnly);
573 }
574 let approval = match app.approval_mode {
575 ApprovalMode::Suggest => tr(locale, MessageId::ChipPermissionAsk),
576 ApprovalMode::Auto => tr(locale, MessageId::ChipPermissionAuto),
577 // Keep the effective permission explicit. `bypass` is an
578 // implementation detail and, more importantly, can imply that
579 // repository law no longer applies. Full Access never bypasses
580 // constitution rules. This is **tool-approval posture**, not
581 // filesystem scope — see filesystem_scope_label.
582 ApprovalMode::Bypass => tr(locale, MessageId::ChipPermissionFullAccess),
583 ApprovalMode::Never => tr(locale, MessageId::ChipPermissionNever),
584 };
585 // Append filesystem scope so "Full Access" (approval) is never confused
586 // with unrestricted disk writes.
587 let fs = filesystem_scope_label(app);
588 Cow::Owned(format!("{approval} · {fs}"))
589 }
590
591 /// Always-legible effective filesystem scope for the shell chrome.
592 #[must_use]
593 fn filesystem_scope_label(app: &App) -> Cow<'static, str> {
594 // Spelled out because the old `fs:` prefix read as an unexplained
595 // acronym (user report, 2026-07-23): this chip states which files the
596 // session may write.
597 let policy = crate::core::authority::sandbox_policy_for_turn(
598 app.mode,
599 app.approval_mode,
600 app.configured_sandbox_mode.as_deref(),
601 &app.workspace,
602 );
603 // A policy is an intent; enforcement needs a backend. On default Linux
604 // (bubblewrap is opt-in) and on all Windows there is none, and this chip
605 // used to say "files: workspace" while nothing restricted anything
606 // (2026-08-04 audit). Say "unenforced" rather than name a boundary that
607 // is not applied. `DangerFullAccess` is already honest, and
608 // `ExternalSandbox` is enforced by the external runner, not by us.
609 let unenforced = app.sandbox_backend.is_none()
610 && !matches!(
611 policy,
612 crate::sandbox::SandboxPolicy::DangerFullAccess
613 | crate::sandbox::SandboxPolicy::ExternalSandbox { .. }
614 );
615 match policy {
616 crate::sandbox::SandboxPolicy::ReadOnly if unenforced => {
617 Cow::Borrowed("files: read-only (unenforced)")
618 }
619 crate::sandbox::SandboxPolicy::ReadOnly => Cow::Borrowed("files: read-only"),
620 crate::sandbox::SandboxPolicy::DangerFullAccess => Cow::Borrowed("files: full disk"),
621 crate::sandbox::SandboxPolicy::ExternalSandbox { .. } => {
622 Cow::Borrowed("files: external sandbox")
623 }
624 crate::sandbox::SandboxPolicy::WorkspaceWrite { .. } if unenforced => {
625 Cow::Borrowed("files: workspace (unenforced)")
626 }
627 crate::sandbox::SandboxPolicy::WorkspaceWrite { .. } => Cow::Borrowed("files: workspace"),
628 }
629 }
630
631 fn span_width(spans: &[Span<'_>]) -> usize {
632 spans.iter().map(|span| span.content.width()).sum()
633 }
634
635 fn truncate_to_width(text: &str, width: usize) -> String {
636 if text.width() <= width {
637 return text.to_string();
638 }
639 if width == 0 {
640 return String::new();
641 }
642 if width <= 3 {
643 return ".".repeat(width);
644 }
645 let mut result = String::new();
646 let mut used = 0;
647 for ch in text.chars() {
648 let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
649 if used + ch_width + 1 > width {
650 break;
651 }
652 result.push(ch);
653 used += ch_width;
654 }
655 result.push('…');
656 result
657 }
658
659 fn render_launch_line(area: Rect, buf: &mut Buffer, y: u16, spans: Vec<Span<'static>>) {
660 if y >= area.height {
661 return;
662 }
663 Paragraph::new(Line::from(spans)).render(
664 Rect {
665 x: area.x,
666 y: area.y.saturating_add(y),
667 width: area.width,
668 height: 1,
669 },
670 buf,
671 );
672 }
673
674 /// Render the distinct pre-session choice state. This screen contains no
675 /// transcript, composer, dashboard, or post-launch whale: each row dispatches
676 /// to real session/worktree machinery before the idle ocean is entered.
677 pub fn render_launch_screen(area: Rect, buf: &mut Buffer, app: &App) {
678 if area.width == 0 || area.height == 0 {
679 return;
680 }
681 Block::default()
682 .style(Style::default().bg(app.ui_theme.surface_bg))
683 .render(area, buf);
684 let width = usize::from(area.width);
685 let version = format!("v{}", shell_build_version());
686 let workspace_budget = width.saturating_sub(version.width() + 6);
687 let workspace = truncate_to_width(
688 &crate::utils::display_path(&app.workspace),
689 workspace_budget,
690 );
691 let mut header = vec![
692 Span::styled(
693 "cw",
694 Style::default()
695 .fg(app.ui_theme.accent_primary)
696 .add_modifier(Modifier::BOLD),
697 ),
698 Span::raw(" "),
699 Span::styled(workspace, Style::default().fg(app.ui_theme.text_muted)),
700 ];
701 let gap = width.saturating_sub(span_width(&header) + version.width());
702 header.push(Span::raw(" ".repeat(gap)));
703 header.push(Span::styled(
704 version,
705 Style::default().fg(app.ui_theme.text_hint),
706 ));
707 render_launch_line(area, buf, 0, header);
708 if area.height > 1 {
709 render_launch_line(
710 area,
711 buf,
712 1,
713 vec![Span::styled(
714 "─".repeat(width),
715 Style::default().fg(app.ui_theme.border),
716 )],
717 );
718 }
719
720 let rows_start = if area.height >= 16 { 4 } else { 3 };
721 for (index, (label_id, key)) in LAUNCH_ROWS.iter().enumerate() {
722 let y = rows_start + u16::try_from(index).unwrap_or(0);
723 if y >= area.height.saturating_sub(3) {
724 break;
725 }
726 let selected = app.launch.selected == index;
727 let mut label = tr(app.ui_locale, *label_id).into_owned();
728 if index == 1 && !app.launch.worktree_available {
729 label.push_str(&format!(
730 " · {}",
731 tr(app.ui_locale, MessageId::LaunchMenuUnavailable)
732 ));
733 }
734 if index == 2 {
735 label.push_str(&format!(
736 " · {}",
737 tr(app.ui_locale, MessageId::LaunchMenuSavedCount)
738 .replace("{count}", &app.launch.workspace_session_count.to_string())
739 ));
740 }
741 let prefix = if selected { " ▸ " } else { " " };
742 let key_width = key.width();
743 let label_budget = width.saturating_sub(prefix.width() + key_width + 2);
744 let label = truncate_to_width(&label, label_budget);
745 let fill = width.saturating_sub(prefix.width() + label.width() + key_width);
746 let row_style = if selected {
747 Style::default()
748 .fg(app.ui_theme.accent_primary)
749 .add_modifier(Modifier::BOLD)
750 } else if index == 1 && !app.launch.worktree_available {
751 Style::default().fg(app.ui_theme.text_dim)
752 } else {
753 Style::default().fg(app.ui_theme.text_body)
754 };
755 render_launch_line(
756 area,
757 buf,
758 y,
759 vec![
760 Span::styled(prefix, row_style),
761 Span::styled(label, row_style),
762 Span::raw(" ".repeat(fill)),
763 Span::styled(*key, Style::default().fg(app.ui_theme.text_hint)),
764 ],
765 );
766 }
767
768 if area.height < 3 {
769 return;
770 }
771 let rule_y = area.height.saturating_sub(3);
772 render_launch_line(
773 area,
774 buf,
775 rule_y,
776 vec![Span::styled(
777 "─".repeat(width),
778 Style::default().fg(app.ui_theme.border),
779 )],
780 );
781 let prompt = if let Some(input) = app.launch.worktree_input.as_deref() {
782 format!(
783 "{} {}{}",
784 tr(app.ui_locale, MessageId::LaunchWorktreeNameLabel),
785 input,
786 if app.low_motion { "_" } else { "▌" }
787 )
788 } else if let Some(status) = app.launch.status.as_deref() {
789 status.to_string()
790 } else if area.width < 60 {
791 format!(
792 "j/k:{} · Enter:{}",
793 tr(app.ui_locale, MessageId::LaunchHintMove),
794 tr(app.ui_locale, MessageId::LaunchHintOpen)
795 )
796 } else {
797 tr(app.ui_locale, MessageId::LaunchTipFlags).into_owned()
798 };
799 render_launch_line(
800 area,
801 buf,
802 area.height.saturating_sub(2),
803 vec![Span::styled(
804 truncate_to_width(&prompt, width),
805 Style::default().fg(if app.launch.status.is_some() {
806 app.ui_theme.text_muted
807 } else {
808 app.ui_theme.text_hint
809 }),
810 )],
811 );
812
813 let saved_sessions = if app.launch.workspace_session_count == 1 {
814 tr(app.ui_locale, MessageId::LaunchSavedSessionSingular).into_owned()
815 } else {
816 tr(app.ui_locale, MessageId::LaunchSavedSessionsPlural)
817 .replace("{count}", &app.launch.workspace_session_count.to_string())
818 };
819 let status = format!(
820 "{} · {} · {}",
821 app.model_display_label(),
822 mode_label(app.ui_locale, app.mode),
823 saved_sessions
824 );
825 render_launch_line(
826 area,
827 buf,
828 area.height.saturating_sub(1),
829 vec![Span::styled(
830 truncate_to_width(&status, width),
831 Style::default().fg(app.ui_theme.text_dim),
832 )],
833 );
834 }
835
836 /// Record the launch row rects immediately after the launch frame is painted.
837 /// The coordinates mirror the renderer's responsive row placement exactly.
838 pub fn record_launch_row_areas(area: Rect, launch: &mut crate::tui::app::LaunchState) {
839 launch.row_areas.clear();
840 let rows_start = if area.height >= 16 { 4 } else { 3 };
841 for index in 0..LAUNCH_ROWS.len() {
842 let y = rows_start + u16::try_from(index).unwrap_or(0);
843 if y >= area.height.saturating_sub(3) {
844 break;
845 }
846 launch.row_areas.push(Rect {
847 x: area.x,
848 y: area.y.saturating_add(y),
849 width: area.width,
850 height: 1,
851 });
852 }
853 }
854
855 fn compact_tokens(tokens: i64) -> String {
856 if tokens >= 1_000_000 {
857 format!("{:.1}M", tokens as f64 / 1_000_000.0)
858 } else if tokens >= 1_000 {
859 format!("{:.0}K", tokens as f64 / 1_000.0)
860 } else {
861 tokens.to_string()
862 }
863 }
864
865 fn compact_effort_label(label: &str) -> &'static str {
866 let effective = label
867 .rsplit_once('→')
868 .map_or(label, |(_, effective)| effective);
869 let effective = effective
870 .rsplit_once(':')
871 .map_or(effective, |(_, effective)| effective)
872 .trim()
873 .to_ascii_lowercase();
874 match effective.as_str() {
875 "off" => "o",
876 "low" => "l",
877 "med" | "medium" => "m",
878 "high" => "h",
879 "max" | "maximum" | "xhigh" => "x",
880 "auto" => "a",
881 _ => "·",
882 }
883 }
884
885 fn session_token_breakdown(app: &App) -> Option<Span<'static>> {
886 app.header_items.contains(&HeaderItem::Tokens).then(|| {
887 Span::styled(
888 format!(
889 "{} in · {} cch · {} out",
890 format_token_count_compact(u64::from(app.session.total_input_tokens)),
891 format_token_count_compact(u64::from(app.session.total_cache_hit_tokens)),
892 format_token_count_compact(u64::from(app.session.total_output_tokens)),
893 ),
894 Style::default().fg(app.ui_theme.info),
895 )
896 })
897 }
898
899 /// Append one right-hand chrome element, inserting the two-space separator
900 /// only between elements so an absent element never leaves trailing padding.
901 fn push_chrome(spans: &mut Vec<Span<'static>>, span: Span<'static>) {
902 if !spans.is_empty() {
903 spans.push(Span::raw(" "));
904 }
905 spans.push(span);
906 }
907
908 /// Render the one-line shell header. Route, mode, requested/effective effort,
909 /// permission, active-agent count, and context each have exactly one owner.
910 pub fn render_header(area: Rect, buf: &mut Buffer, app: &App) {
911 if area.width == 0 || area.height == 0 {
912 return;
913 }
914 let tier = ShellTier::for_chrome_width(area.width);
915 Block::default()
916 .style(Style::default().bg(app.ui_theme.header_bg))
917 .render(area, buf);
918
919 let (effective_provider, effective_model) = app.effective_route_identity_display();
920 let route_label = format!("{effective_provider} · {effective_model}");
921 let effort_label = app.reasoning_effort_display_label();
922 let mode_color = match app.mode {
923 AppMode::Plan => app.ui_theme.mode_plan,
924 AppMode::Operate => app.ui_theme.mode_operate,
925 _ => app.ui_theme.mode_agent,
926 };
927 // Match the composer's warm top edge exactly: Ask amber, Auto-Review
928 // Signal Gold, and Full Access coral.
929 let permission_color = match app.approval_mode {
930 ApprovalMode::Suggest | ApprovalMode::Never => app.ui_theme.permission_ask,
931 ApprovalMode::Auto => app.ui_theme.permission_auto_review,
932 ApprovalMode::Bypass => app.ui_theme.permission_full_access,
933 };
934 let status_indicator = crate::tui::widgets::header_status_indicator_frame(
935 (!app.low_motion && app.fancy_animations)
936 .then_some(app.turn_started_at)
937 .flatten(),
938 &app.status_indicator,
939 )
940 .filter(|indicator| *indicator != "cw");
941 let mut left = vec![
942 Span::styled(
943 "cw",
944 Style::default()
945 .fg(app.ui_theme.accent_primary)
946 .add_modifier(Modifier::BOLD),
947 ),
948 Span::raw(" "),
949 Span::styled(
950 route_label.clone(),
951 Style::default().fg(app.ui_theme.text_muted),
952 ),
953 Span::styled(" · ", Style::default().fg(app.ui_theme.text_dim)),
954 Span::styled(
955 mode_label(app.ui_locale, app.mode),
956 Style::default().fg(mode_color),
957 ),
958 Span::styled(" · ", Style::default().fg(app.ui_theme.text_dim)),
959 Span::styled(effort_label.clone(), Style::default().fg(app.ui_theme.info)),
960 ];
961 // The selected brand/status mark is part of the user's chosen header,
962 // not expendable wide-screen decoration. Keep it in compact layouts too;
963 // route text truncates before the permission posture or selected mark.
964 if let Some(indicator) = status_indicator {
965 left.push(Span::raw(" "));
966 left.push(Span::styled(
967 indicator,
968 Style::default()
969 .fg(app.ui_theme.info)
970 .add_modifier(Modifier::BOLD),
971 ));
972 }
973 // Permission is safety state, not optional chrome. Compact terminals shed
974 // route detail and the context meter, but keep mode, effective effort, and
975 // the effective posture.
976 left.push(Span::styled(
977 " · ",
978 Style::default().fg(app.ui_theme.text_dim),
979 ));
980 left.push(Span::styled(
981 permission_label(app),
982 Style::default().fg(permission_color),
983 ));
984 // Active-goal chip (#39): the ocean shell has no sidebar, so the topbar
985 // is the only always-on surface where a goal set via `create_goal` can
986 // live. Objective truncated to a fixed budget; terminal goals render
987 // nothing. The cramped-layout rebuild below keeps the chip in `suffix`.
988 let goal_chip =
989 crate::tui::footer_ui::active_goal_chip_state(app).map(|(objective, paused)| {
990 let budget = if paused { 22 } else { 26 };
991 let flat = objective.trim().replace(['\n', '\r'], " ");
992 let text = if paused {
993 format!("goal paused {}", truncate_to_width(&flat, budget))
994 } else {
995 format!("goal {}", truncate_to_width(&flat, budget))
996 };
997 let color = if paused {
998 app.ui_theme.warning
999 } else {
1000 app.ui_theme.status_working
1001 };
1002 (text, color)
1003 });
1004 if let Some((text, color)) = &goal_chip {
1005 left.push(Span::styled(
1006 " · ",
1007 Style::default().fg(app.ui_theme.text_dim),
1008 ));
1009 left.push(Span::styled(
1010 text.clone(),
1011 Style::default().fg(*color).add_modifier(Modifier::BOLD),
1012 ));
1013 }
1014 // Workflow-run chip (#5040): the same `WorkflowPanel::top_bar_chip` the
1015 // classic header shows, so a collapsed run stays visible on the ocean
1016 // shell too. No workflow panel means no chip. The cramped-layout rebuild
1017 // below keeps the chip in `suffix` alongside the goal chip.
1018 let workflow_chip = app
1019 .workflow_panel
1020 .as_ref()
1021 .map(|panel| (panel.top_bar_chip(), app.ui_theme.info));
1022 if let Some((text, color)) = &workflow_chip {
1023 left.push(Span::styled(
1024 " · ",
1025 Style::default().fg(app.ui_theme.text_dim),
1026 ));
1027 left.push(Span::styled(
1028 text.clone(),
1029 Style::default().fg(*color).add_modifier(Modifier::BOLD),
1030 ));
1031 }
1032 // Update-available chip (#14): a quiet, persistent affordance set once by
1033 // the startup version check. Gets the workflow chip's treatment: last in
1034 // the left cluster, the route label yields its budget first, and the chip
1035 // drops cleanly when even a minimal chip cannot fit — never a modal,
1036 // never mid-chip clipping.
1037 let update_chip = app
1038 .update_available
1039 .as_ref()
1040 .map(|label| (label.clone(), app.ui_theme.warning));
1041 if let Some((text, color)) = &update_chip {
1042 left.push(Span::styled(
1043 " · ",
1044 Style::default().fg(app.ui_theme.text_dim),
1045 ));
1046 left.push(Span::styled(
1047 text.clone(),
1048 Style::default().fg(*color).add_modifier(Modifier::BOLD),
1049 ));
1050 }
1051
1052 let context_meter = (tier != ShellTier::Compact)
1053 .then(|| crate::tui::ui::context_usage_snapshot(app))
1054 .flatten()
1055 .map(|(used, max, percent)| {
1056 let filled = ((percent / 100.0) * 5.0).ceil().clamp(0.0, 5.0) as usize;
1057 Span::styled(
1058 format!(
1059 "{}/{} [{}{}] {:.0}%",
1060 compact_tokens(used),
1061 compact_tokens(i64::from(max)),
1062 "▰".repeat(filled),
1063 "▱".repeat(5usize.saturating_sub(filled)),
1064 percent
1065 ),
1066 Style::default().fg(app.ui_theme.info),
1067 )
1068 });
1069 let token_breakdown = (tier != ShellTier::Compact)
1070 .then(|| session_token_breakdown(app))
1071 .flatten();
1072 let token_breakdown_requested = token_breakdown.is_some();
1073 let version = (tier == ShellTier::Wide).then(|| {
1074 Span::styled(
1075 format!("v{}", shell_build_version()),
1076 Style::default().fg(app.ui_theme.text_hint),
1077 )
1078 });
1079 // Cached git branch/status only — never probe from the render path.
1080 // Background refresh is scheduled from the event loop / idle ticks.
1081 let git_label = crate::tui::git_status::chrome_label(&crate::tui::git_status::cached_status())
1082 .map(|label| Span::styled(label, Style::default().fg(app.ui_theme.text_muted)));
1083
1084 // Baseline right-hand chrome: git, context meter, version. Exact route
1085 // identity outranks this auxiliary chrome when the full line cannot fit.
1086 let mut right = Vec::new();
1087 if let Some(git_label) = git_label.clone() {
1088 push_chrome(&mut right, git_label);
1089 }
1090 if let Some(context_meter) = context_meter.clone() {
1091 push_chrome(&mut right, context_meter);
1092 }
1093 if let Some(version) = version.clone() {
1094 push_chrome(&mut right, version);
1095 }
1096
1097 let minimum_effort = if tier == ShellTier::Compact {
1098 compact_effort_label(&effort_label).to_string()
1099 } else {
1100 effort_label.clone()
1101 };
1102 let indicator_width = status_indicator.map_or(0, |indicator| 1 + indicator.width());
1103 let minimum_left_width = 4usize
1104 .saturating_add(indicator_width)
1105 .saturating_add(3 + mode_label(app.ui_locale, app.mode).width())
1106 .saturating_add(3 + minimum_effort.width())
1107 .saturating_add(3 + permission_label(app).width());
1108 let available = usize::from(area.width);
1109 // The optional token breakdown is the only elidable element: it is added
1110 // between the git label and the context meter when the terminal is wide
1111 // enough to keep the whole baseline plus the guaranteed-left minimum.
1112 if let Some(token_breakdown) = token_breakdown {
1113 let mut enhanced_right = Vec::new();
1114 if let Some(git_label) = git_label.clone() {
1115 push_chrome(&mut enhanced_right, git_label);
1116 }
1117 push_chrome(&mut enhanced_right, token_breakdown);
1118 if let Some(context_meter) = context_meter.clone() {
1119 push_chrome(&mut enhanced_right, context_meter);
1120 }
1121 if let Some(version) = version.clone() {
1122 push_chrome(&mut enhanced_right, version);
1123 }
1124 let enhanced_width = span_width(&enhanced_right);
1125 let gap = usize::from(enhanced_width > 0);
1126 if minimum_left_width
1127 .saturating_add(gap)
1128 .saturating_add(enhanced_width)
1129 <= available
1130 {
1131 right = enhanced_right;
1132 }
1133 }
1134
1135 // Provider + model are routing truth. Shed auxiliary right-hand chrome in
1136 // least-important-first order before shortening that identity on a normal
1137 // 100+ column shell. Narrow shells keep the context meter, and an explicit
1138 // token-breakdown opt-in keeps its documented width priority.
1139 let full_left_width = span_width(&left);
1140 let route_identity_priority = available >= 100
1141 && !token_breakdown_requested
1142 && app.api_provider == crate::config::ApiProvider::Custom;
1143 if route_identity_priority
1144 && full_left_width
1145 .saturating_add(usize::from(!right.is_empty()))
1146 .saturating_add(span_width(&right))
1147 > available
1148 {
1149 right.clear();
1150 if let Some(context_meter) = context_meter.clone() {
1151 push_chrome(&mut right, context_meter);
1152 }
1153 if let Some(version) = version.clone() {
1154 push_chrome(&mut right, version);
1155 }
1156 }
1157 if route_identity_priority
1158 && full_left_width
1159 .saturating_add(usize::from(!right.is_empty()))
1160 .saturating_add(span_width(&right))
1161 > available
1162 {
1163 right.clear();
1164 if let Some(context_meter) = context_meter {
1165 push_chrome(&mut right, context_meter);
1166 }
1167 }
1168 if route_identity_priority
1169 && full_left_width
1170 .saturating_add(usize::from(!right.is_empty()))
1171 .saturating_add(span_width(&right))
1172 > available
1173 {
1174 right.clear();
1175 }
1176
1177 let right_width = span_width(&right);
1178 let left_budget = available.saturating_sub(right_width + usize::from(right_width > 0));
1179 if span_width(&left) > left_budget {
1180 let mode = mode_label(app.ui_locale, app.mode);
1181 let permission = permission_label(app);
1182 let effort = if tier == ShellTier::Compact {
1183 compact_effort_label(&effort_label).to_string()
1184 } else {
1185 effort_label.clone()
1186 };
1187 let mut suffix = vec![
1188 Span::styled(" · ", Style::default().fg(app.ui_theme.text_dim)),
1189 Span::styled(mode, Style::default().fg(mode_color)),
1190 Span::styled(" · ", Style::default().fg(app.ui_theme.text_dim)),
1191 Span::styled(effort, Style::default().fg(app.ui_theme.info)),
1192 Span::styled(" · ", Style::default().fg(app.ui_theme.text_dim)),
1193 Span::styled(permission, Style::default().fg(permission_color)),
1194 ];
1195 // The goal chip survives cramped layouts too — it is operator state,
1196 // not decoration. The route label yields its budget first (down to
1197 // nothing, as it always has); below that the goal itself truncates,
1198 // and when even a minimal chip cannot fit it drops rather than
1199 // clipping mid-word (#39).
1200 let indicator_width = status_indicator.map_or(0, |indicator| 1 + indicator.width());
1201 let base_fixed = 4usize
1202 .saturating_add(indicator_width)
1203 .saturating_add(span_width(&suffix));
1204 if let Some((text, color)) = &goal_chip {
1205 let goal_room = left_budget.saturating_sub(base_fixed).saturating_sub(3);
1206 if goal_room >= 8 {
1207 suffix.push(Span::styled(
1208 " · ",
1209 Style::default().fg(app.ui_theme.text_dim),
1210 ));
1211 suffix.push(Span::styled(
1212 truncate_to_width(text, goal_room),
1213 Style::default().fg(*color).add_modifier(Modifier::BOLD),
1214 ));
1215 }
1216 }
1217 // The workflow chip (#5040) is operator state too, so it gets the
1218 // goal chip's treatment: whatever room remains after the chips ahead
1219 // of it, clean truncation, and a clean drop when even a minimal chip
1220 // cannot fit. The route label still yields its budget first.
1221 if let Some((text, color)) = &workflow_chip {
1222 let workflow_room = left_budget
1223 .saturating_sub(
1224 4usize
1225 .saturating_add(indicator_width)
1226 .saturating_add(span_width(&suffix)),
1227 )
1228 .saturating_sub(3);
1229 if workflow_room >= 8 {
1230 suffix.push(Span::styled(
1231 " · ",
1232 Style::default().fg(app.ui_theme.text_dim),
1233 ));
1234 suffix.push(Span::styled(
1235 truncate_to_width(text, workflow_room),
1236 Style::default().fg(*color).add_modifier(Modifier::BOLD),
1237 ));
1238 }
1239 }
1240 // The update chip (#14) gets the same treatment, last in line: it is
1241 // useful, but it yields to every piece of operator state ahead of it.
1242 if let Some((text, color)) = &update_chip {
1243 let update_room = left_budget
1244 .saturating_sub(
1245 4usize
1246 .saturating_add(indicator_width)
1247 .saturating_add(span_width(&suffix)),
1248 )
1249 .saturating_sub(3);
1250 if update_room >= 8 {
1251 suffix.push(Span::styled(
1252 " · ",
1253 Style::default().fg(app.ui_theme.text_dim),
1254 ));
1255 suffix.push(Span::styled(
1256 truncate_to_width(text, update_room),
1257 Style::default().fg(*color).add_modifier(Modifier::BOLD),
1258 ));
1259 }
1260 }
1261 let fixed_width = 4usize
1262 .saturating_add(indicator_width)
1263 .saturating_add(span_width(&suffix));
1264 let route_budget = left_budget.saturating_sub(fixed_width);
1265 left = vec![Span::styled(
1266 "cw",
1267 Style::default()
1268 .fg(app.ui_theme.accent_primary)
1269 .add_modifier(Modifier::BOLD),
1270 )];
1271 if let Some(indicator) = status_indicator {
1272 left.push(Span::raw(" "));
1273 left.push(Span::styled(
1274 indicator,
1275 Style::default()
1276 .fg(app.ui_theme.info)
1277 .add_modifier(Modifier::BOLD),
1278 ));
1279 }
1280 left.push(Span::raw(" "));
1281 left.push(Span::styled(
1282 truncate_to_width(&route_label, route_budget),
1283 Style::default().fg(app.ui_theme.text_muted),
1284 ));
1285 left.extend(suffix);
1286 }
1287 let left_width = span_width(&left);
1288 let gap = available.saturating_sub(left_width + right_width);
1289 left.push(Span::raw(" ".repeat(gap)));
1290 left.extend(right);
1291 let title_area = Rect { height: 1, ..area };
1292 Paragraph::new(Line::from(left)).render(title_area, buf);
1293 if area.height > 1 {
1294 let rule_area = Rect {
1295 y: area.y.saturating_add(1),
1296 height: 1,
1297 ..area
1298 };
1299 Paragraph::new(Line::from(Span::styled(
1300 "─".repeat(usize::from(area.width)),
1301 Style::default().fg(app.ui_theme.border),
1302 )))
1303 .render(rule_area, buf);
1304 }
1305 }
1306
1307 /// Render the fixed one-line phase band.
1308 ///
1309 /// Ocean placement (above vs below the composer) is owned by
1310 /// [`crate::tui::phase_strip`]; this entry point only paints the band so
1311 /// classic callers and tests keep a stable name.
1312 pub fn render_footer(area: Rect, buf: &mut Buffer, app: &mut App) {
1313 crate::tui::phase_strip::render(area, buf, app);
1314 }
1315
1316 /// The transcript rows the idle brand mark needs before it will draw at all.
1317 ///
1318 /// This is [`ShellTier::for_area`]'s `Compact` floor, named so the *layout*
1319 /// can honour it before the frame is split. Anything that reserves rows above
1320 /// the transcript must subtract against this constant rather than guess, or
1321 /// the reservation and the render gate drift and the mark is evicted by
1322 /// chrome that was sized without knowing the mark existed.
1323 pub(crate) const AMBIENT_MIN_CHAT_HEIGHT: u16 = 16;
1324 /// Companion column floor, same reasoning as [`AMBIENT_MIN_CHAT_HEIGHT`].
1325 pub(crate) const AMBIENT_MIN_CHAT_WIDTH: u16 = 60;
1326
1327 /// Build the post-launch idle composition. It is deliberately not a command
1328 /// dashboard: one brand mark, one context line, and one quiet Fleet setup path.
1329 ///
1330 /// Expressed in terms of the ambient floor constants so the layout rule that
1331 /// reserves the rows and the gate that spends them cannot disagree. (The old
1332 /// spelling also tested `height >= 14 && width >= 28`, which was dead: the
1333 /// tier check already demands 16 rows and 60 columns.)
1334 #[must_use]
1335 pub(crate) fn empty_state_mark_visible(area: Rect) -> bool {
1336 area.height >= AMBIENT_MIN_CHAT_HEIGHT && area.width >= AMBIENT_MIN_CHAT_WIDTH
1337 }
1338
1339 #[must_use]
1340 pub(crate) fn decorative_shell_motion_enabled(app: &App) -> bool {
1341 app.motion_policy().allows_decorative()
1342 && !app.attention_hold_active()
1343 && app.onboarding == OnboardingState::None
1344 && !app.launch.visible
1345 && app.view_stack.is_empty()
1346 }
1347
1348 #[must_use]
1349 fn idle_mark_animation_enabled(app: &App) -> bool {
1350 decorative_shell_motion_enabled(app) && matches!(ShellPhase::from_app(app), ShellPhase::Idle)
1351 }
1352
1353 /// Raised-cosine caustic band for the idle whale. The 4s cycle spends roughly
1354 /// 1.3s crossing the mark and parks off-screen for the remainder, so the brand
1355 /// has a clear moment of life without becoming looping chrome.
1356 #[must_use]
1357 fn idle_mark_shine_opacity(diagonal: f32, elapsed_ms: u128) -> f32 {
1358 let cycle_progress = (elapsed_ms % IDLE_SHIMMER_CYCLE_MS) as f32 / IDLE_SHIMMER_CYCLE_MS as f32;
1359 let sweep_progress = (cycle_progress / IDLE_SHIMMER_SWEEP_FRACTION).min(1.0);
1360 let band_position =
1361 -IDLE_SHIMMER_BAND_HALF_WIDTH + sweep_progress * (1.0 + 2.0 * IDLE_SHIMMER_BAND_HALF_WIDTH);
1362 let distance = (diagonal - band_position).abs();
1363 if distance >= IDLE_SHIMMER_BAND_HALF_WIDTH {
1364 return 0.0;
1365 }
1366 let raised_cosine =
1367 0.5 * (1.0 + (std::f32::consts::PI * distance / IDLE_SHIMMER_BAND_HALF_WIDTH).cos());
1368 IDLE_SHIMMER_STRENGTH * raised_cosine
1369 }
1370
1371 #[must_use]
1372 fn idle_mark_color(base: Color, highlight: Color, opacity: f32) -> Color {
1373 if opacity <= 0.0 {
1374 return base;
1375 }
1376 match (base, highlight) {
1377 (Color::Rgb(..), Color::Rgb(..)) => crate::palette::blend(highlight, base, opacity),
1378 // Named/terminal-owned colors cannot be blended truthfully. Hold the
1379 // stable brand color instead of flashing the entire mark at full ink.
1380 _ => base,
1381 }
1382 }
1383
1384 fn idle_whale_is_uwu(app: &App) -> bool {
1385 app.ui_theme.name == "uwu"
1386 }
1387
1388 fn idle_whale_spout_row(app: &App) -> &'static str {
1389 if idle_whale_is_uwu(app) {
1390 UWU_IDLE_WHALE_SPOUT_ROW
1391 } else {
1392 IDLE_WHALE_SPOUT_ROW
1393 }
1394 }
1395
1396 fn idle_whale_rows(app: &App) -> [&'static str; 3] {
1397 if idle_whale_is_uwu(app) {
1398 UWU_IDLE_WHALE_ROWS
1399 } else {
1400 IDLE_WHALE_ROWS
1401 }
1402 }
1403
1404 fn idle_whale_row_spans(
1405 text: &'static str,
1406 row: usize,
1407 elapsed_ms: u128,
1408 animated: bool,
1409 base: Color,
1410 highlight: Color,
1411 eye: Color,
1412 ) -> Vec<Span<'static>> {
1413 let rows = IDLE_WHALE_ROWS.len() as f32;
1414 let cols = IDLE_WHALE_ROWS
1415 .iter()
1416 .map(|line| line.chars().count())
1417 .max()
1418 .unwrap_or(1) as f32;
1419 let mut spans = Vec::new();
1420 let mut run = String::new();
1421 let mut run_color = None;
1422
1423 for (column, ch) in text.chars().enumerate() {
1424 let diagonal = (column as f32 + (rows - 1.0 - row as f32)) / (cols + rows);
1425 let color = if matches!(ch, '·' | '░' | '✦') {
1426 // Soft uwu blush/sparkle use the eye/sakura channel; classic only has ·.
1427 eye
1428 } else if animated {
1429 idle_mark_color(
1430 base,
1431 highlight,
1432 idle_mark_shine_opacity(diagonal, elapsed_ms),
1433 )
1434 } else {
1435 base
1436 };
1437 if run_color != Some(color) {
1438 if let Some(previous) = run_color {
1439 spans.push(Span::styled(
1440 std::mem::take(&mut run),
1441 Style::default().fg(previous),
1442 ));
1443 }
1444 run_color = Some(color);
1445 }
1446 run.push(ch);
1447 }
1448 if let Some(previous) = run_color {
1449 spans.push(Span::styled(run, Style::default().fg(previous)));
1450 }
1451 spans
1452 }
1453
1454 #[must_use]
1455 fn idle_whale_block_width() -> usize {
1456 let classic = std::iter::once(IDLE_WHALE_SPOUT_ROW)
1457 .chain(IDLE_WHALE_ROWS.iter().copied())
1458 .map(UnicodeWidthStr::width)
1459 .max()
1460 .unwrap_or(0);
1461 let uwu = std::iter::once(UWU_IDLE_WHALE_SPOUT_ROW)
1462 .chain(UWU_IDLE_WHALE_ROWS.iter().copied())
1463 .map(UnicodeWidthStr::width)
1464 .max()
1465 .unwrap_or(0);
1466 classic.max(uwu)
1467 }
1468
1469 pub fn empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
1470 if area.width == 0 || area.height == 0 {
1471 return Vec::new();
1472 }
1473 let width = usize::from(area.width);
1474 let tier = ShellTier::for_area(area);
1475 let mut lines = vec![Line::from(""); usize::from(area.height / 4)];
1476 if empty_state_mark_visible(area) {
1477 let animated = idle_mark_animation_enabled(app);
1478 let elapsed_ms = app.ocean_started_at.elapsed().as_millis();
1479 let spout = idle_whale_spout_row(app);
1480 let rows = idle_whale_rows(app);
1481 let mut mark = vec![vec![Span::styled(
1482 spout,
1483 Style::default().fg(app.ui_theme.accent_secondary),
1484 )]];
1485 // Soft uwu: sakura blush/sparkle glyphs; classic keeps body peach + text eye.
1486 let highlight = if idle_whale_is_uwu(app) {
1487 app.ui_theme.accent_primary
1488 } else {
1489 app.ui_theme.text_body
1490 };
1491 mark.extend(rows.iter().enumerate().map(|(row, text)| {
1492 idle_whale_row_spans(
1493 text,
1494 row,
1495 elapsed_ms,
1496 animated,
1497 app.ui_theme.accent_action,
1498 app.ui_theme.text_body,
1499 highlight,
1500 )
1501 }));
1502 // The spout, head, belly, peduncle, and flukes are one drawing. Give
1503 // every row the same outer inset so the authored offsets survive;
1504 // centering each row independently shears the silhouette apart.
1505 let block_inset = " ".repeat(width.saturating_sub(idle_whale_block_width()) / 2);
1506 for row in mark {
1507 let mut spans = vec![Span::raw(block_inset.clone())];
1508 spans.extend(row);
1509 lines.push(Line::from(spans));
1510 }
1511 lines.push(Line::from(""));
1512 }
1513
1514 let identity = crate::tui::workspace_context::identity_from_context(
1515 &app.workspace,
1516 app.workspace_context.as_deref(),
1517 );
1518 let workspace = crate::utils::display_path(&app.workspace);
1519 let branch = identity.branch.as_deref().map_or_else(
1520 || tr(app.ui_locale, MessageId::EmptyStateNoGit),
1521 |branch| Cow::Owned(branch.to_string()),
1522 );
1523 let context = if tier == ShellTier::Compact {
1524 format!("codewhale · {branch}")
1525 } else {
1526 format!(
1527 "codewhale · {workspace} · {branch} · {} {}",
1528 tr(app.ui_locale, MessageId::EmptyStateMcpLabel),
1529 app.mcp_configured_count
1530 )
1531 };
1532 let context = truncate_to_width(&context, width);
1533 let inset = " ".repeat(width.saturating_sub(context.width()) / 2);
1534 lines.push(Line::from(Span::styled(
1535 format!("{inset}{context}"),
1536 Style::default().fg(app.ui_theme.text_muted),
1537 )));
1538 if area.height >= 6 {
1539 lines.push(Line::from(""));
1540 let (fleet_label, fleet_action) = if app.onboarding_needs_api_key {
1541 // `--skip-onboarding` can expose the launch shell without a usable
1542 // provider route. Do not claim that Fleet is ready in that state;
1543 // point at the boundary that can actually make it runnable.
1544 (
1545 tr(app.ui_locale, MessageId::EmptyStateFleetLabel),
1546 "/provider",
1547 )
1548 } else {
1549 // Built-in roles are immediately usable with the active route.
1550 // Keep this truth at every responsive tier so `/fleet setup`
1551 // reads as optional customization instead of required setup.
1552 (
1553 tr(app.ui_locale, MessageId::EmptyStateFleetSetupLabel),
1554 "/fleet setup",
1555 )
1556 };
1557 let fleet = format!("{fleet_label} {fleet_action}");
1558 let inset = " ".repeat(width.saturating_sub(fleet.width()) / 2);
1559 lines.push(Line::from(Span::styled(
1560 format!("{inset}{fleet}"),
1561 Style::default().fg(app.ui_theme.text_hint),
1562 )));
1563 if area.height >= 7 {
1564 let help = format!(
1565 "/help or Ctrl+K {}",
1566 tr(app.ui_locale, MessageId::EmptyStateHelpHint)
1567 );
1568 let inset = " ".repeat(width.saturating_sub(help.width()) / 2);
1569 lines.push(Line::from(Span::styled(
1570 format!("{inset}{help}"),
1571 Style::default().fg(app.ui_theme.text_hint),
1572 )));
1573 }
1574 }
1575 lines
1576 }
1577
1578 #[cfg(test)]
1579 mod tests {
1580 use super::*;
1581 use crate::{
1582 config::Config,
1583 tui::app::{LaunchState, TuiOptions},
1584 };
1585 use std::{
1586 cell::RefCell,
1587 path::PathBuf,
1588 time::{Duration, Instant},
1589 };
1590
1591 thread_local! {
1592 static BUILD_VERSION_OVERRIDE: RefCell<Option<String>> = const { RefCell::new(None) };
1593 }
1594
1595 /// Read the header's version-string override (see [`shell_build_version`]).
1596 pub(super) fn build_version_override() -> Option<String> {
1597 BUILD_VERSION_OVERRIDE.with(|cell| cell.borrow().clone())
1598 }
1599
1600 /// Pin the header's version stamp for the current test thread so width
1601 /// choreography is measured against a fixed length, not the ambient
1602 /// build's sha (which is `(dev)` locally and a sha on CI since #5245).
1603 /// The default fixture mirrors a sha-stamped build's width.
1604 struct BuildVersionGuard;
1605
1606 impl BuildVersionGuard {
1607 fn set(version: &str) -> Self {
1608 BUILD_VERSION_OVERRIDE.with(|cell| *cell.borrow_mut() = Some(version.to_string()));
1609 Self
1610 }
1611 }
1612
1613 impl Drop for BuildVersionGuard {
1614 fn drop(&mut self) {
1615 BUILD_VERSION_OVERRIDE.with(|cell| *cell.borrow_mut() = None);
1616 }
1617 }
1618
1619 /// An enforced-sandbox marker for this platform, or `None` where the
1620 /// enum has no enforced variant. Only identity matters here: the header
1621 /// reads `sandbox_backend.is_some()`, never which backend it is.
1622 fn enforced_backend() -> Option<crate::sandbox::SandboxType> {
1623 #[cfg(target_os = "macos")]
1624 {
1625 Some(crate::sandbox::SandboxType::MacosSeatbelt)
1626 }
1627 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1628 {
1629 Some(crate::sandbox::SandboxType::LinuxBubblewrap)
1630 }
1631 #[cfg(target_os = "windows")]
1632 {
1633 Some(crate::sandbox::SandboxType::Windows)
1634 }
1635 #[cfg(not(any(
1636 target_os = "macos",
1637 target_os = "windows",
1638 all(target_os = "linux", not(target_env = "ohos"))
1639 )))]
1640 {
1641 None
1642 }
1643 }
1644
1645 fn test_app() -> App {
1646 let mut app = App::new(
1647 TuiOptions {
1648 model: "deepseek-v4-flash".to_string(),
1649 start_in_agent_mode: true,
1650 ..crate::test_support::test_tui_options(PathBuf::from("."))
1651 },
1652 &Config::default(),
1653 );
1654 // `filesystem_scope_label` is deliberately honest about enforcement:
1655 // with no OS sandbox backend it appends " (unenforced)" (all Windows,
1656 // and Linux where bubblewrap is absent — it is opt-in). That is 12
1657 // extra columns in the permission chip, which both changes the exact
1658 // chip text and eats the width budget the cramped-layout assertions
1659 // below are calibrated against. Header rendering is not a probe of
1660 // the host's sandbox availability, so pin the backend and keep these
1661 // tests platform-stable; `permission_chip_says_unenforced_without_a_
1662 // backend` covers the `None` rendering explicitly.
1663 app.sandbox_backend = enforced_backend();
1664 app
1665 }
1666
1667 fn launch() -> LaunchState {
1668 LaunchState {
1669 visible: true,
1670 selected: 0,
1671 worktree_input: None,
1672 status: None,
1673 workspace_session_count: 2,
1674 worktree_available: true,
1675 row_areas: Vec::new(),
1676 }
1677 }
1678
1679 #[test]
1680 fn launch_row_hitboxes_follow_responsive_render_rows() {
1681 let mut launch = launch();
1682 record_launch_row_areas(Rect::new(3, 2, 80, 24), &mut launch);
1683 assert_eq!(launch.row_areas.len(), 5);
1684 assert_eq!(launch.row_areas[0], Rect::new(3, 6, 80, 1));
1685 assert_eq!(launch.row_areas[4], Rect::new(3, 10, 80, 1));
1686
1687 record_launch_row_areas(Rect::new(3, 2, 40, 10), &mut launch);
1688 assert_eq!(launch.row_areas.len(), 4);
1689 assert_eq!(launch.row_areas[0], Rect::new(3, 5, 40, 1));
1690 }
1691
1692 fn footer_text(app: &mut App) -> String {
1693 let area = Rect::new(0, 0, 100, 1);
1694 let mut buf = Buffer::empty(area);
1695 render_footer(area, &mut buf, app);
1696 (0..area.width).map(|x| buf[(x, 0)].symbol()).collect()
1697 }
1698
1699 fn header_text(app: &App, width: u16) -> String {
1700 let area = Rect::new(0, 0, width, 1);
1701 let mut buf = Buffer::empty(area);
1702 render_header(area, &mut buf, app);
1703 (0..width).map(|x| buf[(x, 0)].symbol()).collect()
1704 }
1705
1706 #[test]
1707 fn configured_session_tokens_follow_underwater_header_width_priority() {
1708 // Pin the version stamp: the Wide-tier breakpoints below are
1709 // calibrated to a sha-length stamp, which #5245 no longer guarantees
1710 // on a local build.
1711 let _version = BuildVersionGuard::set("0.9.4 (000000000000)");
1712 let mut app = test_app();
1713 app.header_items = vec![HeaderItem::Tokens];
1714 app.session.total_input_tokens = 18_000;
1715 app.session.total_cache_hit_tokens = 12_000;
1716 app.session.total_output_tokens = 6_000;
1717 app.session.last_prompt_tokens = Some(48_000);
1718
1719 // The optional chip is the only elidable element. It appears once the
1720 // terminal can hold it alongside the whole baseline right-hand chrome
1721 // plus the guaranteed-left minimum (brand, mode, effort, permission +
1722 // filesystem scope). Route detail — already the first thing this header
1723 // truncates under pressure — is what yields the space.
1724 //
1725 // The Wide tier re-adds the version stamp to the baseline, but the
1726 // route detail yields before the complete optional chip.
1727 for (width, should_show_tokens, should_show_context) in [
1728 (40, false, false),
1729 (60, false, true),
1730 (80, false, true),
1731 (93, true, true),
1732 (100, true, true),
1733 (110, false, true),
1734 (130, true, true),
1735 ] {
1736 let header = header_text(&app, width);
1737 assert_eq!(
1738 header.contains("18.0k in · 12.0k cch · 6.0k out"),
1739 should_show_tokens,
1740 "unexpected token visibility at width {width}: {header:?}",
1741 );
1742 assert_eq!(
1743 header.contains('%'),
1744 should_show_context,
1745 "unexpected context visibility at width {width}: {header:?}",
1746 );
1747 assert!(
1748 header.to_ascii_lowercase().contains("act"),
1749 "mode must survive at width {width}: {header:?}",
1750 );
1751 assert!(
1752 header.to_ascii_lowercase().contains("ask"),
1753 "permission must survive at width {width}: {header:?}",
1754 );
1755 }
1756 }
1757
1758 #[test]
1759 fn underwater_header_shows_update_chip_only_when_update_available() {
1760 // The startup version check sets the label once; the chip then rides
1761 // the right-hand chrome until the session ends (#14).
1762 let mut app = test_app();
1763 app.update_available = Some("↑ v0.9.5".to_string());
1764 for width in [96, 130] {
1765 let header = header_text(&app, width);
1766 assert!(
1767 header.contains("↑ v0.9.5"),
1768 "update chip missing at width {width}: {header:?}"
1769 );
1770 }
1771 // Under width pressure the chip yields cleanly — never clipped
1772 // mid-chip, never evicting the mode/permission posture.
1773 let narrow = header_text(&app, 60);
1774 assert!(
1775 !narrow.contains('↑'),
1776 "update chip must drop when the line has no room: {narrow:?}"
1777 );
1778 assert!(
1779 narrow.to_ascii_lowercase().contains("ask"),
1780 "permission must survive at width 60: {narrow:?}"
1781 );
1782
1783 // Up to date (or the check never ran): silent.
1784 let app = test_app();
1785 let header = header_text(&app, 130);
1786 assert!(
1787 !header.contains('↑'),
1788 "no update chip without an available update: {header:?}"
1789 );
1790 }
1791
1792 #[test]
1793 fn underwater_header_keeps_session_tokens_opt_in() {
1794 let mut app = test_app();
1795 app.header_items.clear();
1796 app.session.total_input_tokens = 18_000;
1797 app.session.total_cache_hit_tokens = 12_000;
1798 app.session.total_output_tokens = 6_000;
1799 app.session.last_prompt_tokens = Some(48_000);
1800
1801 let normal_header = header_text(&app, 60);
1802 let wide_header = header_text(&app, 110);
1803
1804 assert!(
1805 !normal_header.contains("18.0k in"),
1806 "header: {normal_header:?}"
1807 );
1808 assert!(
1809 normal_header.contains('%'),
1810 "context meter missing: {normal_header:?}"
1811 );
1812 assert!(
1813 wide_header.contains('%'),
1814 "context meter missing: {wide_header:?}"
1815 );
1816 assert!(
1817 wide_header.contains(&format!("v{}", shell_build_version())),
1818 "version missing: {wide_header:?}"
1819 );
1820 }
1821
1822 #[test]
1823 fn compact_header_keeps_mode_and_effective_permission() {
1824 let mut app = test_app();
1825 app.mode = AppMode::Operate;
1826 app.approval_mode = ApprovalMode::Bypass;
1827 app.reasoning_effort = crate::tui::app::ReasoningEffort::Low;
1828 app.model = "provider/model-with-a-deliberately-long-route-name".to_string();
1829
1830 let header = header_text(&app, 40);
1831
1832 assert!(header.starts_with("cw"), "brand missing: {header:?}");
1833 assert!(
1834 header.to_ascii_lowercase().contains("operate"),
1835 "mode missing: {header:?}"
1836 );
1837 assert!(
1838 header.contains("Full Access"),
1839 "permission posture missing: {header:?}"
1840 );
1841 assert!(
1842 header.contains(" · l · Full Access"),
1843 "effective effort missing: {header:?}"
1844 );
1845 }
1846
1847 #[test]
1848 fn ocean_header_renders_active_goal_and_hides_it_when_unset_or_terminal() {
1849 // #39: the ocean shell has no sidebar, so the topbar is the surface
1850 // that must show a goal the moment `create_goal` sets it.
1851 let mut app = test_app();
1852 let idle = header_text(&app, 120);
1853 assert!(
1854 !idle.contains("goal"),
1855 "no goal chip without an active goal: {idle:?}"
1856 );
1857
1858 app.hunt.quarry = Some("Ship the v0.9.4 release train".to_string());
1859 let hunting = header_text(&app, 120);
1860 assert!(
1861 hunting.contains("goal Ship the v0.9.4"),
1862 "active goal missing from ocean topbar: {hunting:?}"
1863 );
1864
1865 app.hunt.verdict = crate::tui::app::HuntVerdict::Hunted;
1866 let done = header_text(&app, 120);
1867 assert!(
1868 !done.contains("goal"),
1869 "terminal goal must not linger in the topbar: {done:?}"
1870 );
1871 }
1872
1873 #[test]
1874 fn ocean_header_names_a_paused_goal() {
1875 let mut app = test_app();
1876 app.paused_quarry = Some("Audit the fleet roster".to_string());
1877 let header = header_text(&app, 120);
1878 assert!(
1879 header.contains("goal paused Audit the"),
1880 "paused goal must say so: {header:?}"
1881 );
1882 }
1883
1884 #[test]
1885 fn ocean_header_keeps_goal_chip_in_cramped_layouts() {
1886 let mut app = test_app();
1887 app.model = "provider/model-with-a-deliberately-long-route-name".to_string();
1888 app.hunt.quarry = Some("Ship it".to_string());
1889 // Width pressure forces the cramped rebuild: the route yields first
1890 // and the goal chip survives whole.
1891 let header = header_text(&app, 80);
1892 assert!(
1893 header.contains("goal Ship it"),
1894 "goal chip must survive width pressure: {header:?}"
1895 );
1896 // When even a minimal chip cannot fit alongside mode, effort, and
1897 // permission, it drops cleanly instead of clipping mid-word.
1898 let narrow = header_text(&app, 48);
1899 assert!(
1900 !narrow.contains("goal"),
1901 "unsupportable goal chip must drop, not clip: {narrow:?}"
1902 );
1903 }
1904
1905 #[test]
1906 fn ocean_header_renders_running_workflow_chip_and_hides_it_when_idle() {
1907 // #5040: a collapsed workflow run must stay visible in the ocean
1908 // topbar — the same chip the classic header shows.
1909 let mut app = test_app();
1910 let idle = header_text(&app, 120);
1911 assert!(
1912 !idle.contains("wf "),
1913 "no workflow chip without a run: {idle:?}"
1914 );
1915
1916 app.workflow_panel = Some(crate::tui::widgets::workflow_panel::WorkflowPanel::new(
1917 "wf_1", "ship it", 0,
1918 ));
1919 let running = header_text(&app, 120);
1920 assert!(
1921 running.contains("wf running"),
1922 "running workflow chip missing from ocean topbar: {running:?}"
1923 );
1924 }
1925
1926 #[test]
1927 fn ocean_header_keeps_completed_workflow_status_visible() {
1928 let mut app = test_app();
1929 let mut panel =
1930 crate::tui::widgets::workflow_panel::WorkflowPanel::new("wf_2", "ship it", 1_000);
1931 panel.lifecycle = crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Succeeded;
1932 panel.completed_at_ms = Some(61_000);
1933 app.workflow_panel = Some(panel);
1934 let header = header_text(&app, 120);
1935 assert!(
1936 header.contains("wf success"),
1937 "completed workflow status missing: {header:?}"
1938 );
1939 }
1940
1941 #[test]
1942 fn ocean_header_keeps_workflow_chip_in_cramped_layouts() {
1943 let mut app = test_app();
1944 app.model = "provider/model-with-a-deliberately-long-route-name".to_string();
1945 app.workflow_panel = Some(crate::tui::widgets::workflow_panel::WorkflowPanel::new(
1946 "wf_3", "ship it", 0,
1947 ));
1948 // Width pressure forces the cramped rebuild: the route yields first
1949 // and the workflow chip survives whole.
1950 let header = header_text(&app, 80);
1951 assert!(
1952 header.contains("wf running"),
1953 "workflow chip must survive width pressure: {header:?}"
1954 );
1955 // When even a minimal chip cannot fit alongside mode, effort, and
1956 // permission, it drops cleanly instead of clipping mid-word.
1957 let narrow = header_text(&app, 48);
1958 assert!(
1959 !narrow.contains("wf "),
1960 "unsupportable workflow chip must drop, not clip: {narrow:?}"
1961 );
1962 }
1963
1964 #[test]
1965 fn header_labels_follow_the_ask_amber_auto_gold_full_access_coral_ramp() {
1966 for width in [40, 100] {
1967 for (approval_mode, expected_label) in [
1968 (ApprovalMode::Suggest, "ask"),
1969 (ApprovalMode::Auto, "auto"),
1970 (ApprovalMode::Bypass, "Full Access"),
1971 ] {
1972 let mut app = test_app();
1973 app.approval_mode = approval_mode;
1974 let expected_color = match approval_mode {
1975 ApprovalMode::Suggest | ApprovalMode::Never => app.ui_theme.permission_ask,
1976 ApprovalMode::Auto => app.ui_theme.permission_auto_review,
1977 ApprovalMode::Bypass => app.ui_theme.permission_full_access,
1978 };
1979 let label = permission_label(&app).into_owned();
1980 assert!(
1981 label.starts_with(expected_label) && label.contains("files:"),
1982 "{approval_mode:?}: {label}"
1983 );
1984 let area = Rect::new(0, 0, width, 1);
1985 let mut buf = Buffer::empty(area);
1986
1987 render_header(area, &mut buf, &app);
1988
1989 let rendered = (0..width).map(|x| buf[(x, 0)].symbol()).collect::<String>();
1990 // `auto` can also appear earlier as a route/mode label. The
1991 // permission posture owns the rightmost occurrence.
1992 let label_byte = rendered
1993 .rfind(expected_label)
1994 .expect("permission label should render");
1995 let label_x = rendered[..label_byte].width() as u16;
1996 assert_eq!(buf[(label_x, 0)].fg, expected_color, "{approval_mode:?}");
1997 }
1998 }
1999 }
2000
2001 #[test]
2002 fn permission_chip_reports_the_same_effective_scope_as_execution() {
2003 let mut app = test_app();
2004 app.approval_mode = ApprovalMode::Bypass;
2005 assert_eq!(
2006 permission_label(&app),
2007 Cow::Borrowed("Full Access · files: full disk")
2008 );
2009
2010 app.configured_sandbox_mode = Some("workspace-write".to_string());
2011 assert_eq!(
2012 permission_label(&app),
2013 Cow::Borrowed("Full Access · files: workspace")
2014 );
2015
2016 app.mode = AppMode::Plan;
2017 app.configured_sandbox_mode = Some("danger-full-access".to_string());
2018 assert_eq!(permission_label(&app), Cow::Borrowed("read only"));
2019 }
2020
2021 /// The other half of the chip contract: a policy is an intent, and
2022 /// without a backend nothing applies it. On those platforms the chip must
2023 /// say so rather than name a boundary that is not enforced (2026-08-04
2024 /// audit). `DangerFullAccess` is already honest and stays unqualified.
2025 #[test]
2026 fn permission_chip_says_unenforced_without_a_backend() {
2027 let mut app = test_app();
2028 app.sandbox_backend = None;
2029
2030 app.approval_mode = ApprovalMode::Bypass;
2031 app.configured_sandbox_mode = Some("workspace-write".to_string());
2032 assert_eq!(
2033 permission_label(&app),
2034 Cow::Borrowed("Full Access · files: workspace (unenforced)")
2035 );
2036
2037 app.configured_sandbox_mode = Some("danger-full-access".to_string());
2038 assert_eq!(
2039 permission_label(&app),
2040 Cow::Borrowed("Full Access · files: full disk")
2041 );
2042 }
2043
2044 #[test]
2045 fn normal_header_keeps_requested_effective_effort_before_route_detail() {
2046 let mut app = test_app();
2047 app.mode = AppMode::Operate;
2048 app.approval_mode = ApprovalMode::Bypass;
2049 app.reasoning_effort = crate::tui::app::ReasoningEffort::Low;
2050 app.model = "provider/model-with-a-deliberately-long-route-name".to_string();
2051
2052 let header = header_text(&app, 80);
2053
2054 // First-party DeepSeek maps low -> low (8c5370a56: the wire documents
2055 // [low, high, max] and has no medium), so requested and effective agree
2056 // and the header renders the tier alone. Assert the absence of the
2057 // arrow too: bare `contains("low")` would also pass on the old
2058 // `low→high` rendering, which is the regression this pins against.
2059 assert!(header.contains("low"), "effort missing: {header:?}");
2060 assert!(
2061 !header.contains("low→"),
2062 "requested and effective agree on first-party DeepSeek; no arrow expected: {header:?}"
2063 );
2064 assert!(
2065 header.to_ascii_lowercase().contains("operate"),
2066 "mode missing: {header:?}"
2067 );
2068 assert!(
2069 header.contains("Full Access"),
2070 "permission posture missing: {header:?}"
2071 );
2072 }
2073
2074 #[test]
2075 fn compact_header_never_shows_a_whale_emoji_even_for_legacy_settings() {
2076 // The whale emoji header chip is retired (2026-07-23): a persisted
2077 // "whale" opt-in renders the typographic mark, and no header width
2078 // squeeze may reintroduce the emoji beside the model/mode chips.
2079 let mut app = test_app();
2080 app.status_indicator = "whale".to_string();
2081 app.model = "provider/model-with-a-deliberately-long-route-name".to_string();
2082
2083 let header = header_text(&app, 40);
2084
2085 assert!(
2086 !header.contains('🐳') && !header.contains('🐋'),
2087 "whale emoji must stay out of the header: {header:?}"
2088 );
2089 assert!(header.contains("cw"), "cw mark missing: {header:?}");
2090 }
2091
2092 #[test]
2093 fn header_shows_exact_named_custom_provider() {
2094 let mut app = test_app();
2095 app.set_provider_identity(crate::config::ApiProvider::Custom, "lm-studio");
2096 app.model = "local-code-model".to_string();
2097
2098 let header = header_text(&app, 100);
2099
2100 assert!(
2101 header.contains("lm-studio · local-code-model"),
2102 "{header:?}"
2103 );
2104 assert!(!header.contains("Custom ·"), "{header:?}");
2105 }
2106
2107 /// The footer consumes the toast system, not the legacy status sink: an
2108 /// informational acknowledgement must leave on its own instead of
2109 /// becoming permanent idle chrome.
2110 #[test]
2111 fn footer_notices_expire_instead_of_becoming_permanent_chrome() {
2112 let mut app = test_app();
2113 app.status_message = Some("Auto-compaction enabled".to_string());
2114
2115 let fresh = footer_text(&mut app);
2116 assert!(
2117 fresh.contains("Auto-compaction enabled"),
2118 "a fresh notice should surface once: {fresh}"
2119 );
2120
2121 for toast in &mut app.status_toasts {
2122 toast.created_at = Instant::now() - Duration::from_secs(60);
2123 }
2124 let later = footer_text(&mut app);
2125 assert!(
2126 !later.contains("Auto-compaction"),
2127 "an informational acknowledgement must expire without user action: {later}"
2128 );
2129 assert!(
2130 later.contains("idle"),
2131 "the stable phase fact survives the expiry: {later}"
2132 );
2133 }
2134
2135 /// Errors are sticky: they outlive the informational TTL window and stay
2136 /// until their own resolution window passes, then expire on their own.
2137 #[test]
2138 fn footer_errors_outlive_informational_acknowledgements() {
2139 let mut app = test_app();
2140 app.status_message = Some("Provider request failed: timeout".to_string());
2141
2142 let fresh = footer_text(&mut app);
2143 assert!(fresh.contains("failed"), "error notice missing: {fresh}");
2144
2145 if let Some(sticky) = app.sticky_status.as_mut() {
2146 assert_eq!(
2147 sticky.ttl_ms,
2148 Some(crate::tui::app::App::STICKY_ERROR_TTL_MS)
2149 );
2150 sticky.created_at = Instant::now() - Duration::from_secs(6);
2151 } else {
2152 panic!("an error must be promoted to the sticky slot");
2153 }
2154 let held = footer_text(&mut app);
2155 assert!(
2156 held.contains("failed"),
2157 "errors must hold past the informational window: {held}"
2158 );
2159
2160 if let Some(sticky) = app.sticky_status.as_mut() {
2161 sticky.created_at = Instant::now()
2162 - Duration::from_millis(crate::tui::app::App::STICKY_ERROR_TTL_MS + 1);
2163 }
2164 let expired = footer_text(&mut app);
2165 assert!(
2166 !expired.contains("failed"),
2167 "sticky errors must expire after their TTL: {expired}"
2168 );
2169 }
2170
2171 #[test]
2172 fn sticky_error_clears_when_composer_gets_input() {
2173 let mut app = test_app();
2174 app.set_sticky_status(
2175 "workflow failed: script error",
2176 crate::tui::app::StatusToastLevel::Error,
2177 None,
2178 );
2179 assert!(app.sticky_status.is_some());
2180 app.insert_char('x');
2181 assert!(
2182 app.sticky_status.is_none(),
2183 "composer activity must dismiss sticky error chrome"
2184 );
2185 }
2186
2187 #[test]
2188 fn launch_rows_and_direct_keys_share_actions() {
2189 let mut state = launch();
2190 assert_eq!(
2191 handle_launch_key(
2192 &mut state,
2193 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
2194 Locale::En,
2195 ),
2196 LaunchAction::NewSession
2197 );
2198 assert_eq!(
2199 handle_launch_key(
2200 &mut state,
2201 KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
2202 Locale::En,
2203 ),
2204 LaunchAction::Resume
2205 );
2206 assert_eq!(state.selected, 2);
2207
2208 assert_eq!(
2209 handle_launch_key(
2210 &mut state,
2211 KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL),
2212 Locale::En,
2213 ),
2214 LaunchAction::Changelog
2215 );
2216 assert_eq!(state.selected, 3);
2217 }
2218
2219 #[test]
2220 fn worktree_action_collects_a_name_before_creation() {
2221 let mut state = launch();
2222 assert_eq!(
2223 handle_launch_key(
2224 &mut state,
2225 KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
2226 Locale::En,
2227 ),
2228 LaunchAction::None
2229 );
2230 for ch in "repair-pty".chars() {
2231 assert_eq!(
2232 handle_launch_key(
2233 &mut state,
2234 KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
2235 Locale::En,
2236 ),
2237 LaunchAction::None
2238 );
2239 }
2240 assert_eq!(
2241 handle_launch_key(
2242 &mut state,
2243 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
2244 Locale::En,
2245 ),
2246 LaunchAction::CreateWorktree("repair-pty".to_string())
2247 );
2248 }
2249
2250 #[test]
2251 fn unavailable_worktree_is_truthful_and_non_destructive() {
2252 let mut state = launch();
2253 state.worktree_available = false;
2254 assert_eq!(
2255 handle_launch_key(
2256 &mut state,
2257 KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
2258 Locale::En,
2259 ),
2260 LaunchAction::None
2261 );
2262 assert!(state.worktree_input.is_none());
2263 assert_eq!(
2264 state.status.as_deref(),
2265 Some("New worktree requires a Git repository.")
2266 );
2267 }
2268
2269 #[test]
2270 fn phase_markers_make_motion_and_attention_explicit() {
2271 let mut app = test_app();
2272
2273 app.runtime_turn_status = Some("in_progress".to_string());
2274 app.turn_started_at = Some(Instant::now() - Duration::from_millis(1_300));
2275 let (working, label) = phase_marker(&app, ShellPhase::from_app(&app));
2276 assert!(crate::tui::spinner::BRAILLE_SPINNER_FRAMES.contains(&working));
2277 assert_eq!(label, "working");
2278
2279 app.low_motion = true;
2280 app.turn_started_at = Some(Instant::now() - Duration::from_secs(9));
2281 assert_eq!(
2282 phase_marker(&app, ShellPhase::Working).0,
2283 WORKING_BUBBLE_FRAMES[4]
2284 );
2285
2286 app.runtime_turn_status = None;
2287 app.runtime_turn_status = Some("failed".to_string());
2288 let (marker, label) = phase_marker(&app, ShellPhase::from_app(&app));
2289 assert_eq!(marker, "✕");
2290 assert_eq!(label, "failed");
2291 }
2292
2293 #[test]
2294 fn live_activity_is_truthful_prioritized_and_ignores_stale_tools() {
2295 use crate::tui::active_cell::ActiveCell;
2296 use crate::tui::history::{
2297 ExploringCell, ExploringEntry, GenericToolCell, HistoryCell, ToolCell, ToolStatus,
2298 };
2299
2300 let generic = |name: &str, status: ToolStatus| {
2301 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2302 name: name.to_string(),
2303 status,
2304 input_summary: None,
2305 output: None,
2306 prompts: None,
2307 spillover_path: None,
2308 output_summary: None,
2309 is_diff: false,
2310 }))
2311 };
2312 let reading = || {
2313 HistoryCell::Tool(ToolCell::Exploring(ExploringCell {
2314 entries: vec![ExploringEntry {
2315 label: "Reading src/lib.rs".to_string(),
2316 status: ToolStatus::Running,
2317 }],
2318 }))
2319 };
2320
2321 let mut app = test_app();
2322
2323 // A completed tool may remain in the active group until TurnComplete,
2324 // but it cannot manufacture liveness on its own.
2325 let mut stale = ActiveCell::new();
2326 stale.push_tool("done", generic("write_file", ToolStatus::Success));
2327 app.active_cell = Some(stale);
2328 assert_eq!(
2329 LiveActivity::from_app(&app).kind(),
2330 LiveActivityKind::Working
2331 );
2332 assert_eq!(ShellPhase::from_app(&app), ShellPhase::Idle);
2333
2334 // Only the explicit streaming pointer earns the reasoning label. No
2335 // configured effort, elapsed clock, or generic loading inference is
2336 // involved.
2337 let mut active = ActiveCell::new();
2338 let thinking = active.push_thinking(HistoryCell::Thinking {
2339 content: "private reasoning must not reach the strip".to_string(),
2340 streaming: true,
2341 duration_secs: None,
2342 });
2343 app.active_cell = Some(active);
2344 app.streaming_thinking_active_entry = Some(thinking);
2345 assert_eq!(
2346 LiveActivity::from_app(&app).kind(),
2347 LiveActivityKind::Reasoning
2348 );
2349 let (_, label) = phase_marker(&app, ShellPhase::from_app(&app));
2350 assert_eq!(label, "reasoning");
2351 assert!(!label.contains("private"));
2352
2353 // A running read wins over a stale thinking pointer.
2354 app.active_cell
2355 .as_mut()
2356 .expect("active cell")
2357 .push_tool("read", reading());
2358 let activity = LiveActivity::from_app(&app);
2359 assert_eq!(activity.kind(), LiveActivityKind::Reading);
2360 assert_eq!(activity.running_tool_count(), 1);
2361 assert_eq!(phase_marker(&app, ShellPhase::Working).1, "reading");
2362
2363 // Mixed tool work is not mislabeled as a pure read pass.
2364 app.active_cell
2365 .as_mut()
2366 .expect("active cell")
2367 .push_tool("write", generic("write_file", ToolStatus::Running));
2368 let activity = LiveActivity::from_app(&app);
2369 assert_eq!(activity.kind(), LiveActivityKind::UsingTool);
2370 assert_eq!(activity.running_tool_count(), 2);
2371 assert_eq!(phase_marker(&app, ShellPhase::Working).1, "using tool");
2372
2373 // Verification remains the strongest live promise.
2374 app.active_cell
2375 .as_mut()
2376 .expect("active cell")
2377 .push_tool("verify", generic("run_verifiers", ToolStatus::Running));
2378 assert_eq!(
2379 LiveActivity::from_app(&app).kind(),
2380 LiveActivityKind::Verifying
2381 );
2382 assert_eq!(ShellPhase::from_app(&app), ShellPhase::Verifying);
2383 }
2384
2385 #[test]
2386 fn live_activity_marker_freezes_for_reduced_or_still_and_has_ascii_fallback() {
2387 let mut app = test_app();
2388 app.runtime_turn_status = Some("in_progress".to_string());
2389 app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));
2390
2391 app.low_motion = true;
2392 let reduced = phase_marker(&app, ShellPhase::Working).0;
2393 assert_eq!(reduced, crate::tui::spinner::BRAILLE_SPINNER_STILL_FRAME);
2394
2395 app.low_motion = false;
2396 app.fancy_animations = false;
2397 let fancy_off = phase_marker(&app, ShellPhase::Working).0;
2398 assert_eq!(fancy_off, crate::tui::spinner::LIVE_STATIC_MARKER);
2399
2400 let mut cell = ratatui::buffer::Cell::default();
2401 cell.set_symbol(fancy_off);
2402 crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell);
2403 assert_eq!(cell.symbol(), ">");
2404 assert!(cell.symbol().is_ascii());
2405 }
2406
2407 #[test]
2408 fn idle_whale_caustic_sweeps_then_parks_offscreen() {
2409 assert_eq!(idle_mark_shine_opacity(0.5, 0), 0.0);
2410 assert!(
2411 idle_mark_shine_opacity(0.5, 640) > 0.32,
2412 "the raised-cosine band should reach its peak near mid-sweep"
2413 );
2414 assert_eq!(
2415 idle_mark_shine_opacity(0.5, 2_000),
2416 0.0,
2417 "the caustic must rest offscreen between sweeps"
2418 );
2419 }
2420
2421 #[test]
2422 fn idle_whale_caustic_preserves_text_width_and_has_a_static_fallback() {
2423 let base = Color::Rgb(246, 196, 83);
2424 let highlight = Color::Rgb(246, 242, 232);
2425 let text = IDLE_WHALE_ROWS[0];
2426 let moving = idle_whale_row_spans(text, 0, 640, true, base, highlight, highlight);
2427 let parked = idle_whale_row_spans(text, 0, 2_000, true, base, highlight, highlight);
2428 let frozen_a = idle_whale_row_spans(text, 0, 640, false, base, highlight, highlight);
2429 let frozen_b = idle_whale_row_spans(text, 0, 2_000, false, base, highlight, highlight);
2430
2431 let content = |spans: &[Span<'_>]| {
2432 spans
2433 .iter()
2434 .map(|span| span.content.as_ref())
2435 .collect::<String>()
2436 };
2437 let colors =
2438 |spans: &[Span<'_>]| spans.iter().map(|span| span.style.fg).collect::<Vec<_>>();
2439
2440 for spans in [&moving, &parked, &frozen_a, &frozen_b] {
2441 assert_eq!(content(spans), text);
2442 assert_eq!(span_width(spans), text.width());
2443 }
2444 assert_ne!(colors(&moving), colors(&parked));
2445 assert_eq!(colors(&frozen_a), colors(&frozen_b));
2446 }
2447
2448 #[test]
2449 fn idle_whale_rows_share_one_centered_block_without_losing_authored_offsets() {
2450 let mut app = test_app();
2451 app.ui_theme = crate::palette::ThemeId::Whale.ui_theme();
2452 app.low_motion = true;
2453 let width = 60usize;
2454 let rendered = empty_state_lines(&app, Rect::new(0, 0, width as u16, 16))
2455 .iter()
2456 .map(|line| {
2457 line.spans
2458 .iter()
2459 .map(|span| span.content.as_ref())
2460 .collect::<String>()
2461 })
2462 .collect::<Vec<_>>();
2463 let block_width = idle_whale_block_width();
2464 let block_inset = (width - block_width) / 2;
2465
2466 assert_eq!(
2467 block_width, 23,
2468 "the final mark should stay quiet at 60 cols"
2469 );
2470 for row in std::iter::once(IDLE_WHALE_SPOUT_ROW).chain(IDLE_WHALE_ROWS) {
2471 let line = rendered
2472 .iter()
2473 .find(|line| line.trim_start() == row.trim_start())
2474 .unwrap_or_else(|| panic!("missing authored whale row {row:?}"));
2475 let rendered_inset = line.chars().take_while(|ch| *ch == ' ').count();
2476 let authored_inset = row.chars().take_while(|ch| *ch == ' ').count();
2477
2478 assert_eq!(
2479 rendered_inset - authored_inset,
2480 block_inset,
2481 "row drifted out of the shared silhouette: {line:?}"
2482 );
2483 assert!(
2484 line.width() <= block_inset + block_width,
2485 "row escaped the centered mark block: {line:?}"
2486 );
2487 }
2488 }
2489
2490 #[test]
2491 fn idle_whale_has_a_recognizable_ascii_safe_silhouette() {
2492 let ascii_row = |row: &str| {
2493 let mut rendered = String::new();
2494 for ch in row.chars() {
2495 let mut cell = ratatui::buffer::Cell::default();
2496 cell.set_symbol(&ch.to_string());
2497 crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell);
2498 rendered.push_str(cell.symbol());
2499 }
2500 rendered
2501 };
2502 let rows = std::iter::once(IDLE_WHALE_SPOUT_ROW)
2503 .chain(IDLE_WHALE_ROWS)
2504 .map(ascii_row)
2505 .collect::<Vec<_>>();
2506
2507 assert_eq!(
2508 rows,
2509 [
2510 " o",
2511 r" .###########. \/",
2512 " |##.############----/",
2513 " .############.",
2514 ]
2515 );
2516 assert!(rows.iter().all(|row| row.is_ascii()));
2517 }
2518
2519 #[test]
2520 fn reduced_motion_keeps_the_whole_idle_mark_still_and_cursorless() {
2521 let mut app = test_app();
2522 app.low_motion = true;
2523 app.fancy_animations = true;
2524 app.cursor_position = 7;
2525 app.ocean_started_at = Instant::now() - Duration::from_secs(2);
2526 let first = empty_state_lines(&app, Rect::new(0, 0, 100, 30));
2527
2528 app.ocean_started_at = Instant::now() - Duration::from_secs(11);
2529 let second = empty_state_lines(&app, Rect::new(0, 0, 100, 30));
2530
2531 assert_eq!(first, second, "reduced motion must freeze mark and shine");
2532 assert_eq!(
2533 app.cursor_position, 7,
2534 "the empty-state decoration must leave cursor ownership to the composer"
2535 );
2536 }
2537
2538 #[test]
2539 fn idle_whale_uses_the_human_brand_role_not_focus_blue() {
2540 let mut app = test_app();
2541 app.low_motion = true;
2542 let lines = empty_state_lines(&app, Rect::new(0, 0, 100, 30));
2543 let colors = lines
2544 .iter()
2545 .flat_map(|line| line.spans.iter())
2546 .filter_map(|span| span.style.fg)
2547 .collect::<Vec<_>>();
2548
2549 assert!(colors.contains(&app.ui_theme.accent_action));
2550 assert_ne!(app.ui_theme.accent_action, app.ui_theme.accent_primary);
2551 }
2552
2553 #[test]
2554 fn idle_whale_caustic_obeys_motion_policy_and_attention_stillness() {
2555 let mut app = test_app();
2556 app.launch.visible = false;
2557 app.low_motion = false;
2558 app.fancy_animations = true;
2559 assert!(idle_mark_animation_enabled(&app));
2560
2561 app.low_motion = true;
2562 assert!(!idle_mark_animation_enabled(&app));
2563
2564 app.low_motion = false;
2565 app.fancy_animations = false;
2566 assert!(!idle_mark_animation_enabled(&app));
2567
2568 app.fancy_animations = true;
2569 app.ocean_treatment = crate::tui::ocean::OceanTreatment::Flat;
2570 assert!(idle_mark_animation_enabled(&app));
2571
2572 app.ocean_treatment = crate::tui::ocean::OceanTreatment::Ombre;
2573 app.launch.visible = true;
2574 assert!(!idle_mark_animation_enabled(&app));
2575
2576 app.launch.visible = false;
2577 app.view_stack
2578 .push(crate::tui::views::HelpView::new_for_locale(app.ui_locale));
2579 assert!(!idle_mark_animation_enabled(&app));
2580 }
2581
2582 #[test]
2583 fn verifying_phase_meters_a_tick_for_test_runs_only() {
2584 use crate::tui::active_cell::ActiveCell;
2585 use crate::tui::history::{ExecCell, ExecSource, HistoryCell, ToolCell, ToolStatus};
2586
2587 let running_exec = |command: &str| {
2588 HistoryCell::Tool(ToolCell::Exec(ExecCell {
2589 command: command.to_string(),
2590 status: ToolStatus::Running,
2591 output: None,
2592 live_output: None,
2593 shell_task_id: None,
2594 owner_agent_id: None,
2595 owner_agent_name: None,
2596 started_at: None,
2597 duration_ms: None,
2598 stale_elapsed_since_output_ms: None,
2599 source: ExecSource::Assistant,
2600 interaction: None,
2601 output_summary: None,
2602 }))
2603 };
2604
2605 let mut app = test_app();
2606 app.runtime_turn_status = Some("in_progress".to_string());
2607 app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));
2608
2609 // A live test run reads as `verifying`. Reduced motion keeps the
2610 // semantic label while sharing the calm, static live-work marker.
2611 let mut active = ActiveCell::new();
2612 active.push_tool("exec-1", running_exec("cargo test -p codewhale-tui"));
2613 app.active_cell = Some(active);
2614 assert_eq!(ShellPhase::from_app(&app), ShellPhase::Verifying);
2615 app.low_motion = true;
2616 let (marker, label) = phase_marker(&app, ShellPhase::Verifying);
2617 assert_eq!(marker, crate::tui::spinner::BRAILLE_SPINNER_STILL_FRAME);
2618 assert_eq!(label, "verifying");
2619 app.low_motion = false;
2620
2621 // An ordinary build stays `working` — checking must not lie.
2622 let mut active = ActiveCell::new();
2623 active.push_tool("exec-2", running_exec("cargo build --release"));
2624 app.active_cell = Some(active);
2625 assert_eq!(ShellPhase::from_app(&app), ShellPhase::Working);
2626
2627 // Verifying is a live phase: strip sits above the composer and
2628 // shares the live seafoam hue.
2629 assert!(
2630 crate::tui::phase_strip::PhaseStripPlacement::for_phase(ShellPhase::Verifying)
2631 .is_above_composer()
2632 );
2633 assert_eq!(
2634 ShellPhase::Verifying.color(&app),
2635 app.ui_theme.status_working
2636 );
2637 }
2638
2639 #[test]
2640 fn attention_and_failure_keep_distinct_semantic_hues() {
2641 let app = test_app();
2642 assert_eq!(ShellPhase::Waiting.color(&app), app.ui_theme.accent_action);
2643 assert_eq!(ShellPhase::Approval.color(&app), app.ui_theme.accent_action);
2644 assert_eq!(ShellPhase::Failed.color(&app), app.ui_theme.error_fg);
2645 assert_ne!(
2646 ShellPhase::Waiting.color(&app),
2647 ShellPhase::Failed.color(&app)
2648 );
2649 }
2650
2651 #[test]
2652 fn completion_releases_once_then_settles_to_checkmark() {
2653 let mut app = test_app();
2654 app.runtime_turn_status = Some("completed".to_string());
2655 app.low_motion = false;
2656 app.fancy_animations = true;
2657 app.ocean_completion_started_at = Some(Instant::now() - Duration::from_millis(120));
2658
2659 let (marker, label) = phase_marker(&app, ShellPhase::from_app(&app));
2660 assert_ne!(marker, "✓");
2661 assert_eq!(label, "finishing");
2662
2663 app.ocean_completion_started_at = Some(Instant::now() - Duration::from_millis(700));
2664 let (marker, label) = phase_marker(&app, ShellPhase::Done);
2665 assert_eq!(marker, "✓");
2666 assert_eq!(label, "done");
2667
2668 app.low_motion = true;
2669 app.ocean_completion_started_at = Some(Instant::now());
2670 let (marker, label) = phase_marker(&app, ShellPhase::Done);
2671 assert_eq!(marker, "✓");
2672 assert_eq!(label, "done");
2673 }
2674
2675 #[test]
2676 fn draft_phase_beats_stale_completion_status() {
2677 let mut app = test_app();
2678 app.runtime_turn_status = Some("completed".to_string());
2679
2680 assert_eq!(ShellPhase::from_app(&app), ShellPhase::Done);
2681
2682 app.input = "next task".to_string();
2683 assert_eq!(ShellPhase::from_app(&app), ShellPhase::Typing);
2684 }
2685 }
2686
2686 lines RUST