返回 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 layout::Rect,
14 style::{Color, Modifier, Style},
15 text::{Line, Span},
16 };
17 use unicode_width::UnicodeWidthStr;
18
19 use crate::tui::ui_text::{semantic_truncate, text_display_width};
20 use crate::tui::{
21 app::{App, OnboardingState},
22 ocean::COMPLETION_BREATH_MS,
23 views::ModalKind,
24 };
25 use codewhale_config::AppMode;
26 use codewhale_execpolicy::ApprovalMode;
27 use codewhale_localization::{Locale, MessageId, tr};
28 use codewhale_palette::ChromeInk;
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 /// What one launch key produces. The composer holds focus and takes every
40 /// ordinary key, so the only launch-owned input is F1 help; the card's
41 /// rows are driven by Up/Down + Enter (and the mouse) through
42 /// [`run_launch_card_row`].
43 #[derive(Debug, Clone, PartialEq, Eq)]
44 pub enum LaunchAction {
45 None,
46 /// The prominent new-session entry: begin a fresh session in the
47 /// current workspace.
48 NewSession,
49 ReturnToSession,
50 /// Resume one recent-work row by session id.
51 ResumeSession(String),
52 /// The see-all overflow: open the full session picker.
53 BrowseSessions,
54 /// Inspect and manage the servers counted by the MCP summary.
55 McpManager,
56 /// The MCP problems row: type the remedy it prints into the composer
57 /// (`/mcp login <name>` or `/mcp`). Typing beats copying — it works over
58 /// SSH where a clipboard may not exist, and the user sees the command
59 /// before Enter sends it (#6085).
60 McpRemedy,
61 Help,
62 }
63
64 /// Translate a launch key into one product action. Reached only through
65 /// [`LaunchComposerKey::MenuChord`]; every other key belongs to the
66 /// composer authority.
67 pub fn handle_launch_key(
68 _launch: &mut crate::tui::app::LaunchState,
69 key: KeyEvent,
70 _locale: Locale,
71 ) -> LaunchAction {
72 match key.code {
73 KeyCode::F(1) => LaunchAction::Help,
74 _ => LaunchAction::None,
75 }
76 }
77
78 /// One interactive row on the startup card: the prominent new-session
79 /// entry, one recent-work row, or the see-all overflow. Labels are
80 /// localized; `detail` is right-aligned metadata (a recent row's age).
81 #[derive(Debug, Clone, PartialEq, Eq)]
82 pub struct LaunchCardRow {
83 pub id: crate::tui::app::LaunchRowId,
84 pub label: String,
85 pub detail: String,
86 /// The new-session entry paints prominent (bold accent) when it is
87 /// neither keyboard-selected nor hovered.
88 pub prominent: bool,
89 }
90
91 /// A recent session projected for the card: the display title plus its
92 /// right-aligned detail line. Preformatted by the caller so the renderer
93 /// stays deterministic for golden buffers.
94 #[derive(Debug, Clone, PartialEq, Eq)]
95 pub struct LaunchRecentEntry {
96 pub id: String,
97 pub title: String,
98 pub detail: String,
99 }
100
101 /// The card's rows in paint/click/keyboard order: the prominent
102 /// new-session entry first, then recent work, then the see-all overflow
103 /// when more sessions sit behind the inline list. The single ordering
104 /// keyboard, mouse, and paint share.
105 #[must_use]
106 pub fn launch_card_rows(
107 locale: Locale,
108 recent: &[LaunchRecentEntry],
109 has_more: bool,
110 ) -> Vec<LaunchCardRow> {
111 let mut rows = Vec::with_capacity(recent.len() + 2);
112 rows.push(LaunchCardRow {
113 id: crate::tui::app::LaunchRowId::NewSession,
114 label: tr(locale, MessageId::LaunchNewSession).into_owned(),
115 detail: String::new(),
116 prominent: true,
117 });
118 rows.extend(recent.iter().map(|entry| LaunchCardRow {
119 id: crate::tui::app::LaunchRowId::Recent(entry.id.clone()),
120 label: entry.title.clone(),
121 detail: entry.detail.clone(),
122 prominent: false,
123 }));
124 if has_more {
125 rows.push(LaunchCardRow {
126 id: crate::tui::app::LaunchRowId::SeeAll,
127 label: tr(locale, MessageId::LaunchSeeAllSessions).into_owned(),
128 detail: String::new(),
129 prominent: false,
130 });
131 }
132 rows
133 }
134
135 /// Project the launch state's loaded recent-work list into card entries:
136 /// display titles with right-aligned relative ages, like the resume
137 /// picker. Pure projection of loaded state — no disk reads.
138 fn launch_recent_entries(app: &App) -> (Vec<LaunchRecentEntry>, bool) {
139 let recent = app
140 .launch
141 .recent
142 .iter()
143 .map(|session| {
144 let raw = crate::session_manager::extract_title(&session.title);
145 let title = if raw == "Session" || raw.trim().is_empty() {
146 crate::session_manager::truncate_id(&session.id).to_string()
147 } else {
148 raw.to_string()
149 };
150 let age = crate::tui::session_picker::format_relative_time(
151 &session.updated_at,
152 app.ui_locale,
153 );
154 LaunchRecentEntry {
155 id: session.id.clone(),
156 title,
157 detail: age,
158 }
159 })
160 .collect::<Vec<_>>();
161 // More sessions than the inline cap, or sessions the card's filter
162 // dropped that `/resume` still lists (empty auto-created shells):
163 // either way the see-all row is how the truth stays reachable.
164 let has_more = app.launch.total_workspace_sessions > recent.len()
165 || (recent.is_empty() && app.launch.has_scoped_sessions);
166 (recent, has_more)
167 }
168
169 /// Both painting and input use the same primary action on revisited home.
170 fn home_card_rows(app: &App, recent: &[LaunchRecentEntry], has_more: bool) -> Vec<LaunchCardRow> {
171 let mut rows = launch_card_rows(app.ui_locale, recent, has_more);
172 if app.launch.return_to_session {
173 rows[0].id = crate::tui::app::LaunchRowId::ReturnToSession;
174 rows[0].label = format!(
175 "{} Esc",
176 tr(app.ui_locale, MessageId::HomeBackToConversation)
177 );
178 }
179 rows
180 }
181
182 /// The card's rows for live `App` state, for keyboard navigation and Enter.
183 ///
184 /// The painted rows are the authority. Paint sheds the tail of the recent
185 /// list to fit a short pane and turns the overflow row on when it does, so a
186 /// list built here from scratch would let Up/Down land on — and Enter resume
187 /// — a session the screen is not showing. `row_hitboxes` is what the last
188 /// frame actually drew, in paint order, and `mouse_ui` indexes that same
189 /// list: one ordering for paint, mouse, and keyboard, with no second state.
190 #[must_use]
191 pub fn launch_rows_for_app(app: &App) -> Vec<LaunchCardRow> {
192 let (recent, _) = launch_recent_entries(app);
193 // An empty pane has no navigable rows, including before its first paint.
194 // A preserved multiline draft can legitimately leave no room for home.
195 let mut superset = home_card_rows(app, &recent, true);
196 // MCP rows join the same ordering only when the boot block painted them.
197 for id in [
198 crate::tui::app::LaunchRowId::McpManager,
199 crate::tui::app::LaunchRowId::McpRemedy,
200 ] {
201 superset.push(LaunchCardRow {
202 id,
203 label: String::new(),
204 detail: String::new(),
205 prominent: false,
206 });
207 }
208 app.launch
209 .row_hitboxes
210 .iter()
211 .filter_map(|(id, _)| superset.iter().find(|row| &row.id == id).cloned())
212 .collect()
213 }
214
215 /// Re-anchor the card's clickable rows on what `area` just painted.
216 ///
217 /// The frame renderer calls this instead of rebuilding hitboxes inline, so
218 /// the row list keyboard and mouse read back cannot describe a row the
219 /// transcript did not draw.
220 pub fn refresh_launch_row_hitboxes(app: &mut App, area: Rect) {
221 let state = launch_empty_state(app, area);
222 app.launch.row_hitboxes = state
223 .rows
224 .into_iter()
225 .filter_map(|(id, row)| {
226 let y = area.y.checked_add(u16::try_from(row).ok()?)?;
227 (y < area.y.saturating_add(area.height)).then_some((
228 id,
229 Rect::new(area.x + state.text_column.x, y, state.text_column.width, 1),
230 ))
231 })
232 .collect();
233 // A pane that shrank can leave the highlight past the last painted row.
234 // Clear it rather than clamping: clamping would silently move the
235 // selection onto a different session.
236 let painted = app.launch.row_hitboxes.len();
237 if app
238 .launch
239 .menu_selected
240 .is_some_and(|index| index >= painted)
241 {
242 app.launch.menu_selected = None;
243 }
244 if app.launch.hovered_row.is_some_and(|index| index >= painted) {
245 app.launch.hovered_row = None;
246 }
247 }
248
249 /// The click twin of [`run_launch_card_row`]: one card row id runs the
250 /// same action the keyboard's Enter runs, so mouse and keyboard share one
251 /// contract.
252 #[must_use]
253 pub fn launch_row_click_action(id: &crate::tui::app::LaunchRowId) -> LaunchAction {
254 match id {
255 crate::tui::app::LaunchRowId::NewSession => LaunchAction::NewSession,
256 crate::tui::app::LaunchRowId::ReturnToSession => LaunchAction::ReturnToSession,
257 crate::tui::app::LaunchRowId::Recent(session_id) => {
258 LaunchAction::ResumeSession(session_id.clone())
259 }
260 crate::tui::app::LaunchRowId::SeeAll => LaunchAction::BrowseSessions,
261 crate::tui::app::LaunchRowId::McpManager => LaunchAction::McpManager,
262 crate::tui::app::LaunchRowId::McpRemedy => LaunchAction::McpRemedy,
263 }
264 }
265
266 /// Ask before resuming: open the confirmation popup for `session_id`.
267 ///
268 /// Both the card's Enter and a click on a recent row route here. Resuming
269 /// replaces the whole session context, and the popup is where that is said —
270 /// an arming line over the composer read as chrome rather than as a question.
271 pub fn open_launch_resume_confirm(app: &mut App, session_id: &str) {
272 if app.view_stack.top_kind() == Some(crate::tui::views::ModalKind::LaunchResumeConfirm) {
273 return;
274 }
275 let entry = app
276 .launch
277 .recent
278 .iter()
279 .find(|entry| entry.id == session_id);
280 let title = entry
281 .map(|entry| entry.title.clone())
282 .unwrap_or_else(|| session_id.to_string());
283 let detail = entry
284 .map(|entry| {
285 let when =
286 crate::tui::session_picker::format_relative_time(&entry.updated_at, app.ui_locale);
287 format!(
288 "{when} · {}",
289 crate::tui::session_picker::format_message_count(
290 entry.message_count,
291 app.ui_locale
292 )
293 )
294 })
295 .unwrap_or_default();
296 app.view_stack.push(
297 crate::tui::launch_resume_confirm::LaunchResumeConfirmView::new(
298 session_id.to_string(),
299 title,
300 detail,
301 app.ui_locale,
302 ),
303 );
304 app.needs_redraw = true;
305 }
306 /// Run the card's highlighted row. Enter on the card is the list's runner;
307 /// an untouched list runs nothing.
308 pub fn run_launch_card_row(rows: &[LaunchCardRow], menu_selected: Option<usize>) -> LaunchAction {
309 let Some(selected) = menu_selected else {
310 return LaunchAction::None;
311 };
312 match rows.get(selected) {
313 None => LaunchAction::None,
314 Some(row) => launch_row_click_action(&row.id),
315 }
316 }
317
318 /// What the pre-session composer layer decided about one key.
319 ///
320 /// This is only an admission guard, never an input implementation: the
321 /// startup composer is the session's own [`crate::tui::app::ComposerState`],
322 /// and every editing key is answered by the conversation composer match in
323 /// the event loop — the single composer input authority — exactly as it
324 /// would be in a live session. Word motion, selection, completion menus,
325 /// attachments, history, paste bursts, and vim behaviour therefore cannot
326 /// drift from the shell. Only three things are launch-specific here: an
327 /// empty Enter, F1 help, and submitting.
328 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
329 pub enum LaunchComposerKey {
330 /// The key is fully consumed and does nothing more (Enter on an empty
331 /// composer with no menu entry highlighted: there is no row to run and
332 /// nothing to send; Esc clearing the menu highlight or bringing the
333 /// card back).
334 Consumed,
335 /// Submit the composed message through the normal dispatch path.
336 Submit,
337 /// A completion-menu selection was applied (a slash or mention popup was
338 /// open and Enter picked the highlighted entry); the key is consumed
339 /// without submitting — the completed text stays in the composer.
340 MenuSelect,
341 /// The launch chord (F1 help): the same key is then handed to
342 /// [`handle_launch_key`]. It deliberately wins over its composer
343 /// meaning while the launch screen is up.
344 MenuChord,
345 /// Not launch-specific: the conversation composer match below owns the
346 /// key. The event loop must not run [`handle_launch_key`] for it.
347 ComposerAuthority,
348 /// Move the launch card's row selection (Up/Down while the card is up).
349 MenuNavigate(i32),
350 /// Run the card's highlighted row. Revisited home retains its draft;
351 /// on startup, only an empty composer yields Enter to the card.
352 MenuRun,
353 }
354
355 /// Admit one key for the pre-session composer.
356 ///
357 /// Editing keys are never handled here — they fall through to the
358 /// conversation composer match so there is exactly one composer input
359 /// system. Only F1 help stays launch-owned via
360 /// [`LaunchComposerKey::MenuChord`].
361 pub fn handle_launch_composer_key(app: &mut App, key: KeyEvent) -> LaunchComposerKey {
362 if app.launch.return_to_session && key.code == KeyCode::Esc {
363 app.launch.dismiss();
364 return LaunchComposerKey::Consumed;
365 }
366 let multiline = app.composer_multiline_mode;
367 let card_up = app.launch.dissolve_started_ms.is_none();
368 match key.code {
369 KeyCode::Enter
370 if crate::tui::composer_ui::composer_submit_chord(key, multiline).is_some() =>
371 {
372 // Explicit home navigation takes precedence over a preserved draft.
373 // Only painted rows may own Enter, just as with mouse activation.
374 if app.launch.return_to_session
375 && card_up
376 && app
377 .launch
378 .menu_selected
379 .is_some_and(|index| index < app.launch.row_hitboxes.len())
380 {
381 return LaunchComposerKey::MenuRun;
382 }
383 // #573 parity with the session composer's Enter arm: when a
384 // completion popup is matching (e.g. `/mo` → `/model`), Enter
385 // applies the highlighted entry instead of sending the literal
386 // prefix. A mention completion amends the composed text and is
387 // consumed; a slash completion completes the command and falls
388 // through to Submit so the launch dispatch path executes it.
389 let mention_entries = crate::tui::file_mention::visible_mention_menu_entries(app, 1);
390 if !mention_entries.is_empty()
391 && crate::tui::file_mention::apply_mention_menu_selection(app, &mention_entries)
392 {
393 return LaunchComposerKey::MenuSelect;
394 }
395 let slash_entries = crate::tui::slash_menu::visible_slash_menu_entries(app, 1);
396 if !slash_entries.is_empty() {
397 crate::tui::slash_menu::apply_slash_menu_selection(app, &slash_entries, false);
398 app.close_slash_menu();
399 }
400 if app.input.trim().is_empty() {
401 if card_up && app.launch.menu_selected.is_some() {
402 // The card owns Enter only once the user has arrowed
403 // onto a row; an untouched list runs nothing.
404 return LaunchComposerKey::MenuRun;
405 }
406 LaunchComposerKey::Consumed
407 } else {
408 app.launch.dissolve_card(app.ambient_clock_ms);
409 LaunchComposerKey::Submit
410 }
411 }
412 KeyCode::Up if card_up => LaunchComposerKey::MenuNavigate(-1),
413 KeyCode::Down if card_up => LaunchComposerKey::MenuNavigate(1),
414 // Esc walks back one step: a highlighted row is unhighlighted;
415 // an empty composer with the card gone brings the card back. A draft
416 // in the composer keeps Esc's composer meaning.
417 KeyCode::Esc if card_up && app.launch.menu_selected.is_some() => {
418 app.launch.menu_selected = None;
419 LaunchComposerKey::Consumed
420 }
421 KeyCode::Esc if !card_up && app.input.is_empty() => {
422 app.launch.restore_card();
423 LaunchComposerKey::Consumed
424 }
425 KeyCode::F(1) => LaunchComposerKey::MenuChord,
426 // Every other key — text, caret motion, word motion, selection,
427 // newline chords, Home/End, kill/chord editing, vim motions, Esc,
428 // Tab, history — is answered by the conversation composer authority.
429 _ => {
430 // Typing goes straight to the composer, and the first keystroke
431 // dissolves the card (founder decision, 2026-09-02).
432 if card_up
433 && matches!(key.code, KeyCode::Char(_))
434 && !key
435 .modifiers
436 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
437 {
438 if app.launch.return_to_session {
439 app.launch.dismiss();
440 } else {
441 app.launch.dissolve_card(app.ambient_clock_ms);
442 }
443 }
444 LaunchComposerKey::ComposerAuthority
445 }
446 }
447 }
448
449 impl ShellTier {
450 // `for_area` (the two-dimensional variant) went with the empty state's
451 // tier branch: the idle caption sheds detail continuously now, so nothing
452 // was left that wanted a coarse three-way answer about a whole Rect. The
453 // row and column floors it encoded still exist, spelled out as
454 // `AMBIENT_MIN_CHAT_HEIGHT` / `AMBIENT_MIN_CHAT_WIDTH` where the layout
455 // can honour them.
456 #[must_use]
457 pub fn for_chrome_width(width: u16) -> Self {
458 if width < 60 {
459 Self::Compact
460 } else if width < 110 {
461 Self::Normal
462 } else {
463 Self::Wide
464 }
465 }
466 }
467
468 /// Perceptual session phase. Every treatment reads from this same enum so a
469 /// footer cannot say `idle` while the transcript is asking for approval.
470 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
471 pub enum ShellPhase {
472 Idle,
473 Typing,
474 Working,
475 /// A live verification pass (tests/checks/lints). Same clock family as
476 /// `Working` but rendered as the metered braille tick — checking, not
477 /// searching (ocean state model).
478 Verifying,
479 Waiting,
480 Approval,
481 Done,
482 Failed,
483 }
484
485 /// The one truthful verb shown while a turn is live. This deliberately stays
486 /// smaller than the tool taxonomy: the phase strip only needs to distinguish
487 /// hidden reasoning, read-shaped exploration, other tool use, verification,
488 /// and generic model work. It never exposes reasoning text or tool arguments.
489 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
490 pub(crate) enum LiveActivityKind {
491 Working,
492 Compacting,
493 AutoCompacting,
494 Reasoning,
495 Reading,
496 UsingTool,
497 UsingSubagents,
498 Verifying,
499 }
500
501 /// Bounded projection of live turn activity. Completed entries are ignored,
502 /// so an `ActiveCell` retained until `TurnComplete` cannot keep the shell in a
503 /// false working state.
504 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
505 pub(crate) struct LiveActivity {
506 kind: LiveActivityKind,
507 running_tools: usize,
508 }
509
510 impl LiveActivity {
511 #[must_use]
512 pub(crate) fn from_app(app: &App) -> Self {
513 let tools = running_tool_facts(app);
514 let kind = if app
515 .active_compaction
516 .as_ref()
517 .is_some_and(|compaction| compaction.auto)
518 {
519 LiveActivityKind::AutoCompacting
520 } else if app.active_compaction.is_some() {
521 LiveActivityKind::Compacting
522 } else if tools.verifying {
523 LiveActivityKind::Verifying
524 } else if app_has_unfinished_subagents(app) {
525 LiveActivityKind::UsingSubagents
526 } else if tools.count > 0 && tools.all_reading {
527 LiveActivityKind::Reading
528 } else if tools.count > 0 {
529 LiveActivityKind::UsingTool
530 } else if app.streaming_thinking_active_entry.is_some() {
531 LiveActivityKind::Reasoning
532 } else {
533 LiveActivityKind::Working
534 };
535 Self {
536 kind,
537 running_tools: tools.count,
538 }
539 }
540
541 #[must_use]
542 pub(crate) fn kind(self) -> LiveActivityKind {
543 self.kind
544 }
545
546 #[must_use]
547 fn is_explicit(self) -> bool {
548 !matches!(self.kind, LiveActivityKind::Working)
549 }
550
551 #[must_use]
552 fn label(self, locale: Locale) -> Cow<'static, str> {
553 match self.kind {
554 LiveActivityKind::Working => tr(locale, MessageId::PhaseWorking),
555 LiveActivityKind::Compacting => tr(locale, MessageId::ContextManualCompacting),
556 LiveActivityKind::AutoCompacting => tr(locale, MessageId::ContextAutoCompacting),
557 LiveActivityKind::Reasoning => tr(locale, MessageId::PhaseReasoning),
558 LiveActivityKind::Reading => tr(locale, MessageId::PhaseReading),
559 LiveActivityKind::UsingTool => tr(locale, MessageId::PhaseUsingTool),
560 LiveActivityKind::UsingSubagents => tr(locale, MessageId::PhaseSubagents),
561 LiveActivityKind::Verifying => tr(locale, MessageId::PhaseVerifying),
562 }
563 }
564 }
565
566 #[derive(Debug, Clone, Copy)]
567 struct RunningToolFacts {
568 count: usize,
569 all_reading: bool,
570 verifying: bool,
571 }
572
573 /// True when any sub-agent spawned by this session is still running: live
574 /// progress rows win over the cache, whose Running entries are the persisted
575 /// view of the same actors.
576 fn app_has_unfinished_subagents(app: &App) -> bool {
577 !app.agent_progress.is_empty()
578 || app.subagent_cache.iter().any(|agent| {
579 matches!(
580 agent.status,
581 crate::tools::subagent::SubAgentStatus::Running
582 )
583 })
584 }
585
586 impl Default for RunningToolFacts {
587 fn default() -> Self {
588 Self {
589 count: 0,
590 all_reading: true,
591 verifying: false,
592 }
593 }
594 }
595
596 impl RunningToolFacts {
597 fn observe(&mut self, reading: bool, verifying: bool) {
598 self.count = self.count.saturating_add(1);
599 self.all_reading &= reading;
600 self.verifying |= verifying;
601 }
602 }
603
604 const WORKING_BUBBLE_FRAMES: [&str; 8] = ["⠀", "⢀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣿"];
605 const COMPLETION_RELEASE_MS: u128 = 560;
606 // The idle whale portrait rows (IDLE_WHALE_ROWS / UWU_IDLE_WHALE_ROWS) and
607 // their caustic shimmer were deleted per the 2026-08-29 founder directive:
608 // hand-drawn whale art is out; the only sanctioned terminal mark is the one
609 // generated from the brand master path. The ambient empty-state surface
610 // (wordmark, context caption, prompt) below is not whale art and stays.
611
612 impl ShellPhase {
613 #[must_use]
614 pub fn from_app(app: &App) -> Self {
615 Self::from_app_with_activity(app, LiveActivity::from_app(app))
616 }
617
618 #[must_use]
619 pub(crate) fn from_app_with_activity(app: &App, activity: LiveActivity) -> Self {
620 if matches!(
621 app.view_stack.top_kind(),
622 Some(ModalKind::Approval | ModalKind::Elevation | ModalKind::UserInput)
623 ) {
624 return Self::Approval;
625 }
626 if matches!(
627 activity.kind(),
628 LiveActivityKind::Compacting | LiveActivityKind::AutoCompacting
629 ) {
630 // A typed CompactionStarted event is newer and more specific than
631 // a prior turn's failed projection. Keep the recovery operation
632 // visible until its matching terminal event arrives.
633 return Self::Working;
634 }
635 if app.turn_error_posted
636 || matches!(app.runtime_turn_status.as_deref(), Some("failed" | "error"))
637 {
638 return Self::Failed;
639 }
640 if app.pending_user_input_prompt.is_some()
641 || app
642 .task_panel
643 .iter()
644 .any(|task| matches!(task.status.as_str(), "waiting" | "needs_user"))
645 {
646 return Self::Waiting;
647 }
648 if app.is_loading
649 || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
650 || activity.is_explicit()
651 {
652 if activity.kind() == LiveActivityKind::Verifying {
653 return Self::Verifying;
654 }
655 return Self::Working;
656 }
657 if !app.input.is_empty() {
658 return Self::Typing;
659 }
660 if matches!(app.runtime_turn_status.as_deref(), Some("completed")) {
661 return Self::Done;
662 }
663 Self::Idle
664 }
665
666 #[must_use]
667 pub fn label(self, locale: Locale) -> Cow<'static, str> {
668 match self {
669 Self::Idle => tr(locale, MessageId::PhaseIdle),
670 Self::Typing => tr(locale, MessageId::PhaseDraft),
671 Self::Working => tr(locale, MessageId::PhaseWorking),
672 Self::Verifying => tr(locale, MessageId::PhaseVerifying),
673 Self::Waiting | Self::Approval => tr(locale, MessageId::PhaseWaitingOnYou),
674 Self::Done => tr(locale, MessageId::PhaseDone),
675 Self::Failed => tr(locale, MessageId::PhaseFailed),
676 }
677 }
678 }
679
680 /// Exhaustive on purpose: a new [`AppMode`] must be handed a Policy ink
681 /// deliberately rather than inheriting act's by falling through a wildcard.
682 fn header_mode_ink(mode: AppMode) -> ChromeInk {
683 match mode {
684 AppMode::Plan => ChromeInk::PolicyPlan,
685 AppMode::Operate => ChromeInk::PolicyOperate,
686 AppMode::Agent => ChromeInk::PolicyAct,
687 }
688 }
689
690 fn header_permission_ink(mode: ApprovalMode) -> ChromeInk {
691 match mode {
692 ApprovalMode::Suggest | ApprovalMode::Never => ChromeInk::PermissionAsk,
693 ApprovalMode::Auto => ChromeInk::PermissionAutoReview,
694 ApprovalMode::Bypass => ChromeInk::PermissionFullAccess,
695 }
696 }
697
698 /// One posture word with its ink — the unit the classic header's lockup was
699 /// made of, now carried as merged-footer chips.
700 pub(crate) type PostureChip = (Cow<'static, str>, ChromeInk);
701
702 /// The posture lockup as two standalone chips for the Tideline merged
703 /// footer (spec §3: the old header's mode/permission chips move into the
704 /// footer activity segment). Same words, same inks, and the same mapping
705 /// the classic header used — [`header_mode_ink`] for the mode word,
706 /// [`header_permission_ink`] for the permission phrase. The filesystem
707 /// scope notice, when it deviates, folds into the permission chip's text
708 /// (the header already painted it in the permission ink).
709 pub(crate) fn posture_chips(app: &App) -> (Option<PostureChip>, Option<PostureChip>) {
710 let mode = (
711 mode_label(app.ui_locale, app.mode),
712 header_mode_ink(app.mode),
713 );
714 let mut permission = (
715 permission_label(app),
716 header_permission_ink(app.approval_mode),
717 );
718 if let Some(scope) = filesystem_scope_notice(app) {
719 permission.0 = format!("{} · {scope}", permission.0).into();
720 }
721 (Some(mode), Some(permission))
722 }
723
724 /// Summarize only tools whose lifecycle is actually `Running`. A read label
725 /// is earned only when every running entry is read/exploration-shaped; mixed
726 /// work stays the neutral `using tool`. Verification wins because it is the
727 /// existing stronger promise made by the phase strip.
728 fn running_tool_facts(app: &App) -> RunningToolFacts {
729 use crate::tui::history::{HistoryCell, ToolCell, ToolStatus};
730 use crate::tui::widgets::tool_card::{ToolFamily, tool_family_for_name};
731
732 let mut facts = RunningToolFacts::default();
733 let Some(active) = app.active_cell.as_ref() else {
734 return facts;
735 };
736 for cell in active.entries() {
737 let HistoryCell::Tool(tool) = cell else {
738 continue;
739 };
740 match tool {
741 ToolCell::Exec(exec) if exec.status == ToolStatus::Running => {
742 facts.observe(false, exec_is_verification(&exec.command));
743 }
744 ToolCell::Generic(generic) if generic.status == ToolStatus::Running => {
745 let family = tool_family_for_name(&generic.name);
746 facts.observe(
747 matches!(family, ToolFamily::Read | ToolFamily::Find),
748 family == ToolFamily::Verify || generic.name == "read_lints",
749 );
750 }
751 ToolCell::Exploring(exploring) => {
752 for entry in &exploring.entries {
753 if entry.status == ToolStatus::Running {
754 facts.observe(true, false);
755 }
756 }
757 }
758 ToolCell::WebSearch(search) if search.status == ToolStatus::Running => {
759 facts.observe(true, false);
760 }
761 other if other.status() == Some(ToolStatus::Running) => {
762 facts.observe(false, false);
763 }
764 _ => {}
765 }
766 }
767 facts
768 }
769
770 fn exec_is_verification(command: &str) -> bool {
771 let trimmed = command.trim_start();
772 let mut tokens = trimmed.split_whitespace();
773 let first = tokens.next().unwrap_or("");
774 let second = tokens.next().unwrap_or("");
775 match first {
776 "cargo" => matches!(second, "test" | "check" | "clippy" | "nextest"),
777 "go" => matches!(second, "test" | "vet"),
778 "npm" | "pnpm" | "yarn" | "bun" => matches!(second, "test" | "lint" | "check"),
779 "make" => matches!(second, "test" | "check" | "lint"),
780 "python" | "python3" => trimmed.contains("-m pytest") || trimmed.contains("-m unittest"),
781 "pytest" | "jest" | "vitest" | "tsc" | "eslint" | "ruff" | "mypy" | "clippy-driver"
782 | "golangci-lint" | "shellcheck" => true,
783 _ => false,
784 }
785 }
786
787 fn completion_elapsed_ms(app: &App) -> Option<u128> {
788 if !app.motion_policy().allows_decorative() {
789 return None;
790 }
791 app.ocean_completion_started_at
792 .map(|started| started.elapsed().as_millis())
793 .filter(|elapsed| *elapsed < COMPLETION_BREATH_MS)
794 }
795
796 /// Truthful window-title activity verb for the OSC-0 whale animation.
797 ///
798 /// Uses short English fragments (with fixed-width ellipsis) so alt-tabbed
799 /// sessions stay legible without depending on the full localized phase strip.
800 #[must_use]
801 pub(crate) fn title_activity_verb(app: &App) -> &'static str {
802 let activity = LiveActivity::from_app(app);
803 let phase = ShellPhase::from_app_with_activity(app, activity);
804 match phase {
805 ShellPhase::Waiting | ShellPhase::Approval => "waiting on you…",
806 ShellPhase::Verifying => "verifying…",
807 ShellPhase::Done => "done",
808 ShellPhase::Failed => "failed",
809 ShellPhase::Typing => "drafting…",
810 ShellPhase::Idle => "idle",
811 ShellPhase::Working => match activity.kind() {
812 LiveActivityKind::Compacting | LiveActivityKind::AutoCompacting => {
813 "compacting context…"
814 }
815 LiveActivityKind::Reasoning => "reasoning…",
816 LiveActivityKind::Reading => "reading…",
817 LiveActivityKind::UsingTool => "using tool…",
818 LiveActivityKind::UsingSubagents => "fleet underway…",
819 LiveActivityKind::Verifying => "verifying…",
820 LiveActivityKind::Working => "working…",
821 },
822 }
823 }
824
825 /// Push the current shell phase into the terminal title whale animation.
826 pub(crate) fn sync_title_activity(app: &App) {
827 crate::tui::notifications::set_title_motion_enabled(
828 app.motion_policy().allows_decorative() && app.status_indicator != "off",
829 );
830 // Keep the `[title] …` window-title prefix in step with the session and
831 // config defaults; change detection inside makes this free when nothing
832 // moved.
833 crate::tui::notifications::set_title_prefix(app.window_title_prefix());
834 if app.is_loading
835 || matches!(
836 ShellPhase::from_app(app),
837 ShellPhase::Working
838 | ShellPhase::Verifying
839 | ShellPhase::Waiting
840 | ShellPhase::Approval
841 | ShellPhase::Typing
842 )
843 {
844 crate::tui::notifications::set_title_activity_verb(title_activity_verb(app));
845 }
846 }
847
848 pub(crate) fn phase_marker_with_activity(
849 app: &App,
850 phase: ShellPhase,
851 activity: LiveActivity,
852 ) -> (&'static str, Cow<'static, str>) {
853 let locale = app.ui_locale;
854 match phase {
855 ShellPhase::Idle => ("·", phase.label(locale)),
856 ShellPhase::Typing => ("›", phase.label(locale)),
857 ShellPhase::Working => {
858 // The footer and the live tool card share one wall-clock cadence,
859 // so the two primary liveness marks never look like unrelated
860 // spinners. The shared helper also preserves the 400ms
861 // "motion is earned" delay and reduced/still fallback.
862 let policy = app.motion_policy();
863 let animated = crate::tui::spinner::braille_spinner_frame(app.turn_started_at, false);
864 let earned = app.turn_started_at.is_none_or(|started| {
865 started.elapsed().as_millis()
866 >= u128::from(crate::tui::spinner::LIVE_MARKER_DELAY_MS)
867 });
868 let frame = policy.spinner_glyph(animated, earned);
869 (frame, activity.label(locale))
870 }
871 ShellPhase::Verifying => {
872 // Metered braille tick on the shared live clock — checking, not
873 // searching. Reduced motion holds the legible mid frame.
874 let policy = app.motion_policy();
875 let animated = crate::tui::spinner::verification_tick_frame(app.turn_started_at, false);
876 let earned = app.turn_started_at.is_none_or(|started| {
877 started.elapsed().as_millis()
878 >= u128::from(crate::tui::spinner::LIVE_MARKER_DELAY_MS)
879 });
880 let frame = policy.spinner_glyph(animated, earned);
881 (frame, phase.label(locale))
882 }
883 ShellPhase::Waiting | ShellPhase::Approval => ("◆", phase.label(locale)),
884 ShellPhase::Done => match completion_elapsed_ms(app) {
885 Some(elapsed) if elapsed < COMPLETION_RELEASE_MS => {
886 let index = ((elapsed / 140) as usize + 4).min(WORKING_BUBBLE_FRAMES.len() - 1);
887 (WORKING_BUBBLE_FRAMES[index], phase.label(locale))
888 }
889 _ => (crate::tui::glyphs::DONE, phase.label(locale)),
890 },
891 ShellPhase::Failed => (crate::tui::glyphs::FAILED, phase.label(locale)),
892 }
893 }
894
895 fn mode_label(locale: Locale, mode: AppMode) -> Cow<'static, str> {
896 match mode {
897 AppMode::Agent => tr(locale, MessageId::ChipModeAct),
898 AppMode::Plan => tr(locale, MessageId::ChipModePlan),
899 AppMode::Operate => tr(locale, MessageId::ChipModeOperate),
900 }
901 }
902
903 /// Permission chip words. This maps from the typed [`ApprovalMode`] state —
904 /// never from the English `permission_chip_label()` strings — so localizing
905 /// (or rewording) the upstream chip labels can never silently break the chip.
906 ///
907 /// Tool-approval posture only. Filesystem scope is a separate fact and only
908 /// earns header columns when it is worth reading — see
909 /// [`filesystem_scope_notice`].
910 fn permission_label(app: &App) -> Cow<'static, str> {
911 let locale = app.ui_locale;
912 if app.mode == AppMode::Plan {
913 return tr(locale, MessageId::ChipPermissionReadOnly);
914 }
915 match app.approval_mode {
916 ApprovalMode::Suggest => tr(locale, MessageId::ChipPermissionAsk),
917 ApprovalMode::Auto => tr(locale, MessageId::ChipPermissionAuto),
918 // Keep the effective permission explicit. `bypass` is an
919 // implementation detail and, more importantly, can imply that
920 // repository law no longer applies. Full Access never bypasses
921 // constitution rules. This is **tool-approval posture**, not
922 // filesystem scope — see filesystem_scope_notice.
923 ApprovalMode::Bypass => tr(locale, MessageId::ChipPermissionFullAccess),
924 ApprovalMode::Never => tr(locale, MessageId::ChipPermissionNever),
925 }
926 }
927
928 /// The effective filesystem scope — but only when it says something the
929 /// permission word beside it does not already say.
930 ///
931 /// This chip exists because "Full Access" (tool approval) was being read as
932 /// unrestricted disk writes (user report, 2026-07-23), and because a policy
933 /// with no enforcement backend used to name a boundary nobody applied
934 /// (2026-08-04 audit). Both of those are deviations. The default — an
935 /// enforced workspace-write boundary — is what every ordinary session already
936 /// has, and printing `files: workspace` on every frame of every session spent
937 /// seventeen columns of the primary chrome saying so. A notice that is always
938 /// on cannot signal anything; folding the expected case away is what lets
939 /// `files: workspace (unenforced)` and the Full-Access-but-confined case land
940 /// as warnings when they do appear.
941 ///
942 /// `read-only` under Plan is dropped for the same reason from the other side:
943 /// the permission word there is already the literal phrase "read only".
944 #[must_use]
945 fn filesystem_scope_notice(app: &App) -> Option<Cow<'static, str>> {
946 // Spelled out because the old `fs:` prefix read as an unexplained
947 // acronym (user report, 2026-07-23): this chip states which files the
948 // session may write.
949 let policy = crate::core::authority::sandbox_policy_for_turn(
950 app.mode,
951 app.approval_mode,
952 app.configured_sandbox_mode.as_deref(),
953 &app.workspace,
954 crate::core::authority::SandboxNetworkAccess::from_config(app.configured_sandbox_network),
955 );
956 // A policy is an intent; enforcement needs a backend. On default Linux
957 // (bubblewrap is opt-in) and on all Windows there is none. Say
958 // "unenforced" rather than name a boundary that is not applied.
959 // `DangerFullAccess` is already honest, and `ExternalSandbox` is enforced
960 // by the external runner, not by us.
961 let unenforced = app.sandbox_backend.is_none()
962 && !matches!(
963 policy,
964 crate::sandbox::SandboxPolicy::DangerFullAccess
965 | crate::sandbox::SandboxPolicy::ExternalSandbox { .. }
966 );
967 match policy {
968 crate::sandbox::SandboxPolicy::ReadOnly if unenforced => {
969 Some(Cow::Borrowed("files: read-only (unenforced)"))
970 }
971 crate::sandbox::SandboxPolicy::ReadOnly => {
972 (app.mode != AppMode::Plan).then_some(Cow::Borrowed("files: read-only"))
973 }
974 // `DangerFullAccess` only ever arises from the Bypass posture
975 // (`sandbox_policy_for_turn`), whose permission chip already reads
976 // "Full Access" two words to the left. The name is the disclosure;
977 // restating it as `files: full disk` spent columns saying it twice.
978 // The scope chip speaks in this posture only when the scope is
979 // *narrower* than the name implies (the WorkspaceWrite arm below).
980 crate::sandbox::SandboxPolicy::DangerFullAccess => None,
981 crate::sandbox::SandboxPolicy::ExternalSandbox { .. } => {
982 Some(Cow::Borrowed("files: external sandbox"))
983 }
984 crate::sandbox::SandboxPolicy::WorkspaceWrite { .. } if unenforced => {
985 Some(Cow::Borrowed("files: workspace (unenforced)"))
986 }
987 // The unremarkable case: writes are confined to the workspace and the
988 // OS is actually enforcing it. Saying so on every frame of every
989 // session spends the header on a fact nobody is asking about — with
990 // one exception. When the permission chip reads "Full Access", the
991 // scope chip is the only thing on screen that says the writes are
992 // still confined. Suppressing it there recreates precisely the
993 // misreading the chip was added for (tool-approval "Full Access" taken
994 // to mean unrestricted disk writes), and that pairing is reachable:
995 // Bypass with a configured `workspace-write` is clamped to this policy
996 // by `sandbox_policy_for_turn`.
997 crate::sandbox::SandboxPolicy::WorkspaceWrite { .. } => {
998 (app.approval_mode == ApprovalMode::Bypass).then_some(Cow::Borrowed("files: workspace"))
999 }
1000 }
1001 }
1002
1003 fn truncate_to_width(text: &str, width: usize) -> String {
1004 if text.width() <= width {
1005 return text.to_string();
1006 }
1007 if width == 0 {
1008 return String::new();
1009 }
1010 if width <= 3 {
1011 return ".".repeat(width);
1012 }
1013 let mut result = String::new();
1014 let mut used = 0;
1015 for ch in text.chars() {
1016 let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
1017 if used + ch_width + 1 > width {
1018 break;
1019 }
1020 result.push(ch);
1021 used += ch_width;
1022 }
1023 result.push('…');
1024 result
1025 }
1026
1027 /// The transcript rows the idle brand mark needs before it will draw at all.
1028 ///
1029 /// Named so the *layout* can honour it before the frame is split. Anything that reserves rows above
1030 /// the transcript must subtract against this constant rather than guess, or
1031 /// the reservation and the render gate drift and the mark is evicted by
1032 /// chrome that was sized without knowing the mark existed.
1033 pub(crate) const AMBIENT_MIN_CHAT_HEIGHT: u16 = 16;
1034 /// Companion column floor, same reasoning as [`AMBIENT_MIN_CHAT_HEIGHT`].
1035 pub(crate) const AMBIENT_MIN_CHAT_WIDTH: u16 = 60;
1036
1037 /// Build the post-launch idle composition: brand, workspace context, and one
1038 /// direct invitation. Commands stay in the command surface instead of reading
1039 /// like onboarding homework.
1040 ///
1041 /// Expressed in terms of the ambient floor constants so the layout rule that
1042 /// reserves the rows and the gate that spends them cannot disagree. (The old
1043 /// spelling also tested `height >= 14 && width >= 28`, which was dead: the
1044 /// tier check already demands 16 rows and 60 columns.)
1045 #[must_use]
1046 pub(crate) fn empty_state_mark_visible(area: Rect) -> bool {
1047 area.height >= AMBIENT_MIN_CHAT_HEIGHT && area.width >= AMBIENT_MIN_CHAT_WIDTH
1048 }
1049
1050 #[must_use]
1051 pub(crate) fn decorative_shell_motion_enabled(app: &App) -> bool {
1052 app.motion_policy().allows_decorative()
1053 && !app.attention_hold_active()
1054 && app.onboarding == OnboardingState::None
1055 && !app.launch.visible
1056 && app.view_stack.is_empty()
1057 }
1058
1059 /// Shorten a workspace path to its trailing components, marked with a leading
1060 /// ellipsis so it reads as "somewhere above here" rather than as a real path.
1061 fn shorten_workspace(workspace: &str, keep: usize) -> String {
1062 let sep = if workspace.contains('/') { '/' } else { '\\' };
1063 let parts: Vec<&str> = workspace.split(sep).filter(|p| !p.is_empty()).collect();
1064 if parts.len() <= keep {
1065 return workspace.to_string();
1066 }
1067 let tail = parts[parts.len() - keep..].join(&sep.to_string());
1068 let shortened = format!("…{sep}{tail}");
1069 // Only elide when it actually buys width. `~/code/app` -> `…/code/app` is
1070 // the same length and throws away the `~`, which carries more meaning than
1071 // the ellipsis does.
1072 if shortened.width() >= workspace.width() {
1073 return workspace.to_string();
1074 }
1075 shortened
1076 }
1077
1078 /// Compose the empty-state caption so the caller's centering can survive.
1079 ///
1080 /// This line sits between the wordmark and "What do you want to accomplish?",
1081 /// and every other element of that block is centered. It used to be built at
1082 /// full length and then handed to `truncate_to_width(.., width)`, which made it
1083 /// exactly `width` wide — so the caller's `(width - context.width()) / 2` inset
1084 /// evaluated to zero and the caption rendered flush-left, full-bleed, cutting
1085 /// the composition in half. The clipping also destroyed the information: an
1086 /// absolute path truncated mid-directory ("…/34267917-11f4-4d15-911a-…") tells
1087 /// the reader nothing about where they are.
1088 ///
1089 /// So the caption sheds detail rather than getting cut. In order of what goes
1090 /// first: the MCP count, then the branch, then the leading path components. The
1091 /// folder you are in is the last thing to go, because it is the only part a
1092 /// person actually reads here.
1093 ///
1094 /// One rule was added after watching it at 120 columns: the margin is
1095 /// proportional, not a flat four. A flat four let a 114-column path "fit" a
1096 /// 119-column lane, which put the centring inset back at two and reproduced
1097 /// the full-bleed banner this function exists to prevent — the same failure,
1098 /// arrived at from the other direction. A sixth of the lane, split either
1099 /// side, means the caption is always visibly a caption.
1100 fn empty_state_caption(
1101 workspace: &str,
1102 branch: &str,
1103 mcp_label: &str,
1104 mcp_count: usize,
1105 width: usize,
1106 ) -> String {
1107 // Leave a margin so the line is visibly inset rather than merely fitting,
1108 // and scale it, because "four columns" is only a margin at 60 columns.
1109 let budget = width.saturating_sub((width / 6).max(4)).max(8);
1110 let candidates = [
1111 format!("{workspace} · {branch} · {mcp_label} {mcp_count}"),
1112 format!("{workspace} · {branch}"),
1113 workspace.to_string(),
1114 format!("{} · {branch}", shorten_workspace(workspace, 2)),
1115 shorten_workspace(workspace, 2),
1116 shorten_workspace(workspace, 1),
1117 ];
1118 for candidate in &candidates {
1119 if candidate.width() <= budget {
1120 return candidate.clone();
1121 }
1122 }
1123 // Nothing fit: the last resort is the folder name alone, and the caller
1124 // still clamps. Better a bare name than a path clipped mid-component.
1125 shorten_workspace(workspace, 1)
1126 }
1127
1128 /// The launch card as the idle transcript's own content, plus where its
1129 /// clickable rows landed.
1130 ///
1131 /// The opening screen used to be a second surface: its own layout, its own
1132 /// composer widget, its own input authority. Founder ruling: "we don't have
1133 /// to have a different look for the opening screen ... we can make it an
1134 /// asset that exists there instead". So it is drawn as the empty state of the
1135 /// ordinary transcript — the ocean, the water and the chrome underneath it are
1136 /// the ones every other screen already uses, and the composer below it is the
1137 /// real one.
1138 pub struct LaunchEmptyState {
1139 pub lines: Vec<Line<'static>>,
1140 /// Text lane relative to the paint area. Outer whitespace is not a
1141 /// control; selection and pointer targets share this lane.
1142 text_column: Rect,
1143 /// Clickable rows as `(id, row index within `lines`)`. The caller turns
1144 /// these into rects against the painted area, so hitboxes and glyphs
1145 /// cannot drift apart.
1146 pub rows: Vec<(crate::tui::app::LaunchRowId, usize)>,
1147 }
1148
1149 /// Minimum left indent. Wider terminals balance the bounded reading lane
1150 /// inside the transcript rather than leaving it stranded against one edge.
1151 const LAUNCH_BLOCK_INDENT: usize = 2;
1152 /// The card's reading measure: a row is a title with its detail set against
1153 /// it, and without a ceiling the detail right-aligns against the terminal's
1154 /// far edge. The title is primary; the relative age is secondary.
1155 const LAUNCH_CARD_MEASURE: usize = 72;
1156 /// Gap between a row's title and its right-aligned detail.
1157 const LAUNCH_ROW_GAP: usize = 3;
1158 /// Below this the row spends its whole lane on the title and sheds the detail.
1159 const LAUNCH_ROW_MIN_TITLE: usize = 28;
1160 /// Labels align with their heading; the action cue has its own gutter.
1161 /// Blank rows the card spends on rhythm when the pane is tall enough.
1162 const LAUNCH_SEPARATORS: usize = 3;
1163 /// Blank rows per separator when the pane can afford them.
1164 const LAUNCH_GAP_ROOMY: usize = 2;
1165 /// Below this width the block gives up its left indent.
1166 const LAUNCH_INDENT_MIN_WIDTH: usize = 12;
1167
1168 pub fn empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
1169 if area.width == 0 || area.height == 0 {
1170 return Vec::new();
1171 }
1172 // The opening screen is this screen: the launch card is the idle
1173 // transcript's own content, not a second surface painted over it.
1174 if app.launch.visible {
1175 // The first keystroke starts the dissolve clock; the card sinks by
1176 // ink, not by position — every span eases toward the water behind it
1177 // over `LAUNCH_CARD_DISSOLVE_MS`, and at the end the ambient surface
1178 // (wordmark, caption, prompt) is what remains. Reduced motion takes
1179 // the endpoint at once.
1180 let motion_allowed = app.motion_policy().allows_decorative() && !app.low_motion;
1181 let dissolve = app
1182 .launch
1183 .card_dissolve_progress(app.ambient_clock_ms, motion_allowed);
1184 if dissolve < 1.0 {
1185 let mut state = launch_empty_state(app, area);
1186 if dissolve > 0.0 {
1187 fade_lines(&mut state.lines, dissolve, app.ui_theme.surface_bg);
1188 }
1189 return state.lines;
1190 }
1191 }
1192 let width = usize::from(area.width);
1193 let mut lines = vec![Line::from(""); usize::from(area.height / 4)];
1194 // The idle whale portrait that used to open this block was deleted per
1195 // the 2026-08-29 founder directive; the ambient empty-state surface
1196 // (wordmark, context caption, prompt) is not whale art and stays.
1197
1198 let identity = crate::tui::workspace_context::identity_from_context(
1199 &app.workspace,
1200 app.workspace_context.as_deref(),
1201 );
1202 let workspace = crate::utils::display_path(&app.workspace);
1203 let branch = identity.branch.as_deref().map_or_else(
1204 || tr(app.ui_locale, MessageId::EmptyStateNoGit),
1205 |branch| Cow::Owned(branch.to_string()),
1206 );
1207 // Compact used to bypass the caption entirely and print the bare branch,
1208 // which in a plain folder rendered as the single centred word "no git" —
1209 // a whole row of the hero spent naming something that is not there. The
1210 // shedding ladder already degrades gracefully at any width, so every tier
1211 // now goes through it.
1212 let context = empty_state_caption(
1213 &workspace,
1214 &branch,
1215 tr(app.ui_locale, MessageId::EmptyStateMcpLabel).as_ref(),
1216 app.mcp_configured_count,
1217 width,
1218 );
1219 let brand = "codewhale";
1220 let brand_inset = " ".repeat(width.saturating_sub(brand.width()) / 2);
1221 lines.push(Line::from(Span::styled(
1222 format!("{brand_inset}{brand}"),
1223 Style::default()
1224 .fg(app.ui_theme.text_body)
1225 .add_modifier(Modifier::BOLD),
1226 )));
1227 let context = truncate_to_width(&context, width);
1228 let inset = " ".repeat(width.saturating_sub(context.width()) / 2);
1229 lines.push(Line::from(Span::styled(
1230 format!("{inset}{context}"),
1231 Style::default().fg(app.ui_theme.text_soft),
1232 )));
1233 if area.height >= 4 {
1234 lines.push(Line::from(""));
1235 let prompt = tr(app.ui_locale, MessageId::EmptyStatePrompt);
1236 let prompt = truncate_to_width(prompt.as_ref(), width);
1237 let inset = " ".repeat(width.saturating_sub(prompt.width()) / 2);
1238 lines.push(Line::from(Span::styled(
1239 format!("{inset}{prompt}"),
1240 Style::default().fg(app.ui_theme.text_body),
1241 )));
1242 }
1243 lines
1244 }
1245
1246 /// The remedy the problems row prints, as the command Enter/click types into
1247 /// the composer (#6085): `/mcp login <name>` when a server wants a login,
1248 /// else `/mcp` for failures. `None` when nothing is wrong. One helper serves
1249 /// the row's tail and its action, so what is painted is what runs.
1250 pub(crate) fn mcp_remedy_command(app: &App) -> Option<String> {
1251 use crate::tui::session_boot::{McpServerBootState, PluginBootSummary, SessionBootSurface};
1252 let boot = SessionBootSurface::from_parts(
1253 app.mcp_snapshot.as_ref(),
1254 app.mcp_initializing,
1255 &app.mcp_connecting,
1256 app.mcp_configured_count,
1257 PluginBootSummary::default(),
1258 );
1259 let first_in = |state: McpServerBootState| -> Option<&str> {
1260 boot.servers
1261 .iter()
1262 .find(|row| row.state == state)
1263 .map(|row| row.name.as_str())
1264 };
1265 if let Some(name) = first_in(McpServerBootState::NeedsLogin) {
1266 return Some(format!("/mcp login {name}"));
1267 }
1268 first_in(McpServerBootState::Failed).map(|_| "/mcp".to_string())
1269 }
1270
1271 /// The launch screen's MCP block: what actually became of the configured
1272 /// servers, painted under the recent-work list.
1273 ///
1274 /// The Tideline footer has one clause for this whole fact, so a 23-server
1275 /// workspace rendered as `MCP · 1 connecting · alibaba-cloud-ops` — one
1276 /// arbitrary name, every failure hidden (founder, 2026-09-09). The launch
1277 /// screen has the rows the footer does not, so the two states that carry a
1278 /// remedy get a row each and *name* their servers; the healthy majority stays
1279 /// a count, because a list of things that worked is not information. A server
1280 /// that needs a login and a server that could not connect are different
1281 /// problems with different fixes, so they never share a row.
1282 ///
1283 /// State comes from [`crate::tui::session_boot::SessionBootSurface`], the one
1284 /// MCP status owner; this is only its launch projection, and it computes
1285 /// nothing about a server itself.
1286 ///
1287 /// The problems row is selectable (#6085): `problems_row` is its index within
1288 /// `lines`, which `launch_empty_state` turns into a hitbox so the row joins
1289 /// the card's shared paint/click/keyboard ordering. Enter or click types the
1290 /// printed remedy into the composer — the user sees the command before a
1291 /// second Enter sends it.
1292 struct McpLaunchBlock {
1293 lines: Vec<Line<'static>>,
1294 problems_row: Option<usize>,
1295 }
1296
1297 fn mcp_launch_lines(app: &App, text_width: usize) -> McpLaunchBlock {
1298 use crate::tui::session_boot::{
1299 ITEM_SEPARATOR, McpServerBootState, PluginBootSummary, SessionBootPhase, SessionBootSurface,
1300 };
1301
1302 // MCP only: the plugin half of the boot surface has its own footer chip
1303 // and its own screen, and walking the plugin registry every frame to
1304 // discard it would be waste.
1305 let boot = SessionBootSurface::from_parts(
1306 app.mcp_snapshot.as_ref(),
1307 app.mcp_initializing,
1308 &app.mcp_connecting,
1309 app.mcp_configured_count,
1310 PluginBootSummary::default(),
1311 );
1312 if boot.phase == SessionBootPhase::Hidden || text_width == 0 {
1313 return McpLaunchBlock {
1314 lines: Vec::new(),
1315 problems_row: None,
1316 };
1317 }
1318 let theme = &app.ui_theme;
1319 let locale = app.ui_locale;
1320 let names_in = |state: McpServerBootState| -> Vec<&str> {
1321 boot.servers
1322 .iter()
1323 .filter(|row| row.state == state)
1324 .map(|row| row.name.as_str())
1325 .collect()
1326 };
1327 let failed = names_in(McpServerBootState::Failed);
1328 let needs_login = names_in(McpServerBootState::NeedsLogin);
1329 let connected = names_in(McpServerBootState::Connected).len();
1330 // Before the first boot event the names have not arrived, but the count
1331 // has; without it a 23-server workspace would paint nothing at all.
1332 let connecting = names_in(McpServerBootState::Connecting)
1333 .len()
1334 .max(boot.connecting_without_names());
1335
1336 let indent = 0;
1337 let lane = text_width.saturating_sub(indent);
1338 let mut lines: Vec<Line<'static>> = Vec::new();
1339
1340 // The summary carries every non-zero state, because it is also the floor:
1341 // when the pane can afford one row of this block, that row still has to
1342 // say two servers failed. Order is by what the reader must act on, so the
1343 // tail shed below gives up `connected` first — and while anything is in
1344 // flight that clause leads, since a boot that has connected nothing yet
1345 // must never read as a boot that finished with nothing connected.
1346 // `label (n)` rather than `n label`: number agreement is a grammar this
1347 // renderer cannot get right in fifteen languages.
1348 let mut parts: Vec<String> = Vec::new();
1349 let mut count_part = |id: MessageId, count: usize| {
1350 if count > 0 {
1351 parts.push(format!("{} ({count})", tr(locale, id)));
1352 }
1353 };
1354 count_part(MessageId::McpStateConnecting, connecting);
1355 count_part(MessageId::McpStateFailed, failed.len());
1356 count_part(MessageId::McpStateAuthorizationRequired, needs_login.len());
1357 count_part(MessageId::ExtensionsStateConnected, connected);
1358 if parts.is_empty() {
1359 parts.push(format!(
1360 "{} (0)",
1361 tr(locale, MessageId::ExtensionsStateConnected)
1362 ));
1363 }
1364 let mut summary = format!("MCP{ITEM_SEPARATOR}{}", parts.join(ITEM_SEPARATOR));
1365 while parts.len() > 1 && text_display_width(&summary) > text_width {
1366 parts.pop();
1367 summary = format!("MCP{ITEM_SEPARATOR}{}", parts.join(ITEM_SEPARATOR));
1368 }
1369 lines.push(Line::from(Span::styled(
1370 semantic_truncate(&summary, text_width),
1371 Style::default().fg(if !failed.is_empty() {
1372 theme.error_fg
1373 } else if !needs_login.is_empty() {
1374 theme.warning
1375 } else {
1376 theme.text_muted
1377 }),
1378 )));
1379
1380 // One problems row answers *which* and *what to type*: `✕` groups the
1381 // failed names, `⚠` switches the group to names that want a login, and
1382 // the remedy rides at the tail. Narrow panes shed the hint, then names
1383 // from the tail into `+N`, and finally the row itself — never the
1384 // summary. Glyph *and* state grouping carry the difference, so it
1385 // survives a monochrome terminal and a colour-blind reader.
1386 let mut problems_row = None;
1387 if !failed.is_empty() || !needs_login.is_empty() {
1388 let hint = match (needs_login.first(), failed.is_empty()) {
1389 (Some(name), true) => format!("/mcp login {name}"),
1390 (Some(name), false) => format!("/mcp{ITEM_SEPARATOR}/mcp login {name}"),
1391 (None, false) => "/mcp".to_string(),
1392 (None, true) => String::new(),
1393 };
1394 let problems = mcp_problems_row(&failed, &needs_login, &hint, lane);
1395 if let Some(text) = problems {
1396 let mut spans = Vec::with_capacity(2);
1397 if indent > 0 {
1398 spans.push(Span::raw(" ".repeat(indent)));
1399 }
1400 spans.push(Span::styled(
1401 text,
1402 Style::default().fg(if failed.is_empty() {
1403 theme.warning
1404 } else {
1405 theme.error_fg
1406 }),
1407 ));
1408 problems_row = Some(lines.len());
1409 lines.push(Line::from(spans));
1410 }
1411 }
1412 McpLaunchBlock {
1413 lines,
1414 problems_row,
1415 }
1416 }
1417
1418 /// One problems row: `✕ alibaba-cloud-ops · aws-mcp · ⚠ slack +4 · /mcp`.
1419 ///
1420 /// `✕` opens the failed group, `⚠` opens the needs-login group, and the
1421 /// remedy sits at the tail. Shedding folds names into `+N` from the tail
1422 /// while the remedy holds — at each width the named command is tried first,
1423 /// then bare `/mcp`, then none — and when even `✕ +2 · ⚠ +5` cannot fit
1424 /// the row is dropped whole: the summary above still says the count.
1425 fn mcp_problems_row(
1426 failed: &[&str],
1427 needs_login: &[&str],
1428 hint: &str,
1429 lane: usize,
1430 ) -> Option<String> {
1431 use crate::tui::glyphs::{ATTENTION, FAILED};
1432 use crate::tui::session_boot::ITEM_SEPARATOR;
1433
1434 let group = |glyph: &str, names: &[&str], shown: usize, row: &mut String| {
1435 if names.is_empty() {
1436 return;
1437 }
1438 if !row.is_empty() {
1439 row.push_str(ITEM_SEPARATOR);
1440 }
1441 row.push_str(glyph);
1442 row.push(' ');
1443 if shown > 0 {
1444 row.push_str(&names[..shown].join(ITEM_SEPARATOR));
1445 let extra = names.len() - shown;
1446 if extra > 0 {
1447 row.push_str(ITEM_SEPARATOR);
1448 row.push('+');
1449 row.push_str(&extra.to_string());
1450 }
1451 } else {
1452 row.push('+');
1453 row.push_str(&names.len().to_string());
1454 }
1455 };
1456 let total = failed.len() + needs_login.len();
1457 for shown in (0..=total).rev() {
1458 let failed_shown = shown.min(failed.len());
1459 let login_shown = shown.saturating_sub(failed_shown);
1460 let mut body = String::new();
1461 group(FAILED, failed, failed_shown, &mut body);
1462 group(ATTENTION, needs_login, login_shown, &mut body);
1463 for tail in [hint, "/mcp", ""] {
1464 if tail.is_empty() && !hint.is_empty() && shown > 0 {
1465 continue;
1466 }
1467 let mut row = body.clone();
1468 if !tail.is_empty() {
1469 row.push_str(ITEM_SEPARATOR);
1470 row.push_str(tail);
1471 }
1472 if text_display_width(&row) <= lane {
1473 return Some(row);
1474 }
1475 }
1476 }
1477 None
1478 }
1479
1480 /// Ease every painted glyph toward the water behind it. `dissolve` is
1481 /// `card_dissolve_progress` — 0 is full ink, 1 is gone. Spans without a
1482 /// foreground (padding, blanks) carry nothing to fade.
1483 fn fade_lines(lines: &mut [Line<'static>], dissolve: f32, water: Color) {
1484 for line in lines.iter_mut() {
1485 for span in line.spans.iter_mut() {
1486 if let Some(fg) = span.style.fg {
1487 span.style.fg = Some(crate::tui::mark::lerp_color(fg, water, dissolve));
1488 }
1489 }
1490 }
1491 }
1492
1493 /// How much of the card fits the pane. `New session` is never shed: it is the
1494 /// screen's one actionable choice.
1495 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1496 struct LaunchFit {
1497 brand: bool,
1498 context: bool,
1499 help: bool,
1500 notice: bool,
1501 heading: bool,
1502 blanks: usize,
1503 shown: usize,
1504 see_all: bool,
1505 /// Rows of the MCP block, tail-first as the pane shrinks. The block
1506 /// always costs one separator row on top of these, so it never reads as
1507 /// another session in the list above it.
1508 mcp: usize,
1509 /// Blank rows per separator. A pane with height to spare spends it on
1510 /// breathing room before it spends it on more content: one blank line
1511 /// between blocks packs the card into the top-left corner of a tall
1512 /// terminal and reads as clutter even when every row is earning its place.
1513 /// This is the first thing shed, so a short pane is unaffected.
1514 gap: usize,
1515 }
1516
1517 impl LaunchFit {
1518 const fn rows(self) -> usize {
1519 (self.brand as usize)
1520 + (self.context as usize)
1521 + (self.help as usize)
1522 + (self.notice as usize)
1523 + self.blanks * self.gap
1524 + 1
1525 + (self.heading as usize)
1526 + self.shown
1527 + (self.see_all as usize)
1528 + self.mcp
1529 + (self.mcp > 0) as usize * self.gap
1530 }
1531 }
1532
1533 /// Shed the card down to `height`, in a fixed order: rhythm, the migration
1534 /// notice, the MCP block's detail, identity/help chrome, then the tail of
1535 /// the recent list. The overflow row keeps any hidden sessions reachable.
1536 ///
1537 /// The MCP block gives up its rows before the recent list does (recent work
1538 /// is what the screen is *for*) but keeps its summary line until almost
1539 /// everything else has gone, because "2 failed" in one row still tells the
1540 /// truth that the footer chip could not.
1541 fn launch_fit(height: usize, recent: usize, has_more: bool, notice: bool, mcp: usize) -> LaunchFit {
1542 let mut fit = LaunchFit {
1543 brand: true,
1544 context: true,
1545 help: true,
1546 notice,
1547 heading: recent > 0 || has_more,
1548 blanks: LAUNCH_SEPARATORS,
1549 shown: recent,
1550 see_all: has_more,
1551 mcp,
1552 gap: LAUNCH_GAP_ROOMY,
1553 };
1554 let mut step = 0u8;
1555 while fit.rows() > height {
1556 match step {
1557 // Breathing room is the first luxury to go, before any content.
1558 0 => fit.gap = 1,
1559 1 => fit.blanks = 1,
1560 2 => fit.blanks = 0,
1561 3 => fit.notice = false,
1562 4 => {
1563 while fit.mcp > 1 && fit.rows() > height {
1564 fit.mcp -= 1;
1565 }
1566 }
1567 5 => fit.help = false,
1568 6 => fit.context = false,
1569 7 => fit.heading = false,
1570 8 => fit.brand = false,
1571 9 => {
1572 while fit.shown > 0 && fit.rows() > height {
1573 fit.shown -= 1;
1574 fit.see_all = true;
1575 }
1576 }
1577 10 => fit.mcp = 0,
1578 11 => fit.see_all = false,
1579 _ => break,
1580 }
1581 step += 1;
1582 }
1583 fit
1584 }
1585
1586 pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
1587 if area.width == 0 || area.height == 0 {
1588 return LaunchEmptyState {
1589 lines: Vec::new(),
1590 rows: Vec::new(),
1591 text_column: Rect::default(),
1592 };
1593 }
1594 let width = usize::from(area.width);
1595 let height = usize::from(area.height);
1596 let theme = &app.ui_theme;
1597 let locale = app.ui_locale;
1598 let mut lines: Vec<Line<'static>> = Vec::new();
1599 let mut rows: Vec<(crate::tui::app::LaunchRowId, usize)> = Vec::new();
1600
1601 // One reading lane: identity never steals width from session titles.
1602 // Reserve a two-cell action gutter where the terminal can afford it.
1603 let block_indent = if width >= LAUNCH_INDENT_MIN_WIDTH {
1604 LAUNCH_BLOCK_INDENT.max(width.saturating_sub(LAUNCH_CARD_MEASURE + 2) / 2)
1605 } else {
1606 0
1607 };
1608 let action_gutter = if width >= LAUNCH_INDENT_MIN_WIDTH {
1609 2
1610 } else {
1611 0
1612 };
1613 let text_indent = block_indent + action_gutter;
1614 let text_width = width
1615 .saturating_sub(text_indent + block_indent)
1616 .min(LAUNCH_CARD_MEASURE);
1617 if text_width == 0 {
1618 return LaunchEmptyState {
1619 lines: Vec::new(),
1620 rows: Vec::new(),
1621 text_column: Rect::default(),
1622 };
1623 }
1624
1625 let (entries, has_more) = launch_recent_entries(app);
1626 // Built before the fit ladder runs: how many rows the block wants is a
1627 // fact about this workspace's servers, not about the pane.
1628 let mcp_block = mcp_launch_lines(app, text_width);
1629 // Brand occupies the header only; recent titles retain the whole reading lane.
1630 // Scale the canonical raster derivative before sacrificing any controls.
1631 use crate::tui::mark::MarkSize;
1632 let mut mark = if crate::tui::color_compat::ascii_safe_enabled() {
1633 None
1634 } else if height >= 14 && text_width >= 40 {
1635 Some(MarkSize::Large)
1636 } else if height >= 8 && text_width >= 26 {
1637 Some(MarkSize::Small)
1638 } else if height >= 4 && text_width >= 19 {
1639 Some(MarkSize::Tiny)
1640 } else {
1641 None
1642 };
1643 let mark_extra = mark.map_or(0, |size| usize::from(size.cells().1).saturating_sub(2));
1644 let mut fit = launch_fit(
1645 height.saturating_sub(mark_extra),
1646 entries.len(),
1647 has_more,
1648 app.launch.claude_code_detected,
1649 mcp_block.lines.len(),
1650 );
1651 if mark.is_some() && !(fit.brand && fit.context) {
1652 // At the absolute height floor the wordmark yields to the actions too.
1653 mark = None;
1654 fit = launch_fit(
1655 height,
1656 entries.len(),
1657 has_more,
1658 app.launch.claude_code_detected,
1659 mcp_block.lines.len(),
1660 );
1661 }
1662 let header_width =
1663 text_width.saturating_sub(mark.map_or(0, |size| usize::from(size.cells().0) + 2));
1664 let spacious = fit.blanks == LAUNCH_SEPARATORS;
1665 let visible: Vec<LaunchRecentEntry> = entries.into_iter().take(fit.shown).collect();
1666 let card_rows = home_card_rows(app, &visible, fit.see_all);
1667
1668 // The text column, in order. `None` is a blank row.
1669 let mut text: Vec<Option<Line<'static>>> = Vec::new();
1670 if fit.brand {
1671 let brand = "codewhale";
1672 let version = format!("v{}", env!("CODEWHALE_BUILD_VERSION"));
1673 let mut spans = vec![Span::styled(
1674 semantic_truncate(brand, header_width),
1675 Style::default().fg(theme.accent_primary).bold(),
1676 )];
1677 if header_width >= text_display_width(brand) + 1 + text_display_width(&version) {
1678 spans.push(Span::styled(
1679 format!(
1680 "{}{version}",
1681 " ".repeat(
1682 header_width - text_display_width(brand) - text_display_width(&version)
1683 )
1684 ),
1685 Style::default().fg(theme.text_muted),
1686 ));
1687 }
1688 text.push(Some(Line::from(spans)));
1689 }
1690 if fit.context {
1691 let workspace = shorten_workspace(&crate::utils::display_path(&app.workspace), 2);
1692 let identity = crate::tui::workspace_context::identity_from_context(
1693 &app.workspace,
1694 app.workspace_context.as_deref(),
1695 );
1696 let mut spans = vec![Span::styled(
1697 semantic_truncate(&workspace, header_width),
1698 Style::default().fg(theme.text_soft),
1699 )];
1700 if let Some(branch) = identity.branch {
1701 let detail = format!(" · {branch}");
1702 if text_display_width(&workspace) + text_display_width(&detail) <= header_width {
1703 spans.push(Span::styled(detail, Style::default().fg(theme.text_muted)));
1704 }
1705 }
1706 text.push(Some(Line::from(spans)));
1707 }
1708 if let Some(mark) = mark {
1709 text.resize_with(usize::from(mark.cells().1), || None);
1710 for (row, dots) in mark.rows().iter().enumerate() {
1711 let mut spans = vec![
1712 Span::styled(
1713 crate::tui::mark::reveal_row(
1714 dots,
1715 app.launch
1716 .mark_reveal_started_at
1717 .map_or(crate::tui::mark::REVEAL_MS, |started| {
1718 started.elapsed().as_millis()
1719 }),
1720 app.motion_policy().allows_decorative() && !app.launch.return_to_session,
1721 ),
1722 Style::default().fg(theme.accent_primary),
1723 ),
1724 Span::raw(" "),
1725 ];
1726 if let Some(line) = text[row].take() {
1727 spans.extend(line.spans);
1728 }
1729 text[row] = Some(Line::from(spans));
1730 }
1731 }
1732 // The migration notice, while there is still a question to answer. It
1733 // retires for good once `/import-claude` has been run.
1734 if fit.notice {
1735 text.push(Some(Line::from(Span::styled(
1736 semantic_truncate(&tr(locale, MessageId::LaunchNoticeClaude), text_width),
1737 Style::default().fg(theme.text_muted),
1738 ))));
1739 }
1740 if fit.blanks >= 1 {
1741 for _ in 0..fit.gap {
1742 text.push(None);
1743 }
1744 }
1745
1746 for row in &card_rows {
1747 let style = if row.prominent {
1748 Style::default().fg(theme.text_body).bold()
1749 } else {
1750 Style::default().fg(theme.text_body)
1751 };
1752 if matches!(row.id, crate::tui::app::LaunchRowId::SeeAll) && spacious {
1753 for _ in 0..fit.gap {
1754 text.push(None);
1755 }
1756 }
1757 let lane = text_width;
1758 let detail_width = text_display_width(&row.detail);
1759 // Age is context: when the lane cannot hold a readable title
1760 // beside it, drop the age whole
1761 // rather than ellipsing the title to a stub. This is also the
1762 // fallback for a locale that spends more cells on the same fact.
1763 let detail = if row.detail.is_empty()
1764 || lane < LAUNCH_ROW_MIN_TITLE + LAUNCH_ROW_GAP + detail_width
1765 {
1766 ""
1767 } else {
1768 row.detail.as_str()
1769 };
1770 let label_budget = if detail.is_empty() {
1771 lane
1772 } else {
1773 lane.saturating_sub(LAUNCH_ROW_GAP + detail_width)
1774 };
1775 let label = semantic_truncate(&row.label, label_budget);
1776 let label_width = text_display_width(&label);
1777 let mut spans = Vec::with_capacity(4);
1778 spans.push(Span::styled(label, style));
1779 if !detail.is_empty() {
1780 let pad = lane
1781 .saturating_sub(label_width)
1782 .saturating_sub(detail_width);
1783 spans.push(Span::raw(" ".repeat(pad)));
1784 spans.push(Span::styled(
1785 detail.to_string(),
1786 Style::default().fg(theme.text_muted),
1787 ));
1788 }
1789 if row.prominent {
1790 spans.push(Span::styled(
1791 " ".repeat(lane.saturating_sub(label_width)),
1792 style,
1793 ));
1794 }
1795 rows.push((row.id.clone(), text.len()));
1796 text.push(Some(Line::from(spans)));
1797 if row.prominent && fit.heading {
1798 // An empty workspace needs only the invitation and composer.
1799 // Real history, including filtered sessions reachable via See all,
1800 // still gets a heading; zero counts are not content.
1801 if spacious {
1802 for _ in 0..fit.gap {
1803 text.push(None);
1804 }
1805 }
1806 let label = semantic_truncate(&tr(locale, MessageId::LaunchRecentHeading), text_width);
1807 let remaining = text_width.saturating_sub(text_display_width(&label) + 2);
1808 let mut spans = vec![Span::styled(
1809 label,
1810 Style::default().fg(theme.text_soft).bold(),
1811 )];
1812 if remaining >= 4 {
1813 let rule = if crate::tui::color_compat::ascii_safe_enabled() {
1814 "-"
1815 } else {
1816 "─"
1817 };
1818 spans.push(Span::styled(
1819 format!(" {}", rule.repeat(remaining)),
1820 Style::default().fg(theme.border),
1821 ));
1822 }
1823 text.push(Some(Line::from(spans)));
1824 }
1825 }
1826
1827 // MCP status, under the recent-work list, where the founder asked for it
1828 // (2026-09-09): the footer chip could name one server out of 23 and hid
1829 // every failure behind a count.
1830 if fit.mcp > 0 {
1831 for _ in 0..fit.gap {
1832 text.push(None);
1833 }
1834 for (offset, line) in mcp_block.lines.into_iter().enumerate() {
1835 if offset >= fit.mcp {
1836 break;
1837 }
1838 if offset == 0 {
1839 rows.push((crate::tui::app::LaunchRowId::McpManager, text.len()));
1840 } else if mcp_block.problems_row == Some(offset) {
1841 rows.push((crate::tui::app::LaunchRowId::McpRemedy, text.len()));
1842 }
1843 text.push(Some(line));
1844 }
1845 }
1846
1847 // Keep the invitation and recent work ahead of command instructions.
1848 if fit.help {
1849 if text.len() + 1 < height {
1850 text.push(None);
1851 }
1852 text.push(Some(Line::from(Span::styled(
1853 semantic_truncate(
1854 &tr(locale, MessageId::LaunchHelpLine).replace(
1855 "{dock}",
1856 crate::tui::shell_key_routing::binding(
1857 crate::tui::shell_key_routing::ShellBindingId::ViewCycle,
1858 )
1859 .footer_chord,
1860 ),
1861 text_width,
1862 ),
1863 Style::default().fg(theme.text_hint),
1864 ))));
1865 }
1866
1867 // Paint the state mouse/keyboard navigation already records. Restrict the
1868 // band to the text lane so selecting a session never colors the margins.
1869 for (index, (_, row)) in rows.iter().enumerate() {
1870 let style = if app.launch.menu_selected == Some(index) {
1871 Some(crate::tui::menu_style::selected_row_bg_style().bold())
1872 } else if app.launch.hovered_row == Some(index) {
1873 Some(crate::tui::menu_style::hovered_row_style())
1874 } else {
1875 None
1876 };
1877 if let Some(style) = style
1878 && let Some(Some(line)) = text.get_mut(*row)
1879 {
1880 let padding = text_width.saturating_sub(line.width());
1881 line.spans.push(Span::raw(" ".repeat(padding)));
1882 for span in &mut line.spans {
1883 span.style = span.style.patch(style);
1884 }
1885 }
1886 }
1887
1888 // Stable focus gutter: the cursor identifies the current Enter target.
1889 // The band includes the gutter and only the bounded reading lane.
1890 // A little top breathing room only comes from unused space. Compact
1891 // terminals never sacrifice a control for this composition.
1892 if height >= 16 {
1893 // Use spare height to balance the launcher above the composer. Leave
1894 // the bottom half as breathing room; controls never lose a row.
1895 let top = height.saturating_sub(text.len()) / 2;
1896 lines.resize_with(top, || Line::from(""));
1897 }
1898 let block_rows = text.len().min(height.saturating_sub(lines.len()));
1899 let mut row_offsets = Vec::with_capacity(block_rows);
1900 for (row, line) in text.iter().take(block_rows).enumerate() {
1901 let action = rows.iter().position(|(_, y)| *y == row);
1902 let selected = action.is_some_and(|i| app.launch.menu_selected == Some(i));
1903 let hovered = action.is_some_and(|i| app.launch.hovered_row == Some(i));
1904 let style = if selected {
1905 crate::tui::menu_style::selected_row_style()
1906 } else if hovered {
1907 crate::tui::menu_style::hovered_row_style()
1908 } else {
1909 Style::default().fg(theme.text_muted)
1910 };
1911 let mut spans = vec![Span::raw(" ".repeat(block_indent))];
1912 if action_gutter > 0 {
1913 let marker = if selected || hovered {
1914 if crate::tui::color_compat::ascii_safe_enabled() {
1915 "> "
1916 } else {
1917 "› "
1918 }
1919 } else {
1920 " "
1921 };
1922 spans.push(Span::styled(marker, style));
1923 }
1924 if let Some(line) = line {
1925 spans.extend(line.spans.iter().cloned());
1926 }
1927 row_offsets.push(lines.len());
1928 lines.push(Line::from(spans));
1929 }
1930
1931 // Re-point the hitboxes at the composed rows. A row the block could not
1932 // fit has no offset, so it has no hitbox either.
1933 let rows = rows
1934 .into_iter()
1935 .filter_map(|(id, text_row)| row_offsets.get(text_row).map(|y| (id, *y)))
1936 .collect();
1937
1938 LaunchEmptyState {
1939 lines,
1940 rows,
1941 text_column: Rect::new(
1942 block_indent as u16,
1943 0,
1944 (text_width + action_gutter) as u16,
1945 area.height,
1946 ),
1947 }
1948 }
1949
1950 #[cfg(test)]
1951 mod launch_card_tests {
1952 use super::{
1953 LAUNCH_CARD_MEASURE, LaunchAction, launch_empty_state, launch_fit, launch_recent_entries,
1954 launch_row_click_action, launch_rows_for_app, refresh_launch_row_hitboxes,
1955 run_launch_card_row, text_display_width,
1956 };
1957 use crate::tui::app::{App, LaunchRecentSession, LaunchRowId};
1958 use ratatui::layout::Rect;
1959 use ratatui::text::Line;
1960 use unicode_segmentation::UnicodeSegmentation;
1961
1962 fn app_with_recent(titles: &[&str], total: usize) -> App {
1963 let mut app = crate::test_support::test_app_with_options(
1964 crate::test_support::test_tui_options(std::env::temp_dir()),
1965 );
1966 app.launch.visible = true;
1967 app.launch.claude_code_detected = false;
1968 app.launch.recent = titles
1969 .iter()
1970 .enumerate()
1971 .map(|(index, title)| LaunchRecentSession {
1972 id: format!("{index}0abcdef-session"),
1973 title: (*title).to_string(),
1974 updated_at: chrono::Utc::now()
1975 - chrono::Duration::hours(i64::try_from(index).unwrap_or(0) + 1),
1976 message_count: 40 + index,
1977 })
1978 .collect();
1979 app.launch.total_workspace_sessions = total;
1980 app
1981 }
1982
1983 #[test]
1984 fn launch_primary_action_has_readable_ink_in_every_theme() {
1985 for theme in codewhale_palette::SELECTABLE_THEMES {
1986 let mut app = app_with_recent(&["Recent proof"], 1);
1987 app.ui_theme = theme.ui_theme();
1988 app.theme_id = *theme;
1989 let card = launch_empty_state(&app, Rect::new(0, 0, 100, 24));
1990 let span = card
1991 .lines
1992 .iter()
1993 .flat_map(|line| &line.spans)
1994 .find(|span| span.content.contains("New session"))
1995 .unwrap();
1996 assert_eq!(
1997 span.style.fg,
1998 Some(app.ui_theme.text_body),
1999 "{}",
2000 theme.name()
2001 );
2002 if let Some(ratio) =
2003 codewhale_palette::contrast_ratio(span.style.fg.unwrap(), app.ui_theme.panel_bg)
2004 {
2005 assert!(
2006 ratio >= 4.5,
2007 "{} New session contrast {ratio}",
2008 theme.name()
2009 );
2010 }
2011 }
2012 }
2013
2014 #[test]
2015 fn completion_settle_keeps_done_label_stable() {
2016 let mut app = app_with_recent(&[], 0);
2017 app.low_motion = false;
2018 app.fancy_animations = true;
2019 let activity = super::LiveActivity::from_app(&app);
2020 for elapsed in [0, 280, 700] {
2021 app.ocean_completion_started_at =
2022 Some(std::time::Instant::now() - std::time::Duration::from_millis(elapsed));
2023 let (_, label) =
2024 super::phase_marker_with_activity(&app, super::ShellPhase::Done, activity);
2025 assert_eq!(label, super::ShellPhase::Done.label(app.ui_locale));
2026 }
2027 }
2028
2029 #[test]
2030 fn launch_reveal_stops_scheduling_after_its_endpoint() {
2031 let mut app = app_with_recent(&[], 0);
2032 app.onboarding = crate::tui::app::OnboardingState::None;
2033 app.theme_id = codewhale_palette::ThemeId::Shoreline;
2034 app.low_motion = false;
2035 app.fancy_animations = true;
2036 app.launch.mark_reveal_started_at = Some(std::time::Instant::now());
2037 assert!(super::launch_motion_active(&app, false, true));
2038 assert!(!super::launch_motion_active(&app, true, true));
2039 app.low_motion = true;
2040 assert!(!super::launch_motion_active(&app, false, true));
2041 app.low_motion = false;
2042 app.launch.mark_reveal_started_at =
2043 Some(std::time::Instant::now() - std::time::Duration::from_millis(361));
2044 assert!(!super::launch_motion_active(&app, false, true));
2045 }
2046
2047 /// The founder's own shape: many servers, a couple genuinely broken, a
2048 /// pile sitting unauthenticated, the rest fine.
2049 fn with_mcp(mut app: App) -> App {
2050 use crate::mcp::{McpManagerSnapshot, McpServerCapabilityMetadata, McpServerSnapshot};
2051 let mut servers = Vec::new();
2052 let mut push = |name: &str, connected: bool, error: Option<&str>, auth: bool| {
2053 servers.push(McpServerSnapshot {
2054 name: name.to_string(),
2055 enabled: true,
2056 required: false,
2057 transport: "stdio".to_string(),
2058 command_or_url: format!("cmd-{name}"),
2059 connect_timeout: 5,
2060 execute_timeout: 5,
2061 read_timeout: 5,
2062 connected,
2063 error: error.map(str::to_string),
2064 auth_required: auth,
2065 capability_metadata: McpServerCapabilityMetadata::NotObserved,
2066 tools: Vec::new(),
2067 resources: Vec::new(),
2068 prompts: Vec::new(),
2069 });
2070 };
2071 for name in ["github", "linear", "supabase", "posthog", "vercel"] {
2072 push(name, true, None, false);
2073 }
2074 push(
2075 "alibaba-cloud-ops",
2076 false,
2077 Some("Invalid request parameters"),
2078 false,
2079 );
2080 push("aws-mcp", false, Some("Stdio transport closed"), false);
2081 for name in ["slack", "notion", "stripe", "figma", "excalidraw"] {
2082 push(name, false, Some("401 Unauthorized"), true);
2083 }
2084 app.mcp_configured_count = servers.len();
2085 app.mcp_snapshot = Some(McpManagerSnapshot {
2086 config_path: std::path::PathBuf::from("mcp.json"),
2087 config_exists: true,
2088 reload_required: false,
2089 servers,
2090 });
2091 app.mcp_initializing = false;
2092 app.mcp_connecting = Vec::new();
2093 app
2094 }
2095
2096 fn flatten(line: &Line<'_>) -> String {
2097 line.spans
2098 .iter()
2099 .map(|span| span.content.to_string())
2100 .collect::<String>()
2101 }
2102
2103 fn painted(app: &App, width: u16, height: u16) -> Vec<String> {
2104 launch_empty_state(app, Rect::new(0, 0, width, height))
2105 .lines
2106 .iter()
2107 .map(|line| flatten(line).trim_end().to_string())
2108 .collect()
2109 }
2110
2111 fn row_ids(app: &App) -> Vec<LaunchRowId> {
2112 app.launch
2113 .row_hitboxes
2114 .iter()
2115 .map(|(id, _)| id.clone())
2116 .collect()
2117 }
2118
2119 /// The recent row's own text, with the block indent stripped. Only used at
2120 /// widths narrow enough that the detail has shed, so what remains is the
2121 /// title alone.
2122 fn recent_row_title(app: &App, width: u16, height: u16) -> String {
2123 let state = launch_empty_state(app, Rect::new(0, 0, width, height));
2124 let (_, row) = state
2125 .rows
2126 .iter()
2127 .find(|(id, _)| matches!(id, LaunchRowId::Recent(_)))
2128 .expect("a recent row painted");
2129 flatten(&state.lines[*row])
2130 .trim()
2131 .trim_start_matches("› ")
2132 .trim_start_matches("> ")
2133 .to_string()
2134 }
2135
2136 fn is_grapheme_prefix(candidate: &str, full: &str) -> bool {
2137 let mut source = full.graphemes(true);
2138 candidate.graphemes(true).all(|g| source.next() == Some(g))
2139 }
2140
2141 // --- one ordering for paint, mouse, and keyboard -------------------
2142
2143 #[test]
2144 fn keyboard_rows_are_exactly_the_rows_the_pane_painted() {
2145 let mut app = app_with_recent(&["one", "two", "three", "four", "five"], 9);
2146
2147 refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, 30));
2148 let tall = launch_rows_for_app(&app);
2149 assert_eq!(
2150 tall.iter().map(|row| row.id.clone()).collect::<Vec<_>>(),
2151 row_ids(&app),
2152 "keyboard list must be the painted list",
2153 );
2154 assert!(tall.len() >= 7, "{:?}", row_ids(&app));
2155
2156 // Highlight the last row, then shrink the pane under it.
2157 app.launch.menu_selected = Some(tall.len() - 1);
2158 refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, 4));
2159 let short = launch_rows_for_app(&app);
2160 assert_eq!(
2161 short.iter().map(|row| row.id.clone()).collect::<Vec<_>>(),
2162 row_ids(&app),
2163 );
2164 assert!(short.len() < tall.len(), "the short pane shed nothing");
2165
2166 // The stale highlight is gone, so Enter cannot resume a row that is
2167 // no longer on screen.
2168 assert_eq!(app.launch.menu_selected, None);
2169 assert_eq!(
2170 run_launch_card_row(&short, app.launch.menu_selected),
2171 LaunchAction::None,
2172 );
2173 }
2174
2175 #[test]
2176 fn no_keyboard_row_names_a_session_the_pane_is_not_showing() {
2177 let mut app = app_with_recent(&["one", "two", "three", "four", "five"], 5);
2178 for height in 0u16..=14 {
2179 refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, height));
2180 let painted: Vec<LaunchRowId> = row_ids(&app);
2181 for row in launch_rows_for_app(&app) {
2182 assert!(
2183 painted.contains(&row.id),
2184 "height {height}: {:?} is runnable but was not painted",
2185 row.id,
2186 );
2187 }
2188 // Every arrow position runs a painted row or nothing at all.
2189 for index in 0..painted.len() + 3 {
2190 let rows = launch_rows_for_app(&app);
2191 match run_launch_card_row(&rows, Some(index)) {
2192 LaunchAction::None => {}
2193 LaunchAction::ResumeSession(id) => assert!(
2194 painted.contains(&LaunchRowId::Recent(id.clone())),
2195 "height {height}: Enter at {index} would resume unpainted {id}",
2196 ),
2197 _ => {}
2198 }
2199 }
2200 }
2201 }
2202
2203 #[test]
2204 fn shed_sessions_stay_reachable_through_the_overflow_row() {
2205 let mut app = app_with_recent(&["one", "two", "three", "four", "five"], 5);
2206 // Five sessions, none behind the inline list: a tall pane needs no
2207 // overflow row, a short one sheds and therefore must offer it.
2208 refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, 30));
2209 assert!(!row_ids(&app).contains(&LaunchRowId::SeeAll));
2210 refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, 4));
2211 let ids = row_ids(&app);
2212 assert!(ids.contains(&LaunchRowId::SeeAll), "{ids:?}");
2213 assert!(
2214 launch_rows_for_app(&app)
2215 .iter()
2216 .any(|row| row.id == LaunchRowId::SeeAll)
2217 );
2218 }
2219
2220 // --- the card fits the pane it is drawn into -----------------------
2221
2222 #[test]
2223 fn the_card_fits_every_pane_it_is_drawn_into() {
2224 // Both shapes: no MCP servers at all, and the twelve-server workspace
2225 // whose status block is the widest thing the card paints.
2226 for app in [
2227 app_with_recent(&["one", "two", "three", "four", "five"], 9),
2228 with_mcp(app_with_recent(&["one", "two", "three", "four", "five"], 9)),
2229 ] {
2230 for width in [0u16, 1, 2, 3, 8, 12, 20, 31, 32, 40, 44, 64, 80, 120, 200] {
2231 for height in 0u16..=30 {
2232 let state = launch_empty_state(&app, Rect::new(0, 0, width, height));
2233 assert!(
2234 state.lines.len() <= usize::from(height),
2235 "{width}x{height}: {} lines",
2236 state.lines.len(),
2237 );
2238 for line in &state.lines {
2239 let painted = text_display_width(&flatten(line));
2240 assert!(
2241 painted <= usize::from(width),
2242 "{width}x{height}: row of {painted} cells",
2243 );
2244 }
2245 for (id, row) in &state.rows {
2246 assert!(
2247 *row < state.lines.len(),
2248 "{width}x{height}: hitbox {id:?} has no row",
2249 );
2250 }
2251 if width > 0 && height > 0 {
2252 assert!(
2253 state
2254 .rows
2255 .iter()
2256 .any(|(id, _)| matches!(id, LaunchRowId::NewSession)),
2257 "{width}x{height}: nothing actionable painted",
2258 );
2259 }
2260 }
2261 }
2262 }
2263 }
2264
2265 // --- MCP status, under the recent list ------------------------------
2266
2267 /// The defect this block was built for: the footer chip named one server
2268 /// out of twenty-three and reported every other state as a bare count, so
2269 /// ten servers sitting unauthenticated were invisible. Failed and
2270 /// needs-login are different problems with different fixes and must never
2271 /// collapse into one clause — and the block is two lines at most now:
2272 /// the summary owns the counts, one problems row names what broke.
2273 #[test]
2274 fn the_mcp_block_names_what_broke_and_separates_it_from_what_needs_a_login() {
2275 let app = with_mcp(app_with_recent(&["one", "two"], 2));
2276 let lines = painted(&app, 120, 30);
2277 // The summary owns the totals, and every non-zero state is on it.
2278 let summary = lines
2279 .iter()
2280 .find(|line| line.contains("MCP"))
2281 .expect("the MCP summary");
2282 assert!(summary.contains("connection failed (2)"), "{summary:?}");
2283 assert!(
2284 summary.contains("authorization required (5)"),
2285 "ten servers at not-logged-in were invisible before this: {summary:?}",
2286 );
2287 // One problems row answers *which*: ✕ groups the failures, ⚠ groups
2288 // the logins, and the remedy rides at the tail.
2289 let problems: Vec<_> = lines
2290 .iter()
2291 .filter(|line| {
2292 line.contains(crate::tui::glyphs::FAILED)
2293 || line.contains(crate::tui::glyphs::ATTENTION)
2294 })
2295 .collect();
2296 assert_eq!(problems.len(), 1, "one problems row: {lines:#?}");
2297 let row = problems[0];
2298 assert!(row.contains("alibaba-cloud-ops"), "{row:?}");
2299 assert!(row.contains("aws-mcp"), "{row:?}");
2300 assert!(row.contains("slack"), "{row:?}");
2301 // The remedy is the command to type, not advice about typing one —
2302 // the named form sheds to bare `/mcp` before any name does.
2303 assert!(row.contains("/mcp"), "{row:?}");
2304 }
2305
2306 /// While the boot is in flight the block must not read as a finished one.
2307 #[test]
2308 fn a_boot_in_flight_says_connecting_rather_than_connected() {
2309 let mut app = app_with_recent(&["one"], 1);
2310 app.mcp_initializing = true;
2311 app.mcp_configured_count = 23;
2312 app.mcp_connecting = Vec::new();
2313 let lines = painted(&app, 120, 30);
2314 let summary = lines
2315 .iter()
2316 .find(|line| line.contains("MCP"))
2317 .expect("the MCP summary");
2318 assert!(summary.contains("connecting (23)"), "{summary:?}");
2319 assert!(
2320 !summary.contains("connected"),
2321 "a boot with nothing connected yet claimed a count: {summary:?}",
2322 );
2323 }
2324
2325 #[test]
2326 fn the_fit_ladder_never_sheds_the_one_actionable_row() {
2327 for height in 1usize..=16 {
2328 for recent in 0usize..=5 {
2329 for has_more in [false, true] {
2330 for notice in [false, true] {
2331 for mcp in 0usize..=4 {
2332 let fit = launch_fit(height, recent, has_more, notice, mcp);
2333 assert!(fit.rows() <= height.max(1), "{height} {recent}: {fit:?}");
2334 assert!(fit.shown <= recent);
2335 assert!(fit.mcp <= mcp);
2336 if fit.shown < recent {
2337 assert!(
2338 fit.see_all || fit.rows() >= height,
2339 "shed rows became unreachable: {fit:?}",
2340 );
2341 }
2342 }
2343 }
2344 }
2345 }
2346 }
2347 }
2348
2349 #[test]
2350 fn empty_workspace_omits_recent_section_but_hidden_history_stays_reachable() {
2351 let app = app_with_recent(&[], 0);
2352 let text = painted(&app, 100, 24).join("\n");
2353 assert!(text.contains("New session"));
2354 assert!(!text.contains("Recent"));
2355 assert!(!text.contains("No recent sessions"));
2356 assert!(!launch_fit(24, 0, false, false, 0).heading);
2357 assert!(launch_fit(24, 0, true, false, 0).heading);
2358 assert!(launch_fit(24, 0, true, false, 0).see_all);
2359 }
2360
2361 // --- the row reads as one object -----------------------------------
2362
2363 #[test]
2364 fn a_row_keeps_its_detail_beside_its_title() {
2365 let app = app_with_recent(&["Ship the launch card"], 1);
2366 let lines = painted(&app, 200, 24);
2367 let row = lines
2368 .iter()
2369 .find(|line| line.contains("Ship the launch card"))
2370 .expect("recent row painted");
2371 assert!(
2372 text_display_width(row.trim_start()) <= LAUNCH_CARD_MEASURE + 2,
2373 "row runs to the terminal edge: {row:?}",
2374 );
2375 let (entries, _) = launch_recent_entries(&app);
2376 assert!(
2377 row.contains(&entries[0].detail),
2378 "row lost its age: {row:?}"
2379 );
2380 assert!(
2381 !row.contains("msgs"),
2382 "message counts belong in session details: {row:?}"
2383 );
2384 }
2385
2386 #[test]
2387 fn a_narrow_pane_spends_its_lane_on_the_title() {
2388 let app = app_with_recent(&["Ship the launch card"], 1);
2389 let row = recent_row_title(&app, 40, 24);
2390 let (entries, _) = launch_recent_entries(&app);
2391 assert!(
2392 !row.contains(&entries[0].detail),
2393 "age should have shed: {row:?}"
2394 );
2395 assert!(row.starts_with("Ship the launch"), "{row:?}");
2396 }
2397
2398 // --- scripts --------------------------------------------------------
2399
2400 #[test]
2401 fn titles_truncate_on_grapheme_boundaries_in_several_scripts() {
2402 // Not a claim about every script: these are the four shapes that break
2403 // char-indexed truncation — wide cells, RTL runs, ZWJ/skin-tone emoji,
2404 // and combining marks.
2405 let samples = [
2406 (
2407 "latin",
2408 "Ship the launch card and verify truncation behaviour",
2409 ),
2410 ("cjk", "部署新的会话启动卡片并验证宽字符截断行为"),
2411 ("arabic", "تهيئة بطاقة إطلاق الجلسة والتحقق من سلوك الاقتطاع"),
2412 ("hebrew", "הגדרת כרטיס פתיחת הפעלה ואימות התנהגות הקיצוץ"),
2413 ("emoji", "👩🏽‍🚀 crew 👨‍👩‍👧‍👦 families and flags 🇯🇵 shipping today"),
2414 (
2415 "combining",
2416 "cafe\u{301} de\u{301}ja\u{300} vu\u{308} with combining marks throughout",
2417 ),
2418 ];
2419 for (name, title) in samples {
2420 let app = app_with_recent(&[title], 1);
2421 for width in [8u16, 12, 20, 32, 40, 60, 80, 120, 200] {
2422 for line in painted(&app, width, 24) {
2423 assert!(
2424 text_display_width(&line) <= usize::from(width),
2425 "{name} at {width}: {line:?} overruns the pane",
2426 );
2427 }
2428 }
2429 // At these widths the detail has shed, so the row is the title
2430 // alone: what is painted must be whole graphemes off its front.
2431 for width in [12u16, 20, 32, 40] {
2432 let row = recent_row_title(&app, width, 24);
2433 let body = row.strip_suffix('…').unwrap_or(&row);
2434 assert!(
2435 is_grapheme_prefix(body, title),
2436 "{name} at {width}: {body:?} splits a grapheme of {title:?}",
2437 );
2438 }
2439 }
2440 }
2441
2442 // --- rhythm ---------------------------------------------------------
2443
2444 #[test]
2445 fn a_tall_pane_breathes_and_a_short_one_gives_the_rhythm_up_first() {
2446 // Counting blank *painted* rows would be wrong: the mark column sits
2447 // behind the first six of them. The rhythm is the gap between the
2448 // new-session entry and the heading below it.
2449 let app = app_with_recent(&["one", "two", "three", "four", "five"], 9);
2450
2451 let tall = painted(&app, 120, 30);
2452 let entry = tall
2453 .iter()
2454 .position(|line| line.contains("New session"))
2455 .expect("new-session row painted");
2456 assert!(
2457 !tall[entry + 1].contains("Recent"),
2458 "a tall pane should breathe: {tall:#?}",
2459 );
2460
2461 let tight = painted(&app, 120, 12);
2462 let entry = tight
2463 .iter()
2464 .position(|line| line.contains("New session"))
2465 .expect("new-session row painted");
2466 assert!(
2467 tight[entry + 1].contains("Recent"),
2468 "a tight pane kept rhythm it cannot afford: {tight:#?}",
2469 );
2470 }
2471
2472 /// Print the card as the renderer actually paints it, for the evidence
2473 /// fixture. Ignored by default; run with
2474 /// `cargo test -p codewhale-tui --lib render_launch_card_fixture -- --ignored --nocapture`.
2475 #[test]
2476 #[ignore = "fixture generator, not an assertion"]
2477 #[allow(clippy::print_stdout)] // the fixture's whole job is its stdout
2478 fn render_launch_card_fixture() {
2479 let app = with_mcp(app_with_recent(
2480 &[
2481 "Fix the diagnostic display",
2482 "Hunter has explicitly authorized setting up Codewhale",
2483 "部署新的会话启动卡片并验证宽字符截断行为",
2484 "👩🏽\u{200d}🚀 crew 👨\u{200d}👩\u{200d}👧\u{200d}👦 families and flags 🇯🇵",
2485 "תהיה כרטיס פתיחת הפעלה",
2486 ],
2487 9,
2488 ));
2489 for (width, height) in [(170u16, 24u16), (120, 24), (80, 24), (40, 12), (20, 8)] {
2490 println!("\n{width}x{height}");
2491 println!("+{}+", "-".repeat(usize::from(width)));
2492 for line in painted(&app, width, height) {
2493 let pad = usize::from(width).saturating_sub(text_display_width(&line));
2494 println!("|{line}{}|", " ".repeat(pad));
2495 }
2496 println!("+{}+", "-".repeat(usize::from(width)));
2497 }
2498 }
2499
2500 // --- the problems row runs the remedy it prints (#6085) -------------
2501
2502 #[test]
2503 fn mcp_problems_row_joins_the_shared_row_ordering() {
2504 let mut app = with_mcp(app_with_recent(&["one", "two"], 9));
2505 refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, 30));
2506
2507 let ids = row_ids(&app);
2508 assert_eq!(
2509 ids.last(),
2510 Some(&LaunchRowId::McpRemedy),
2511 "the problems row is the last row in the painted ordering"
2512 );
2513
2514 // Keyboard: arrowing onto the last row and pressing Enter runs the
2515 // remedy action, through the same arm a click reaches.
2516 let rows = launch_rows_for_app(&app);
2517 assert_eq!(
2518 rows.last().map(|row| row.id.clone()),
2519 Some(LaunchRowId::McpRemedy),
2520 "Up/Down must be able to land on the painted problems row"
2521 );
2522 assert_eq!(
2523 run_launch_card_row(&rows, Some(rows.len() - 1)),
2524 LaunchAction::McpRemedy
2525 );
2526 assert_eq!(
2527 launch_row_click_action(&LaunchRowId::McpRemedy),
2528 LaunchAction::McpRemedy,
2529 "click and Enter share one contract"
2530 );
2531 }
2532
2533 #[test]
2534 fn launch_healthy_mcp_and_recent_rows_have_visible_focus_in_their_click_lane() {
2535 let mut app = with_mcp(app_with_recent(&["Recent proof"], 1));
2536 app.mcp_snapshot
2537 .as_mut()
2538 .unwrap()
2539 .servers
2540 .retain(|s| s.connected);
2541 app.mcp_configured_count = 5;
2542 for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
2543 let area = Rect::new(3, 2, width, height);
2544 refresh_launch_row_hitboxes(&mut app, area);
2545 let rows = launch_rows_for_app(&app);
2546 let mcp = rows
2547 .iter()
2548 .position(|r| r.id == LaunchRowId::McpManager)
2549 .unwrap();
2550 assert_eq!(
2551 run_launch_card_row(&rows, Some(mcp)),
2552 LaunchAction::McpManager
2553 );
2554 assert!(!row_ids(&app).contains(&LaunchRowId::McpRemedy));
2555
2556 for index in [1, mcp] {
2557 app.launch.menu_selected = None;
2558 app.launch.hovered_row = Some(index);
2559 let hovered = launch_empty_state(&app, area);
2560 let (_, y) = hovered.rows[index];
2561 let hit = app.launch.row_hitboxes[index].1;
2562 assert_eq!(hit.x, area.x + hovered.text_column.x);
2563 assert_eq!(hit.y, area.y + y as u16);
2564 assert!(hit.right() <= area.right());
2565 let text = hovered.lines[y].spans.last().unwrap();
2566 assert_eq!(
2567 text.style.bg,
2568 crate::tui::menu_style::hovered_row_style().bg
2569 );
2570
2571 app.launch.menu_selected = Some(index);
2572 let selected = launch_empty_state(&app, area);
2573 assert_eq!(
2574 selected.lines[y].spans.last().unwrap().style,
2575 crate::tui::menu_style::selected_row_bg_style().bold()
2576 );
2577 // Neither the whale nor the leading whitespace changes color.
2578 assert_eq!(selected.lines[y].spans[0].style.bg, None);
2579 }
2580 }
2581 }
2582
2583 #[test]
2584 fn mcp_warning_ink_survives_selection_and_compact_layout() {
2585 let mut app = with_mcp(app_with_recent(&["Recent proof"], 1));
2586 for (width, height) in [(40, 12), (80, 24), (140, 40)] {
2587 let area = Rect::new(0, 0, width, height);
2588 let layout = launch_empty_state(&app, area);
2589 let index = layout
2590 .rows
2591 .iter()
2592 .position(|(id, _)| *id == LaunchRowId::McpManager)
2593 .unwrap();
2594 app.launch.menu_selected = Some(index);
2595 let selected = launch_empty_state(&app, area);
2596 let (_, y) = selected.rows[index];
2597 let summary = selected.lines[y]
2598 .spans
2599 .iter()
2600 .find(|span| span.content.starts_with("MCP"))
2601 .unwrap();
2602 assert_eq!(summary.style.fg, Some(app.ui_theme.error_fg));
2603 assert_eq!(
2604 summary.style.bg,
2605 crate::tui::menu_style::selected_row_bg_style().bg
2606 );
2607 }
2608 }
2609
2610 #[test]
2611 fn mcp_remedy_action_types_the_command_into_the_composer() {
2612 let mut app = with_mcp(app_with_recent(&["one"], 9));
2613 app.launch.menu_selected = Some(0);
2614
2615 crate::tui::ui::type_launch_mcp_remedy(&mut app);
2616
2617 // `slack` is the fixture's first needs-login server; typing the
2618 // printed remedy beats copying it — no clipboard to depend on.
2619 assert_eq!(app.input, "/mcp login slack");
2620 assert_eq!(app.cursor_position, app.input.chars().count());
2621 assert_eq!(app.launch.menu_selected, None);
2622 }
2623
2624 #[test]
2625 fn mcp_remedy_preserves_a_draft_and_opens_the_manager() {
2626 let mut app = with_mcp(app_with_recent(&["one"], 9));
2627 app.launch.return_to_session = true;
2628 app.input = "unsent draft".into();
2629 app.cursor_position = 4;
2630 crate::tui::ui::type_launch_mcp_remedy(&mut app);
2631 assert_eq!(app.input, "unsent draft");
2632 assert_eq!(app.cursor_position, 4);
2633 assert_eq!(
2634 app.view_stack.top_kind(),
2635 Some(crate::tui::views::ModalKind::Extensions),
2636 );
2637 }
2638
2639 #[test]
2640 fn mcp_remedy_action_is_a_noop_when_nothing_is_wrong() {
2641 let mut app = app_with_recent(&["one"], 9);
2642 crate::tui::ui::type_launch_mcp_remedy(&mut app);
2643 assert!(app.input.is_empty());
2644 }
2645
2646 #[test]
2647 fn every_hitbox_points_at_the_row_that_painted() {
2648 let app = app_with_recent(&["one", "two", "three"], 9);
2649 for height in 1u16..=24 {
2650 let state = launch_empty_state(&app, Rect::new(0, 0, 120, height));
2651 for (id, row) in &state.rows {
2652 let text = flatten(&state.lines[*row]);
2653 assert!(
2654 !text.trim().is_empty(),
2655 "height {height}: hitbox for {id:?} points at a blank row",
2656 );
2657 }
2658 }
2659 }
2660 }
2661
2662 #[cfg(test)]
2663 mod empty_state_caption_tests {
2664 use super::{empty_state_caption, shorten_workspace};
2665 use unicode_width::UnicodeWidthStr;
2666
2667 const DEEP: &str = "/private/tmp/claude-501/-Volumes-VIXinSSD-CW-codewhale/34267917-11f4-4d15-911a-2a8acd5c49e1/scratchpad/surface/ws2";
2668
2669 #[test]
2670 fn caption_stays_narrow_enough_to_actually_centre() {
2671 // The caller centres this line with `(width - caption.width()) / 2`.
2672 // Building it at full length and truncating to `width` made that inset
2673 // zero, so the caption rendered flush-left and full-bleed straight
2674 // through the centred whale/wordmark/prompt composition.
2675 for width in [60usize, 80, 100, 120] {
2676 let caption = empty_state_caption(DEEP, "no git", "MCP", 0, width);
2677 assert!(
2678 caption.width() <= width,
2679 "width {width}: caption {caption:?} overflows the lane",
2680 );
2681 assert!(
2682 width.saturating_sub(caption.width()) / 2 > 0,
2683 "width {width}: caption {caption:?} would render flush-left",
2684 );
2685 }
2686 }
2687
2688 #[test]
2689 fn caption_keeps_the_folder_you_are_standing_in() {
2690 let long = "/a/very/deeply/nested/checkout/somewhere/far/away/myproject";
2691 for width in [40usize, 60, 80, 120] {
2692 let caption = empty_state_caption(long, "main", "MCP", 2, width);
2693 assert!(
2694 caption.contains("myproject"),
2695 "width {width}: {caption:?} dropped the current folder",
2696 );
2697 }
2698 }
2699
2700 #[test]
2701 fn caption_sheds_the_least_important_detail_first() {
2702 let ws = "~/code/app";
2703 let wide = empty_state_caption(ws, "main", "MCP", 3, 120);
2704 assert!(wide.contains("MCP 3") && wide.contains("main") && wide.contains(ws));
2705
2706 let mid = empty_state_caption(ws, "main", "MCP", 3, 24);
2707 assert!(
2708 !mid.contains("MCP"),
2709 "{mid:?} should shed the MCP count first"
2710 );
2711 assert!(mid.contains("main"), "{mid:?} should still name the branch");
2712
2713 let tight = empty_state_caption(ws, "main", "MCP", 3, 16);
2714 assert!(
2715 tight.contains("app"),
2716 "{tight:?} should still name the folder"
2717 );
2718 }
2719
2720 #[test]
2721 fn elision_lands_on_a_separator_not_mid_component() {
2722 // The old line ended in an ellipsis mid-directory
2723 // ("…/34267917-11f4-4d15-911a-"), which told the reader nothing.
2724 let caption = empty_state_caption(DEEP, "no git", "MCP", 0, 60);
2725 assert!(
2726 !caption.contains("2a8acd5c49e1"),
2727 "{caption:?} clipped mid-component"
2728 );
2729 if caption.starts_with('…') {
2730 assert!(
2731 caption.starts_with("…/"),
2732 "elision must land on a separator: {caption:?}",
2733 );
2734 }
2735 }
2736
2737 #[test]
2738 fn caption_margin_scales_so_it_is_always_visibly_a_caption() {
2739 // The flat four-column margin only looked like a margin at 60 columns.
2740 // At 119 it let a 114-column path through with an inset of two — a
2741 // full-bleed banner cutting the centred composition in half, which is
2742 // the exact failure the shedding ladder exists to prevent.
2743 for width in [40usize, 60, 80, 100, 119, 120, 200] {
2744 for workspace in [DEEP, "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/project"] {
2745 let caption = empty_state_caption(workspace, "main", "MCP", 2, width);
2746 let inset = width.saturating_sub(caption.width()) / 2;
2747 assert!(
2748 inset * 12 >= width,
2749 "width {width}: caption {caption:?} insets by only {inset}",
2750 );
2751 }
2752 }
2753 }
2754
2755 #[test]
2756 fn shorten_workspace_is_a_no_op_when_it_already_fits() {
2757 assert_eq!(shorten_workspace("~/code/app", 2), "~/code/app".to_string());
2758 assert_eq!(shorten_workspace("app", 2), "app".to_string());
2759 }
2760 }
2761
2762 // ---------------------------------------------------------------------------
2763 // Launch motion scheduling: the bounded mark reveal, a real dissolve, or
2764 // an active water field requests frames through the existing scheduler.
2765 // ---------------------------------------------------------------------------
2766
2767 /// Whether the launch screen has a visible transition or ambient scene.
2768 #[must_use]
2769 pub fn launch_motion_active(app: &App, obscured: bool, ambient_settled: bool) -> bool {
2770 if !app.launch.visible
2771 || obscured
2772 || app.onboarding != OnboardingState::None
2773 || !app.view_stack.is_empty()
2774 || !app.motion_policy().allows_decorative()
2775 {
2776 return false;
2777 }
2778 let now = app.ambient_clock_ms;
2779 let dissolve = app.launch.card_dissolve_progress(now, true);
2780 let dissolving = dissolve > 0.0 && dissolve < 1.0;
2781 let water_alive = app.theme_id == codewhale_palette::ThemeId::Underwater && !ambient_settled;
2782 let revealing = !app.launch.return_to_session
2783 && !crate::tui::color_compat::ascii_safe_enabled()
2784 && app
2785 .launch
2786 .mark_reveal_started_at
2787 .is_some_and(|started| started.elapsed().as_millis() < crate::tui::mark::REVEAL_MS);
2788 revealing || dissolving || water_alive
2789 }
2790
2790 lines RUST