返回 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 // A child agent's unanswered approval or question is the person's
641 // move, even while other agents keep working: the footer says
642 // "waiting on you", not "agents underway" (#6565).
643 if app.pending_user_input_prompt.is_some()
644 || !app.pending_child_requests.is_empty()
645 || app
646 .task_panel
647 .iter()
648 .any(|task| matches!(task.status.as_str(), "waiting" | "needs_user"))
649 {
650 return Self::Waiting;
651 }
652 if app.is_loading
653 || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
654 || activity.is_explicit()
655 {
656 if activity.kind() == LiveActivityKind::Verifying {
657 return Self::Verifying;
658 }
659 return Self::Working;
660 }
661 if !app.input.is_empty() {
662 return Self::Typing;
663 }
664 if matches!(app.runtime_turn_status.as_deref(), Some("completed")) {
665 return Self::Done;
666 }
667 Self::Idle
668 }
669
670 #[must_use]
671 pub fn label(self, locale: Locale) -> Cow<'static, str> {
672 match self {
673 Self::Idle => tr(locale, MessageId::PhaseIdle),
674 Self::Typing => tr(locale, MessageId::PhaseDraft),
675 Self::Working => tr(locale, MessageId::PhaseWorking),
676 Self::Verifying => tr(locale, MessageId::PhaseVerifying),
677 Self::Waiting | Self::Approval => tr(locale, MessageId::PhaseWaitingOnYou),
678 Self::Done => tr(locale, MessageId::PhaseDone),
679 Self::Failed => tr(locale, MessageId::PhaseFailed),
680 }
681 }
682 }
683
684 /// Exhaustive on purpose: a new [`AppMode`] must be handed a Policy ink
685 /// deliberately rather than inheriting act's by falling through a wildcard.
686 fn header_mode_ink(mode: AppMode) -> ChromeInk {
687 match mode {
688 AppMode::Plan => ChromeInk::PolicyPlan,
689 AppMode::Operate => ChromeInk::PolicyOperate,
690 AppMode::Agent => ChromeInk::PolicyAct,
691 }
692 }
693
694 fn header_permission_ink(mode: ApprovalMode) -> ChromeInk {
695 match mode {
696 ApprovalMode::Suggest | ApprovalMode::Never => ChromeInk::PermissionAsk,
697 ApprovalMode::Auto => ChromeInk::PermissionAutoReview,
698 ApprovalMode::Bypass => ChromeInk::PermissionFullAccess,
699 }
700 }
701
702 /// One posture word with its ink — the unit the classic header's lockup was
703 /// made of, now carried as merged-footer chips.
704 pub(crate) type PostureChip = (Cow<'static, str>, ChromeInk);
705
706 /// The posture lockup as two standalone chips for the Tideline merged
707 /// footer (spec §3: the old header's mode/permission chips move into the
708 /// footer activity segment). Same words, same inks, and the same mapping
709 /// the classic header used — [`header_mode_ink`] for the mode word,
710 /// [`header_permission_ink`] for the permission phrase. The filesystem
711 /// scope notice, when it deviates, folds into the permission chip's text
712 /// (the header already painted it in the permission ink).
713 pub(crate) fn posture_chips(app: &App) -> (Option<PostureChip>, Option<PostureChip>) {
714 let mode = (
715 mode_label(app.ui_locale, app.mode),
716 header_mode_ink(app.mode),
717 );
718 let mut permission = (
719 permission_label(app),
720 header_permission_ink(app.approval_mode),
721 );
722 if let Some(scope) = filesystem_scope_notice(app) {
723 permission.0 = format!("{} · {scope}", permission.0).into();
724 }
725 (Some(mode), Some(permission))
726 }
727
728 /// Summarize only tools whose lifecycle is actually `Running`. A read label
729 /// is earned only when every running entry is read/exploration-shaped; mixed
730 /// work stays the neutral `using tool`. Verification wins because it is the
731 /// existing stronger promise made by the phase strip.
732 fn running_tool_facts(app: &App) -> RunningToolFacts {
733 use crate::tui::history::{HistoryCell, ToolCell, ToolStatus};
734 use crate::tui::widgets::tool_card::{ToolFamily, tool_family_for_name};
735
736 let mut facts = RunningToolFacts::default();
737 let Some(active) = app.active_cell.as_ref() else {
738 return facts;
739 };
740 for cell in active.entries() {
741 let HistoryCell::Tool(tool) = cell else {
742 continue;
743 };
744 match tool {
745 ToolCell::Exec(exec) if exec.status == ToolStatus::Running => {
746 facts.observe(false, exec_is_verification(&exec.command));
747 }
748 ToolCell::Generic(generic) if generic.status == ToolStatus::Running => {
749 let family = tool_family_for_name(&generic.name);
750 facts.observe(
751 matches!(family, ToolFamily::Read | ToolFamily::Find),
752 family == ToolFamily::Verify || generic.name == "read_lints",
753 );
754 }
755 ToolCell::Exploring(exploring) => {
756 for entry in &exploring.entries {
757 if entry.status == ToolStatus::Running {
758 facts.observe(true, false);
759 }
760 }
761 }
762 ToolCell::WebSearch(search) if search.status == ToolStatus::Running => {
763 facts.observe(true, false);
764 }
765 other if other.status() == Some(ToolStatus::Running) => {
766 facts.observe(false, false);
767 }
768 _ => {}
769 }
770 }
771 facts
772 }
773
774 fn exec_is_verification(command: &str) -> bool {
775 let trimmed = command.trim_start();
776 let mut tokens = trimmed.split_whitespace();
777 let first = tokens.next().unwrap_or("");
778 let second = tokens.next().unwrap_or("");
779 match first {
780 "cargo" => matches!(second, "test" | "check" | "clippy" | "nextest"),
781 "go" => matches!(second, "test" | "vet"),
782 "npm" | "pnpm" | "yarn" | "bun" => matches!(second, "test" | "lint" | "check"),
783 "make" => matches!(second, "test" | "check" | "lint"),
784 "python" | "python3" => trimmed.contains("-m pytest") || trimmed.contains("-m unittest"),
785 "pytest" | "jest" | "vitest" | "tsc" | "eslint" | "ruff" | "mypy" | "clippy-driver"
786 | "golangci-lint" | "shellcheck" => true,
787 _ => false,
788 }
789 }
790
791 fn completion_elapsed_ms(app: &App) -> Option<u128> {
792 if !app.motion_policy().allows_decorative() {
793 return None;
794 }
795 app.ocean_completion_started_at
796 .map(|started| started.elapsed().as_millis())
797 .filter(|elapsed| *elapsed < COMPLETION_BREATH_MS)
798 }
799
800 /// Truthful window-title activity verb for the OSC-0 whale animation.
801 ///
802 /// Uses short English fragments (with fixed-width ellipsis) so alt-tabbed
803 /// sessions stay legible without depending on the full localized phase strip.
804 #[must_use]
805 pub(crate) fn title_activity_verb(app: &App) -> &'static str {
806 let activity = LiveActivity::from_app(app);
807 let phase = ShellPhase::from_app_with_activity(app, activity);
808 match phase {
809 ShellPhase::Waiting | ShellPhase::Approval => "waiting on you…",
810 ShellPhase::Verifying => "verifying…",
811 ShellPhase::Done => "done",
812 ShellPhase::Failed => "failed",
813 ShellPhase::Typing => "drafting…",
814 ShellPhase::Idle => "idle",
815 ShellPhase::Working => match activity.kind() {
816 LiveActivityKind::Compacting | LiveActivityKind::AutoCompacting => {
817 "compacting context…"
818 }
819 LiveActivityKind::Reasoning => "reasoning…",
820 LiveActivityKind::Reading => "reading…",
821 LiveActivityKind::UsingTool => "using tool…",
822 LiveActivityKind::UsingSubagents => "fleet underway…",
823 LiveActivityKind::Verifying => "verifying…",
824 LiveActivityKind::Working => "working…",
825 },
826 }
827 }
828
829 /// Push the current shell phase into the terminal title whale animation.
830 pub(crate) fn sync_title_activity(app: &App) {
831 crate::tui::notifications::set_title_motion_enabled(
832 app.motion_policy().allows_decorative() && app.status_indicator != "off",
833 );
834 // Keep the `[title] …` window-title prefix in step with the session and
835 // config defaults; change detection inside makes this free when nothing
836 // moved.
837 crate::tui::notifications::set_title_prefix(app.window_title_prefix());
838 if app.is_loading
839 || matches!(
840 ShellPhase::from_app(app),
841 ShellPhase::Working
842 | ShellPhase::Verifying
843 | ShellPhase::Waiting
844 | ShellPhase::Approval
845 | ShellPhase::Typing
846 )
847 {
848 crate::tui::notifications::set_title_activity_verb(title_activity_verb(app));
849 }
850 }
851
852 pub(crate) fn phase_marker_with_activity(
853 app: &App,
854 phase: ShellPhase,
855 activity: LiveActivity,
856 ) -> (&'static str, Cow<'static, str>) {
857 let locale = app.ui_locale;
858 match phase {
859 ShellPhase::Idle => ("·", phase.label(locale)),
860 ShellPhase::Typing => ("›", phase.label(locale)),
861 ShellPhase::Working => {
862 // The footer and the live tool card share one wall-clock cadence,
863 // so the two primary liveness marks never look like unrelated
864 // spinners. The shared helper also preserves the 400ms
865 // "motion is earned" delay and reduced/still fallback.
866 let policy = app.motion_policy();
867 let animated = crate::tui::spinner::braille_spinner_frame(app.turn_started_at, false);
868 let earned = app.turn_started_at.is_none_or(|started| {
869 started.elapsed().as_millis()
870 >= u128::from(crate::tui::spinner::LIVE_MARKER_DELAY_MS)
871 });
872 let frame = policy.spinner_glyph(animated, earned);
873 (frame, activity.label(locale))
874 }
875 ShellPhase::Verifying => {
876 // Metered braille tick on the shared live clock — checking, not
877 // searching. Reduced motion holds the legible mid frame.
878 let policy = app.motion_policy();
879 let animated = crate::tui::spinner::verification_tick_frame(app.turn_started_at, false);
880 let earned = app.turn_started_at.is_none_or(|started| {
881 started.elapsed().as_millis()
882 >= u128::from(crate::tui::spinner::LIVE_MARKER_DELAY_MS)
883 });
884 let frame = policy.spinner_glyph(animated, earned);
885 (frame, phase.label(locale))
886 }
887 ShellPhase::Waiting | ShellPhase::Approval => ("◆", phase.label(locale)),
888 ShellPhase::Done => match completion_elapsed_ms(app) {
889 Some(elapsed) if elapsed < COMPLETION_RELEASE_MS => {
890 let index = ((elapsed / 140) as usize + 4).min(WORKING_BUBBLE_FRAMES.len() - 1);
891 (WORKING_BUBBLE_FRAMES[index], phase.label(locale))
892 }
893 _ => (crate::tui::glyphs::DONE, phase.label(locale)),
894 },
895 ShellPhase::Failed => (crate::tui::glyphs::FAILED, phase.label(locale)),
896 }
897 }
898
899 fn mode_label(locale: Locale, mode: AppMode) -> Cow<'static, str> {
900 match mode {
901 AppMode::Agent => tr(locale, MessageId::ChipModeAct),
902 AppMode::Plan => tr(locale, MessageId::ChipModePlan),
903 AppMode::Operate => tr(locale, MessageId::ChipModeOperate),
904 }
905 }
906
907 /// Permission chip words. This maps from the typed [`ApprovalMode`] state —
908 /// never from the English `permission_chip_label()` strings — so localizing
909 /// (or rewording) the upstream chip labels can never silently break the chip.
910 ///
911 /// Tool-approval posture only. Filesystem scope is a separate fact and only
912 /// earns header columns when it is worth reading — see
913 /// [`filesystem_scope_notice`].
914 fn permission_label(app: &App) -> Cow<'static, str> {
915 let locale = app.ui_locale;
916 if app.mode == AppMode::Plan {
917 return tr(locale, MessageId::ChipPermissionReadOnly);
918 }
919 match app.approval_mode {
920 ApprovalMode::Suggest => tr(locale, MessageId::ChipPermissionAsk),
921 ApprovalMode::Auto => tr(locale, MessageId::ChipPermissionAuto),
922 // Keep the effective permission explicit. `bypass` is an
923 // implementation detail and, more importantly, can imply that
924 // repository law no longer applies. Full Access never bypasses
925 // constitution rules. This is **tool-approval posture**, not
926 // filesystem scope — see filesystem_scope_notice.
927 ApprovalMode::Bypass => tr(locale, MessageId::ChipPermissionFullAccess),
928 ApprovalMode::Never => tr(locale, MessageId::ChipPermissionNever),
929 }
930 }
931
932 /// The effective filesystem scope — but only when it says something the
933 /// permission word beside it does not already say.
934 ///
935 /// This chip exists because "Full Access" (tool approval) was being read as
936 /// unrestricted disk writes (user report, 2026-07-23), and because a policy
937 /// with no enforcement backend used to name a boundary nobody applied
938 /// (2026-08-04 audit). Both of those are deviations. The default — an
939 /// enforced workspace-write boundary — is what every ordinary session already
940 /// has, and printing `files: workspace` on every frame of every session spent
941 /// seventeen columns of the primary chrome saying so. A notice that is always
942 /// on cannot signal anything; folding the expected case away is what lets
943 /// `files: workspace (unenforced)` and the Full-Access-but-confined case land
944 /// as warnings when they do appear.
945 ///
946 /// `read-only` under Plan is dropped for the same reason from the other side:
947 /// the permission word there is already the literal phrase "read only".
948 #[must_use]
949 fn filesystem_scope_notice(app: &App) -> Option<Cow<'static, str>> {
950 // Spelled out because the old `fs:` prefix read as an unexplained
951 // acronym (user report, 2026-07-23): this chip states which files the
952 // session may write.
953 let policy = crate::core::authority::sandbox_policy_for_turn(
954 app.mode,
955 app.approval_mode,
956 app.configured_sandbox_mode.as_deref(),
957 &app.workspace,
958 crate::core::authority::SandboxNetworkAccess::from_config(app.configured_sandbox_network),
959 );
960 // A policy is an intent; enforcement needs a backend. On default Linux
961 // (bubblewrap is opt-in) and on all Windows there is none. Say
962 // "unenforced" rather than name a boundary that is not applied.
963 // `DangerFullAccess` is already honest, and `ExternalSandbox` is enforced
964 // by the external runner, not by us.
965 let unenforced = app.sandbox_backend.is_none()
966 && !matches!(
967 policy,
968 crate::sandbox::SandboxPolicy::DangerFullAccess
969 | crate::sandbox::SandboxPolicy::ExternalSandbox { .. }
970 );
971 match policy {
972 crate::sandbox::SandboxPolicy::ReadOnly if unenforced => {
973 Some(Cow::Borrowed("files: read-only (unenforced)"))
974 }
975 crate::sandbox::SandboxPolicy::ReadOnly => {
976 (app.mode != AppMode::Plan).then_some(Cow::Borrowed("files: read-only"))
977 }
978 // `DangerFullAccess` only ever arises from the Bypass posture
979 // (`sandbox_policy_for_turn`), whose permission chip already reads
980 // "Full Access" two words to the left. The name is the disclosure;
981 // restating it as `files: full disk` spent columns saying it twice.
982 // The scope chip speaks in this posture only when the scope is
983 // *narrower* than the name implies (the WorkspaceWrite arm below).
984 crate::sandbox::SandboxPolicy::DangerFullAccess => None,
985 crate::sandbox::SandboxPolicy::ExternalSandbox { .. } => {
986 Some(Cow::Borrowed("files: external sandbox"))
987 }
988 crate::sandbox::SandboxPolicy::WorkspaceWrite { .. } if unenforced => {
989 Some(Cow::Borrowed("files: workspace (unenforced)"))
990 }
991 // The unremarkable case: writes are confined to the workspace and the
992 // OS is actually enforcing it. Saying so on every frame of every
993 // session spends the header on a fact nobody is asking about — with
994 // one exception. When the permission chip reads "Full Access", the
995 // scope chip is the only thing on screen that says the writes are
996 // still confined. Suppressing it there recreates precisely the
997 // misreading the chip was added for (tool-approval "Full Access" taken
998 // to mean unrestricted disk writes), and that pairing is reachable:
999 // Bypass with a configured `workspace-write` is clamped to this policy
1000 // by `sandbox_policy_for_turn`.
1001 crate::sandbox::SandboxPolicy::WorkspaceWrite { .. } => {
1002 (app.approval_mode == ApprovalMode::Bypass).then_some(Cow::Borrowed("files: workspace"))
1003 }
1004 }
1005 }
1006
1007 fn truncate_to_width(text: &str, width: usize) -> String {
1008 if text.width() <= width {
1009 return text.to_string();
1010 }
1011 if width == 0 {
1012 return String::new();
1013 }
1014 if width <= 3 {
1015 return ".".repeat(width);
1016 }
1017 let mut result = String::new();
1018 let mut used = 0;
1019 for ch in text.chars() {
1020 let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
1021 if used + ch_width + 1 > width {
1022 break;
1023 }
1024 result.push(ch);
1025 used += ch_width;
1026 }
1027 result.push('…');
1028 result
1029 }
1030
1031 /// The transcript rows the idle brand mark needs before it will draw at all.
1032 ///
1033 /// Named so the *layout* can honour it before the frame is split. Anything that reserves rows above
1034 /// the transcript must subtract against this constant rather than guess, or
1035 /// the reservation and the render gate drift and the mark is evicted by
1036 /// chrome that was sized without knowing the mark existed.
1037 pub(crate) const AMBIENT_MIN_CHAT_HEIGHT: u16 = 16;
1038 /// Companion column floor, same reasoning as [`AMBIENT_MIN_CHAT_HEIGHT`].
1039 pub(crate) const AMBIENT_MIN_CHAT_WIDTH: u16 = 60;
1040
1041 /// Build the post-launch idle composition: brand, workspace context, and one
1042 /// direct invitation. Commands stay in the command surface instead of reading
1043 /// like onboarding homework.
1044 ///
1045 /// Expressed in terms of the ambient floor constants so the layout rule that
1046 /// reserves the rows and the gate that spends them cannot disagree. (The old
1047 /// spelling also tested `height >= 14 && width >= 28`, which was dead: the
1048 /// tier check already demands 16 rows and 60 columns.)
1049 #[must_use]
1050 pub(crate) fn empty_state_mark_visible(area: Rect) -> bool {
1051 area.height >= AMBIENT_MIN_CHAT_HEIGHT && area.width >= AMBIENT_MIN_CHAT_WIDTH
1052 }
1053
1054 #[must_use]
1055 pub(crate) fn decorative_shell_motion_enabled(app: &App) -> bool {
1056 app.motion_policy().allows_decorative()
1057 && !app.attention_hold_active()
1058 && app.onboarding == OnboardingState::None
1059 && !app.launch.visible
1060 && app.view_stack.is_empty()
1061 }
1062
1063 /// Shorten a workspace path to its trailing components, marked with a leading
1064 /// ellipsis so it reads as "somewhere above here" rather than as a real path.
1065 fn shorten_workspace(workspace: &str, keep: usize) -> String {
1066 let sep = if workspace.contains('/') { '/' } else { '\\' };
1067 let parts: Vec<&str> = workspace.split(sep).filter(|p| !p.is_empty()).collect();
1068 if parts.len() <= keep {
1069 return workspace.to_string();
1070 }
1071 let tail = parts[parts.len() - keep..].join(&sep.to_string());
1072 let shortened = format!("…{sep}{tail}");
1073 // Only elide when it actually buys width. `~/code/app` -> `…/code/app` is
1074 // the same length and throws away the `~`, which carries more meaning than
1075 // the ellipsis does.
1076 if shortened.width() >= workspace.width() {
1077 return workspace.to_string();
1078 }
1079 shortened
1080 }
1081
1082 /// Compose the empty-state caption so the caller's centering can survive.
1083 ///
1084 /// This line sits between the wordmark and "What do you want to accomplish?",
1085 /// and every other element of that block is centered. It used to be built at
1086 /// full length and then handed to `truncate_to_width(.., width)`, which made it
1087 /// exactly `width` wide — so the caller's `(width - context.width()) / 2` inset
1088 /// evaluated to zero and the caption rendered flush-left, full-bleed, cutting
1089 /// the composition in half. The clipping also destroyed the information: an
1090 /// absolute path truncated mid-directory ("…/34267917-11f4-4d15-911a-…") tells
1091 /// the reader nothing about where they are.
1092 ///
1093 /// So the caption sheds detail rather than getting cut. In order of what goes
1094 /// first: the MCP count, then the branch, then the leading path components. The
1095 /// folder you are in is the last thing to go, because it is the only part a
1096 /// person actually reads here.
1097 ///
1098 /// One rule was added after watching it at 120 columns: the margin is
1099 /// proportional, not a flat four. A flat four let a 114-column path "fit" a
1100 /// 119-column lane, which put the centring inset back at two and reproduced
1101 /// the full-bleed banner this function exists to prevent — the same failure,
1102 /// arrived at from the other direction. A sixth of the lane, split either
1103 /// side, means the caption is always visibly a caption.
1104 fn empty_state_caption(
1105 workspace: &str,
1106 branch: &str,
1107 mcp_label: &str,
1108 mcp_count: usize,
1109 width: usize,
1110 ) -> String {
1111 // Leave a margin so the line is visibly inset rather than merely fitting,
1112 // and scale it, because "four columns" is only a margin at 60 columns.
1113 let budget = width.saturating_sub((width / 6).max(4)).max(8);
1114 let candidates = [
1115 format!("{workspace} · {branch} · {mcp_label} {mcp_count}"),
1116 format!("{workspace} · {branch}"),
1117 workspace.to_string(),
1118 format!("{} · {branch}", shorten_workspace(workspace, 2)),
1119 shorten_workspace(workspace, 2),
1120 shorten_workspace(workspace, 1),
1121 ];
1122 for candidate in &candidates {
1123 if candidate.width() <= budget {
1124 return candidate.clone();
1125 }
1126 }
1127 // Nothing fit: the last resort is the folder name alone, and the caller
1128 // still clamps. Better a bare name than a path clipped mid-component.
1129 shorten_workspace(workspace, 1)
1130 }
1131
1132 /// The launch card as the idle transcript's own content, plus where its
1133 /// clickable rows landed.
1134 ///
1135 /// The opening screen used to be a second surface: its own layout, its own
1136 /// composer widget, its own input authority. Founder ruling: "we don't have
1137 /// to have a different look for the opening screen ... we can make it an
1138 /// asset that exists there instead". So it is drawn as the empty state of the
1139 /// ordinary transcript — the ocean, the water and the chrome underneath it are
1140 /// the ones every other screen already uses, and the composer below it is the
1141 /// real one.
1142 pub struct LaunchEmptyState {
1143 pub lines: Vec<Line<'static>>,
1144 /// Text lane relative to the paint area. Outer whitespace is not a
1145 /// control; selection and pointer targets share this lane.
1146 text_column: Rect,
1147 /// Clickable rows as `(id, row index within `lines`)`. The caller turns
1148 /// these into rects against the painted area, so hitboxes and glyphs
1149 /// cannot drift apart.
1150 pub rows: Vec<(crate::tui::app::LaunchRowId, usize)>,
1151 }
1152
1153 /// Minimum left indent. Wider terminals balance the bounded reading lane
1154 /// inside the transcript rather than leaving it stranded against one edge.
1155 const LAUNCH_BLOCK_INDENT: usize = 2;
1156 /// The card's reading measure: a row is a title with its detail set against
1157 /// it, and without a ceiling the detail right-aligns against the terminal's
1158 /// far edge. The title is primary; the relative age is secondary.
1159 const LAUNCH_CARD_MEASURE: usize = 72;
1160 /// Gap between a row's title and its right-aligned detail.
1161 const LAUNCH_ROW_GAP: usize = 3;
1162 /// Below this the row spends its whole lane on the title and sheds the detail.
1163 const LAUNCH_ROW_MIN_TITLE: usize = 28;
1164 /// Labels align with their heading; the action cue has its own gutter.
1165 /// Blank rows the card spends on rhythm when the pane is tall enough.
1166 const LAUNCH_SEPARATORS: usize = 3;
1167 /// Blank rows per separator when the pane can afford them.
1168 const LAUNCH_GAP_ROOMY: usize = 2;
1169 /// Below this width the block gives up its left indent.
1170 const LAUNCH_INDENT_MIN_WIDTH: usize = 12;
1171
1172 pub fn empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
1173 if area.width == 0 || area.height == 0 {
1174 return Vec::new();
1175 }
1176 // The opening screen is this screen: the launch card is the idle
1177 // transcript's own content, not a second surface painted over it.
1178 if app.launch.visible {
1179 // The first keystroke starts the dissolve clock; the card sinks by
1180 // ink, not by position — every span eases toward the water behind it
1181 // over `LAUNCH_CARD_DISSOLVE_MS`, and at the end the ambient surface
1182 // (wordmark, caption, prompt) is what remains. Reduced motion takes
1183 // the endpoint at once.
1184 let motion_allowed = app.motion_policy().allows_decorative() && !app.low_motion;
1185 let dissolve = app
1186 .launch
1187 .card_dissolve_progress(app.ambient_clock_ms, motion_allowed);
1188 if dissolve < 1.0 {
1189 let mut state = launch_empty_state(app, area);
1190 if dissolve > 0.0 {
1191 fade_lines(&mut state.lines, dissolve, app.ui_theme.surface_bg);
1192 }
1193 return state.lines;
1194 }
1195 }
1196 let width = usize::from(area.width);
1197 let mut lines = vec![Line::from(""); usize::from(area.height / 4)];
1198 // The idle whale portrait that used to open this block was deleted per
1199 // the 2026-08-29 founder directive; the ambient empty-state surface
1200 // (wordmark, context caption, prompt) is not whale art and stays.
1201
1202 let identity = crate::tui::workspace_context::identity_from_context(
1203 &app.workspace,
1204 app.workspace_context.as_deref(),
1205 );
1206 let workspace = crate::utils::display_path(&app.workspace);
1207 let branch = identity.branch.as_deref().map_or_else(
1208 || tr(app.ui_locale, MessageId::EmptyStateNoGit),
1209 |branch| Cow::Owned(branch.to_string()),
1210 );
1211 // Compact used to bypass the caption entirely and print the bare branch,
1212 // which in a plain folder rendered as the single centred word "no git" —
1213 // a whole row of the hero spent naming something that is not there. The
1214 // shedding ladder already degrades gracefully at any width, so every tier
1215 // now goes through it.
1216 let context = empty_state_caption(
1217 &workspace,
1218 &branch,
1219 tr(app.ui_locale, MessageId::EmptyStateMcpLabel).as_ref(),
1220 app.mcp_configured_count,
1221 width,
1222 );
1223 let brand = "codewhale";
1224 let brand_inset = " ".repeat(width.saturating_sub(brand.width()) / 2);
1225 lines.push(Line::from(Span::styled(
1226 format!("{brand_inset}{brand}"),
1227 Style::default()
1228 .fg(app.ui_theme.text_body)
1229 .add_modifier(Modifier::BOLD),
1230 )));
1231 let context = truncate_to_width(&context, width);
1232 let inset = " ".repeat(width.saturating_sub(context.width()) / 2);
1233 lines.push(Line::from(Span::styled(
1234 format!("{inset}{context}"),
1235 Style::default().fg(app.ui_theme.text_soft),
1236 )));
1237 if area.height >= 4 {
1238 lines.push(Line::from(""));
1239 let prompt = tr(app.ui_locale, MessageId::EmptyStatePrompt);
1240 let prompt = truncate_to_width(prompt.as_ref(), width);
1241 let inset = " ".repeat(width.saturating_sub(prompt.width()) / 2);
1242 lines.push(Line::from(Span::styled(
1243 format!("{inset}{prompt}"),
1244 Style::default().fg(app.ui_theme.text_body),
1245 )));
1246 }
1247 lines
1248 }
1249
1250 /// The remedy the problems row prints, as the command Enter/click types into
1251 /// the composer (#6085): `/mcp login <name>` when a server wants a login,
1252 /// else `/mcp` for failures. `None` when nothing is wrong. One helper serves
1253 /// the row's tail and its action, so what is painted is what runs.
1254 pub(crate) fn mcp_remedy_command(app: &App) -> Option<String> {
1255 use crate::tui::session_boot::{McpServerBootState, PluginBootSummary, SessionBootSurface};
1256 let boot = SessionBootSurface::from_parts(
1257 app.mcp_snapshot.as_ref(),
1258 app.mcp_initializing,
1259 &app.mcp_connecting,
1260 app.mcp_configured_count,
1261 PluginBootSummary::default(),
1262 );
1263 let first_in = |state: McpServerBootState| -> Option<&str> {
1264 boot.servers
1265 .iter()
1266 .find(|row| row.state == state)
1267 .map(|row| row.name.as_str())
1268 };
1269 if let Some(name) = first_in(McpServerBootState::NeedsLogin) {
1270 return Some(format!("/mcp login {name}"));
1271 }
1272 first_in(McpServerBootState::Failed).map(|_| "/mcp".to_string())
1273 }
1274
1275 /// The launch screen's MCP block: what actually became of the configured
1276 /// servers, painted under the recent-work list.
1277 ///
1278 /// The Tideline footer has one clause for this whole fact, so a 23-server
1279 /// workspace rendered as `MCP · 1 connecting · alibaba-cloud-ops` — one
1280 /// arbitrary name, every failure hidden (founder, 2026-09-09). The launch
1281 /// screen has the rows the footer does not, so the two states that carry a
1282 /// remedy get a row each and *name* their servers; the healthy majority stays
1283 /// a count, because a list of things that worked is not information. A server
1284 /// that needs a login and a server that could not connect are different
1285 /// problems with different fixes, so they never share a row.
1286 ///
1287 /// State comes from [`crate::tui::session_boot::SessionBootSurface`], the one
1288 /// MCP status owner; this is only its launch projection, and it computes
1289 /// nothing about a server itself.
1290 ///
1291 /// The problems row is selectable (#6085): `problems_row` is its index within
1292 /// `lines`, which `launch_empty_state` turns into a hitbox so the row joins
1293 /// the card's shared paint/click/keyboard ordering. Enter or click types the
1294 /// printed remedy into the composer — the user sees the command before a
1295 /// second Enter sends it.
1296 struct McpLaunchBlock {
1297 lines: Vec<Line<'static>>,
1298 problems_row: Option<usize>,
1299 }
1300
1301 fn mcp_launch_lines(app: &App, text_width: usize) -> McpLaunchBlock {
1302 use crate::tui::session_boot::{
1303 ITEM_SEPARATOR, McpServerBootState, PluginBootSummary, SessionBootPhase, SessionBootSurface,
1304 };
1305
1306 // MCP only: the plugin half of the boot surface has its own footer chip
1307 // and its own screen, and walking the plugin registry every frame to
1308 // discard it would be waste.
1309 let boot = SessionBootSurface::from_parts(
1310 app.mcp_snapshot.as_ref(),
1311 app.mcp_initializing,
1312 &app.mcp_connecting,
1313 app.mcp_configured_count,
1314 PluginBootSummary::default(),
1315 );
1316 if boot.phase == SessionBootPhase::Hidden || text_width == 0 {
1317 return McpLaunchBlock {
1318 lines: Vec::new(),
1319 problems_row: None,
1320 };
1321 }
1322 let theme = &app.ui_theme;
1323 let locale = app.ui_locale;
1324 let names_in = |state: McpServerBootState| -> Vec<&str> {
1325 boot.servers
1326 .iter()
1327 .filter(|row| row.state == state)
1328 .map(|row| row.name.as_str())
1329 .collect()
1330 };
1331 let failed = names_in(McpServerBootState::Failed);
1332 let needs_login = names_in(McpServerBootState::NeedsLogin);
1333 let connected = names_in(McpServerBootState::Connected).len();
1334 // Before the first boot event the names have not arrived, but the count
1335 // has; without it a 23-server workspace would paint nothing at all.
1336 let connecting = names_in(McpServerBootState::Connecting)
1337 .len()
1338 .max(boot.connecting_without_names());
1339
1340 let indent = 0;
1341 let lane = text_width.saturating_sub(indent);
1342 let mut lines: Vec<Line<'static>> = Vec::new();
1343
1344 // The summary carries every non-zero state, because it is also the floor:
1345 // when the pane can afford one row of this block, that row still has to
1346 // say two servers failed. Order is by what the reader must act on, so the
1347 // tail shed below gives up `connected` first — and while anything is in
1348 // flight that clause leads, since a boot that has connected nothing yet
1349 // must never read as a boot that finished with nothing connected.
1350 // `label (n)` rather than `n label`: number agreement is a grammar this
1351 // renderer cannot get right in fifteen languages.
1352 let mut parts: Vec<String> = Vec::new();
1353 let mut count_part = |id: MessageId, count: usize| {
1354 if count > 0 {
1355 parts.push(format!("{} ({count})", tr(locale, id)));
1356 }
1357 };
1358 count_part(MessageId::McpStateConnecting, connecting);
1359 count_part(MessageId::McpStateFailed, failed.len());
1360 count_part(MessageId::McpStateAuthorizationRequired, needs_login.len());
1361 count_part(MessageId::ExtensionsStateConnected, connected);
1362 if parts.is_empty() {
1363 parts.push(format!(
1364 "{} (0)",
1365 tr(locale, MessageId::ExtensionsStateConnected)
1366 ));
1367 }
1368 let mut summary = format!("MCP{ITEM_SEPARATOR}{}", parts.join(ITEM_SEPARATOR));
1369 while parts.len() > 1 && text_display_width(&summary) > text_width {
1370 parts.pop();
1371 summary = format!("MCP{ITEM_SEPARATOR}{}", parts.join(ITEM_SEPARATOR));
1372 }
1373 lines.push(Line::from(Span::styled(
1374 semantic_truncate(&summary, text_width),
1375 Style::default().fg(if !failed.is_empty() {
1376 theme.error_fg
1377 } else if !needs_login.is_empty() {
1378 theme.warning
1379 } else {
1380 theme.text_muted
1381 }),
1382 )));
1383
1384 // One problems row answers *which* and *what to type*: `✕` groups the
1385 // failed names, `⚠` switches the group to names that want a login, and
1386 // the remedy rides at the tail. Narrow panes shed the hint, then names
1387 // from the tail into `+N`, and finally the row itself — never the
1388 // summary. Glyph *and* state grouping carry the difference, so it
1389 // survives a monochrome terminal and a colour-blind reader.
1390 let mut problems_row = None;
1391 if !failed.is_empty() || !needs_login.is_empty() {
1392 let hint = match (needs_login.first(), failed.is_empty()) {
1393 (Some(name), true) => format!("/mcp login {name}"),
1394 (Some(name), false) => format!("/mcp{ITEM_SEPARATOR}/mcp login {name}"),
1395 (None, false) => "/mcp".to_string(),
1396 (None, true) => String::new(),
1397 };
1398 let problems = mcp_problems_row(&failed, &needs_login, &hint, lane);
1399 if let Some(text) = problems {
1400 let mut spans = Vec::with_capacity(2);
1401 if indent > 0 {
1402 spans.push(Span::raw(" ".repeat(indent)));
1403 }
1404 spans.push(Span::styled(
1405 text,
1406 Style::default().fg(if failed.is_empty() {
1407 theme.warning
1408 } else {
1409 theme.error_fg
1410 }),
1411 ));
1412 problems_row = Some(lines.len());
1413 lines.push(Line::from(spans));
1414 }
1415 }
1416 McpLaunchBlock {
1417 lines,
1418 problems_row,
1419 }
1420 }
1421
1422 /// One problems row: `✕ alibaba-cloud-ops · aws-mcp · ⚠ slack +4 · /mcp`.
1423 ///
1424 /// `✕` opens the failed group, `⚠` opens the needs-login group, and the
1425 /// remedy sits at the tail. Shedding folds names into `+N` from the tail
1426 /// while the remedy holds — at each width the named command is tried first,
1427 /// then bare `/mcp`, then none — and when even `✕ +2 · ⚠ +5` cannot fit
1428 /// the row is dropped whole: the summary above still says the count.
1429 fn mcp_problems_row(
1430 failed: &[&str],
1431 needs_login: &[&str],
1432 hint: &str,
1433 lane: usize,
1434 ) -> Option<String> {
1435 use crate::tui::glyphs::{ATTENTION, FAILED};
1436 use crate::tui::session_boot::ITEM_SEPARATOR;
1437
1438 let group = |glyph: &str, names: &[&str], shown: usize, row: &mut String| {
1439 if names.is_empty() {
1440 return;
1441 }
1442 if !row.is_empty() {
1443 row.push_str(ITEM_SEPARATOR);
1444 }
1445 row.push_str(glyph);
1446 row.push(' ');
1447 if shown > 0 {
1448 row.push_str(&names[..shown].join(ITEM_SEPARATOR));
1449 let extra = names.len() - shown;
1450 if extra > 0 {
1451 row.push_str(ITEM_SEPARATOR);
1452 row.push('+');
1453 row.push_str(&extra.to_string());
1454 }
1455 } else {
1456 row.push('+');
1457 row.push_str(&names.len().to_string());
1458 }
1459 };
1460 let total = failed.len() + needs_login.len();
1461 for shown in (0..=total).rev() {
1462 let failed_shown = shown.min(failed.len());
1463 let login_shown = shown.saturating_sub(failed_shown);
1464 let mut body = String::new();
1465 group(FAILED, failed, failed_shown, &mut body);
1466 group(ATTENTION, needs_login, login_shown, &mut body);
1467 for tail in [hint, "/mcp", ""] {
1468 if tail.is_empty() && !hint.is_empty() && shown > 0 {
1469 continue;
1470 }
1471 let mut row = body.clone();
1472 if !tail.is_empty() {
1473 row.push_str(ITEM_SEPARATOR);
1474 row.push_str(tail);
1475 }
1476 if text_display_width(&row) <= lane {
1477 return Some(row);
1478 }
1479 }
1480 }
1481 None
1482 }
1483
1484 /// Ease every painted glyph toward the water behind it. `dissolve` is
1485 /// `card_dissolve_progress` — 0 is full ink, 1 is gone. Spans without a
1486 /// foreground (padding, blanks) carry nothing to fade.
1487 fn fade_lines(lines: &mut [Line<'static>], dissolve: f32, water: Color) {
1488 for line in lines.iter_mut() {
1489 for span in line.spans.iter_mut() {
1490 if let Some(fg) = span.style.fg {
1491 span.style.fg = Some(crate::tui::mark::lerp_color(fg, water, dissolve));
1492 }
1493 }
1494 }
1495 }
1496
1497 /// How much of the card fits the pane. `New session` is never shed: it is the
1498 /// screen's one actionable choice.
1499 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1500 struct LaunchFit {
1501 brand: bool,
1502 context: bool,
1503 help: bool,
1504 notice: bool,
1505 /// The "no model connected · run /provider" line (UX-3).
1506 setup: bool,
1507 heading: bool,
1508 blanks: usize,
1509 shown: usize,
1510 see_all: bool,
1511 /// Rows of the MCP block, tail-first as the pane shrinks. The block
1512 /// always costs one separator row on top of these, so it never reads as
1513 /// another session in the list above it.
1514 mcp: usize,
1515 /// Blank rows per separator. A pane with height to spare spends it on
1516 /// breathing room before it spends it on more content: one blank line
1517 /// between blocks packs the card into the top-left corner of a tall
1518 /// terminal and reads as clutter even when every row is earning its place.
1519 /// This is the first thing shed, so a short pane is unaffected.
1520 gap: usize,
1521 }
1522
1523 impl LaunchFit {
1524 const fn rows(self) -> usize {
1525 (self.brand as usize)
1526 + (self.context as usize)
1527 + (self.help as usize)
1528 + (self.notice as usize)
1529 + (self.setup as usize)
1530 + self.blanks * self.gap
1531 + 1
1532 + (self.heading as usize)
1533 + self.shown
1534 + (self.see_all as usize)
1535 + self.mcp
1536 + (self.mcp > 0) as usize * self.gap
1537 }
1538 }
1539
1540 /// Shed the card down to `height`, in a fixed order: rhythm, the migration
1541 /// notice, the MCP block's detail, identity/help chrome, then the tail of
1542 /// the recent list. The overflow row keeps any hidden sessions reachable.
1543 /// The "no model connected" line goes last of all: on a keyless first run
1544 /// it is the only thing on the card that explains why nothing will answer.
1545 ///
1546 /// The MCP block gives up its rows before the recent list does (recent work
1547 /// is what the screen is *for*) but keeps its summary line until almost
1548 /// everything else has gone, because "2 failed" in one row still tells the
1549 /// truth that the footer chip could not.
1550 fn launch_fit(
1551 height: usize,
1552 recent: usize,
1553 has_more: bool,
1554 notice: bool,
1555 mcp: usize,
1556 setup: bool,
1557 ) -> LaunchFit {
1558 let mut fit = LaunchFit {
1559 brand: true,
1560 context: true,
1561 help: true,
1562 notice,
1563 setup,
1564 heading: recent > 0 || has_more,
1565 blanks: LAUNCH_SEPARATORS,
1566 shown: recent,
1567 see_all: has_more,
1568 mcp,
1569 gap: LAUNCH_GAP_ROOMY,
1570 };
1571 let mut step = 0u8;
1572 while fit.rows() > height {
1573 match step {
1574 // Breathing room is the first luxury to go, before any content.
1575 0 => fit.gap = 1,
1576 1 => fit.blanks = 1,
1577 2 => fit.blanks = 0,
1578 3 => fit.notice = false,
1579 4 => {
1580 while fit.mcp > 1 && fit.rows() > height {
1581 fit.mcp -= 1;
1582 }
1583 }
1584 5 => fit.help = false,
1585 6 => fit.context = false,
1586 7 => fit.heading = false,
1587 8 => fit.brand = false,
1588 9 => {
1589 while fit.shown > 0 && fit.rows() > height {
1590 fit.shown -= 1;
1591 fit.see_all = true;
1592 }
1593 }
1594 10 => fit.mcp = 0,
1595 11 => fit.see_all = false,
1596 12 => fit.setup = false,
1597 _ => break,
1598 }
1599 step += 1;
1600 }
1601 fit
1602 }
1603
1604 pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
1605 if area.width == 0 || area.height == 0 {
1606 return LaunchEmptyState {
1607 lines: Vec::new(),
1608 rows: Vec::new(),
1609 text_column: Rect::default(),
1610 };
1611 }
1612 let width = usize::from(area.width);
1613 let height = usize::from(area.height);
1614 let theme = &app.ui_theme;
1615 let locale = app.ui_locale;
1616 let mut lines: Vec<Line<'static>> = Vec::new();
1617 let mut rows: Vec<(crate::tui::app::LaunchRowId, usize)> = Vec::new();
1618
1619 // One reading lane: identity never steals width from session titles.
1620 // Reserve a two-cell action gutter where the terminal can afford it.
1621 let block_indent = if width >= LAUNCH_INDENT_MIN_WIDTH {
1622 LAUNCH_BLOCK_INDENT.max(width.saturating_sub(LAUNCH_CARD_MEASURE + 2) / 2)
1623 } else {
1624 0
1625 };
1626 let action_gutter = if width >= LAUNCH_INDENT_MIN_WIDTH {
1627 2
1628 } else {
1629 0
1630 };
1631 let text_indent = block_indent + action_gutter;
1632 let text_width = width
1633 .saturating_sub(text_indent + block_indent)
1634 .min(LAUNCH_CARD_MEASURE);
1635 if text_width == 0 {
1636 return LaunchEmptyState {
1637 lines: Vec::new(),
1638 rows: Vec::new(),
1639 text_column: Rect::default(),
1640 };
1641 }
1642
1643 let (entries, has_more) = launch_recent_entries(app);
1644 // Nothing will answer a message until a model is connected; the card
1645 // says so instead of letting the first Enter fail silently (UX-3).
1646 let no_model_connected = app.onboarding_needs_api_key;
1647 // Built before the fit ladder runs: how many rows the block wants is a
1648 // fact about this workspace's servers, not about the pane.
1649 let mcp_block = mcp_launch_lines(app, text_width);
1650 // Brand occupies the header only; recent titles retain the whole reading lane.
1651 // Scale the canonical raster derivative before sacrificing any controls.
1652 use crate::tui::mark::MarkSize;
1653 let mut mark = if crate::tui::color_compat::ascii_safe_enabled() {
1654 None
1655 } else if height >= 14 && text_width >= 40 {
1656 Some(MarkSize::Large)
1657 } else if height >= 8 && text_width >= 26 {
1658 Some(MarkSize::Small)
1659 } else if height >= 4 && text_width >= 19 {
1660 Some(MarkSize::Tiny)
1661 } else {
1662 None
1663 };
1664 let mark_extra = mark.map_or(0, |size| usize::from(size.cells().1).saturating_sub(2));
1665 let mut fit = launch_fit(
1666 height.saturating_sub(mark_extra),
1667 entries.len(),
1668 has_more,
1669 app.launch.claude_code_detected,
1670 mcp_block.lines.len(),
1671 no_model_connected,
1672 );
1673 if mark.is_some() && !(fit.brand && fit.context) {
1674 // At the absolute height floor the wordmark yields to the actions too.
1675 mark = None;
1676 fit = launch_fit(
1677 height,
1678 entries.len(),
1679 has_more,
1680 app.launch.claude_code_detected,
1681 mcp_block.lines.len(),
1682 no_model_connected,
1683 );
1684 }
1685 let header_width =
1686 text_width.saturating_sub(mark.map_or(0, |size| usize::from(size.cells().0) + 2));
1687 let spacious = fit.blanks == LAUNCH_SEPARATORS;
1688 let visible: Vec<LaunchRecentEntry> = entries.into_iter().take(fit.shown).collect();
1689 let card_rows = home_card_rows(app, &visible, fit.see_all);
1690
1691 // The text column, in order. `None` is a blank row.
1692 let mut text: Vec<Option<Line<'static>>> = Vec::new();
1693 if fit.brand {
1694 let brand = "codewhale";
1695 let version = format!("v{}", env!("CODEWHALE_BUILD_VERSION"));
1696 let mut spans = vec![Span::styled(
1697 semantic_truncate(brand, header_width),
1698 Style::default().fg(theme.accent_primary).bold(),
1699 )];
1700 if header_width >= text_display_width(brand) + 1 + text_display_width(&version) {
1701 spans.push(Span::styled(
1702 format!(
1703 "{}{version}",
1704 " ".repeat(
1705 header_width - text_display_width(brand) - text_display_width(&version)
1706 )
1707 ),
1708 Style::default().fg(theme.text_muted),
1709 ));
1710 }
1711 text.push(Some(Line::from(spans)));
1712 }
1713 if fit.context {
1714 let workspace = shorten_workspace(&crate::utils::display_path(&app.workspace), 2);
1715 let identity = crate::tui::workspace_context::identity_from_context(
1716 &app.workspace,
1717 app.workspace_context.as_deref(),
1718 );
1719 let mut spans = vec![Span::styled(
1720 semantic_truncate(&workspace, header_width),
1721 Style::default().fg(theme.text_soft),
1722 )];
1723 if let Some(branch) = identity.branch {
1724 let detail = format!(" · {branch}");
1725 if text_display_width(&workspace) + text_display_width(&detail) <= header_width {
1726 spans.push(Span::styled(detail, Style::default().fg(theme.text_muted)));
1727 }
1728 }
1729 text.push(Some(Line::from(spans)));
1730 }
1731 if let Some(mark) = mark {
1732 text.resize_with(usize::from(mark.cells().1), || None);
1733 for (row, dots) in mark.rows().iter().enumerate() {
1734 let mut spans = vec![
1735 Span::styled(
1736 crate::tui::mark::reveal_row(
1737 dots,
1738 app.launch
1739 .mark_reveal_started_at
1740 .map_or(crate::tui::mark::REVEAL_MS, |started| {
1741 started.elapsed().as_millis()
1742 }),
1743 app.motion_policy().allows_decorative() && !app.launch.return_to_session,
1744 ),
1745 Style::default().fg(theme.accent_primary),
1746 ),
1747 Span::raw(" "),
1748 ];
1749 if let Some(line) = text[row].take() {
1750 spans.extend(line.spans);
1751 }
1752 text[row] = Some(Line::from(spans));
1753 }
1754 }
1755 if fit.setup {
1756 let line = format!(
1757 "{}{}{}",
1758 tr(locale, MessageId::LaunchNoModelConnected),
1759 crate::tui::session_boot::ITEM_SEPARATOR,
1760 tr(locale, MessageId::LaunchRunCommand).replace("{command}", "/provider"),
1761 );
1762 text.push(Some(Line::from(Span::styled(
1763 semantic_truncate(&line, text_width),
1764 Style::default().fg(theme.warning),
1765 ))));
1766 }
1767 // The migration notice, while there is still a question to answer. It
1768 // retires for good once `/import-claude` has been run.
1769 if fit.notice {
1770 text.push(Some(Line::from(Span::styled(
1771 semantic_truncate(&tr(locale, MessageId::LaunchNoticeClaude), text_width),
1772 Style::default().fg(theme.text_muted),
1773 ))));
1774 }
1775 if fit.blanks >= 1 {
1776 for _ in 0..fit.gap {
1777 text.push(None);
1778 }
1779 }
1780
1781 for row in &card_rows {
1782 let style = if row.prominent {
1783 Style::default().fg(theme.text_body).bold()
1784 } else {
1785 Style::default().fg(theme.text_body)
1786 };
1787 if matches!(row.id, crate::tui::app::LaunchRowId::SeeAll) && spacious {
1788 for _ in 0..fit.gap {
1789 text.push(None);
1790 }
1791 }
1792 let lane = text_width;
1793 let detail_width = text_display_width(&row.detail);
1794 // Age is context: when the lane cannot hold a readable title
1795 // beside it, drop the age whole
1796 // rather than ellipsing the title to a stub. This is also the
1797 // fallback for a locale that spends more cells on the same fact.
1798 let detail = if row.detail.is_empty()
1799 || lane < LAUNCH_ROW_MIN_TITLE + LAUNCH_ROW_GAP + detail_width
1800 {
1801 ""
1802 } else {
1803 row.detail.as_str()
1804 };
1805 let label_budget = if detail.is_empty() {
1806 lane
1807 } else {
1808 lane.saturating_sub(LAUNCH_ROW_GAP + detail_width)
1809 };
1810 let label = semantic_truncate(&row.label, label_budget);
1811 let label_width = text_display_width(&label);
1812 let mut spans = Vec::with_capacity(4);
1813 spans.push(Span::styled(label, style));
1814 if !detail.is_empty() {
1815 let pad = lane
1816 .saturating_sub(label_width)
1817 .saturating_sub(detail_width);
1818 spans.push(Span::raw(" ".repeat(pad)));
1819 spans.push(Span::styled(
1820 detail.to_string(),
1821 Style::default().fg(theme.text_muted),
1822 ));
1823 }
1824 if row.prominent {
1825 spans.push(Span::styled(
1826 " ".repeat(lane.saturating_sub(label_width)),
1827 style,
1828 ));
1829 }
1830 rows.push((row.id.clone(), text.len()));
1831 text.push(Some(Line::from(spans)));
1832 if row.prominent && fit.heading {
1833 // An empty workspace needs only the invitation and composer.
1834 // Real history, including filtered sessions reachable via See all,
1835 // still gets a heading; zero counts are not content.
1836 if spacious {
1837 for _ in 0..fit.gap {
1838 text.push(None);
1839 }
1840 }
1841 let label = semantic_truncate(&tr(locale, MessageId::LaunchRecentHeading), text_width);
1842 let remaining = text_width.saturating_sub(text_display_width(&label) + 2);
1843 let mut spans = vec![Span::styled(
1844 label,
1845 Style::default().fg(theme.text_soft).bold(),
1846 )];
1847 if remaining >= 4 {
1848 let rule = if crate::tui::color_compat::ascii_safe_enabled() {
1849 "-"
1850 } else {
1851 "─"
1852 };
1853 spans.push(Span::styled(
1854 format!(" {}", rule.repeat(remaining)),
1855 Style::default().fg(theme.border),
1856 ));
1857 }
1858 text.push(Some(Line::from(spans)));
1859 }
1860 }
1861
1862 // MCP status, under the recent-work list, where the founder asked for it
1863 // (2026-09-09): the footer chip could name one server out of 23 and hid
1864 // every failure behind a count.
1865 if fit.mcp > 0 {
1866 for _ in 0..fit.gap {
1867 text.push(None);
1868 }
1869 for (offset, line) in mcp_block.lines.into_iter().enumerate() {
1870 if offset >= fit.mcp {
1871 break;
1872 }
1873 if offset == 0 {
1874 rows.push((crate::tui::app::LaunchRowId::McpManager, text.len()));
1875 } else if mcp_block.problems_row == Some(offset) {
1876 rows.push((crate::tui::app::LaunchRowId::McpRemedy, text.len()));
1877 }
1878 text.push(Some(line));
1879 }
1880 }
1881
1882 // Keep the invitation and recent work ahead of command instructions.
1883 if fit.help {
1884 if text.len() + 1 < height {
1885 text.push(None);
1886 }
1887 text.push(Some(Line::from(Span::styled(
1888 semantic_truncate(
1889 &tr(locale, MessageId::LaunchHelpLine).replace(
1890 "{dock}",
1891 crate::tui::shell_key_routing::binding(
1892 crate::tui::shell_key_routing::ShellBindingId::ViewCycle,
1893 )
1894 .footer_chord,
1895 ),
1896 text_width,
1897 ),
1898 Style::default().fg(theme.text_hint),
1899 ))));
1900 }
1901
1902 // Paint the state mouse/keyboard navigation already records. Restrict the
1903 // band to the text lane so selecting a session never colors the margins.
1904 for (index, (_, row)) in rows.iter().enumerate() {
1905 let style = if app.launch.menu_selected == Some(index) {
1906 Some(crate::tui::menu_style::selected_row_bg_style().bold())
1907 } else if app.launch.hovered_row == Some(index) {
1908 Some(crate::tui::menu_style::hovered_row_style())
1909 } else {
1910 None
1911 };
1912 if let Some(style) = style
1913 && let Some(Some(line)) = text.get_mut(*row)
1914 {
1915 let padding = text_width.saturating_sub(line.width());
1916 line.spans.push(Span::raw(" ".repeat(padding)));
1917 for span in &mut line.spans {
1918 span.style = span.style.patch(style);
1919 }
1920 }
1921 }
1922
1923 // Stable focus gutter: the cursor identifies the current Enter target.
1924 // The band includes the gutter and only the bounded reading lane.
1925 // A little top breathing room only comes from unused space. Compact
1926 // terminals never sacrifice a control for this composition.
1927 if height >= 16 {
1928 // Use spare height to balance the launcher above the composer. Leave
1929 // the bottom half as breathing room; controls never lose a row.
1930 let top = height.saturating_sub(text.len()) / 2;
1931 lines.resize_with(top, || Line::from(""));
1932 }
1933 let block_rows = text.len().min(height.saturating_sub(lines.len()));
1934 let mut row_offsets = Vec::with_capacity(block_rows);
1935 for (row, line) in text.iter().take(block_rows).enumerate() {
1936 let action = rows.iter().position(|(_, y)| *y == row);
1937 let selected = action.is_some_and(|i| app.launch.menu_selected == Some(i));
1938 let hovered = action.is_some_and(|i| app.launch.hovered_row == Some(i));
1939 let style = if selected {
1940 crate::tui::menu_style::selected_row_style()
1941 } else if hovered {
1942 crate::tui::menu_style::hovered_row_style()
1943 } else {
1944 Style::default().fg(theme.text_muted)
1945 };
1946 let mut spans = vec![Span::raw(" ".repeat(block_indent))];
1947 if action_gutter > 0 {
1948 let marker = if selected || hovered {
1949 if crate::tui::color_compat::ascii_safe_enabled() {
1950 "> "
1951 } else {
1952 "› "
1953 }
1954 } else {
1955 " "
1956 };
1957 spans.push(Span::styled(marker, style));
1958 }
1959 if let Some(line) = line {
1960 spans.extend(line.spans.iter().cloned());
1961 }
1962 row_offsets.push(lines.len());
1963 lines.push(Line::from(spans));
1964 }
1965
1966 // Re-point the hitboxes at the composed rows. A row the block could not
1967 // fit has no offset, so it has no hitbox either.
1968 let rows = rows
1969 .into_iter()
1970 .filter_map(|(id, text_row)| row_offsets.get(text_row).map(|y| (id, *y)))
1971 .collect();
1972
1973 LaunchEmptyState {
1974 lines,
1975 rows,
1976 text_column: Rect::new(
1977 block_indent as u16,
1978 0,
1979 (text_width + action_gutter) as u16,
1980 area.height,
1981 ),
1982 }
1983 }
1984
1985 #[cfg(test)]
1986 mod launch_card_tests {
1987 use super::{
1988 LAUNCH_CARD_MEASURE, LaunchAction, launch_empty_state, launch_fit, launch_recent_entries,
1989 launch_row_click_action, launch_rows_for_app, refresh_launch_row_hitboxes,
1990 run_launch_card_row, text_display_width,
1991 };
1992 use crate::tui::app::{App, LaunchRecentSession, LaunchRowId};
1993 use ratatui::layout::Rect;
1994 use ratatui::text::Line;
1995 use unicode_segmentation::UnicodeSegmentation;
1996
1997 fn app_with_recent(titles: &[&str], total: usize) -> App {
1998 let mut app = crate::test_support::test_app_with_options(
1999 crate::test_support::test_tui_options(std::env::temp_dir()),
2000 );
2001 app.launch.visible = true;
2002 app.launch.claude_code_detected = false;
2003 app.launch.recent = titles
2004 .iter()
2005 .enumerate()
2006 .map(|(index, title)| LaunchRecentSession {
2007 id: format!("{index}0abcdef-session"),
2008 title: (*title).to_string(),
2009 updated_at: chrono::Utc::now()
2010 - chrono::Duration::hours(i64::try_from(index).unwrap_or(0) + 1),
2011 message_count: 40 + index,
2012 })
2013 .collect();
2014 app.launch.total_workspace_sessions = total;
2015 app
2016 }
2017
2018 #[test]
2019 fn launch_primary_action_has_readable_ink_in_every_theme() {
2020 for theme in codewhale_palette::SELECTABLE_THEMES {
2021 let mut app = app_with_recent(&["Recent proof"], 1);
2022 app.ui_theme = theme.ui_theme();
2023 app.theme_id = *theme;
2024 let card = launch_empty_state(&app, Rect::new(0, 0, 100, 24));
2025 let span = card
2026 .lines
2027 .iter()
2028 .flat_map(|line| &line.spans)
2029 .find(|span| span.content.contains("New session"))
2030 .unwrap();
2031 assert_eq!(
2032 span.style.fg,
2033 Some(app.ui_theme.text_body),
2034 "{}",
2035 theme.name()
2036 );
2037 if let Some(ratio) =
2038 codewhale_palette::contrast_ratio(span.style.fg.unwrap(), app.ui_theme.panel_bg)
2039 {
2040 assert!(
2041 ratio >= 4.5,
2042 "{} New session contrast {ratio}",
2043 theme.name()
2044 );
2045 }
2046 }
2047 }
2048
2049 #[test]
2050 fn completion_settle_keeps_done_label_stable() {
2051 let mut app = app_with_recent(&[], 0);
2052 app.low_motion = false;
2053 app.fancy_animations = true;
2054 let activity = super::LiveActivity::from_app(&app);
2055 for elapsed in [0, 280, 700] {
2056 app.ocean_completion_started_at =
2057 Some(std::time::Instant::now() - std::time::Duration::from_millis(elapsed));
2058 let (_, label) =
2059 super::phase_marker_with_activity(&app, super::ShellPhase::Done, activity);
2060 assert_eq!(label, super::ShellPhase::Done.label(app.ui_locale));
2061 }
2062 }
2063
2064 #[test]
2065 fn launch_reveal_stops_scheduling_after_its_endpoint() {
2066 let mut app = app_with_recent(&[], 0);
2067 app.onboarding = crate::tui::app::OnboardingState::None;
2068 app.theme_id = codewhale_palette::ThemeId::Shoreline;
2069 app.low_motion = false;
2070 app.fancy_animations = true;
2071 app.launch.mark_reveal_started_at = Some(std::time::Instant::now());
2072 assert!(super::launch_motion_active(&app, false, true));
2073 assert!(!super::launch_motion_active(&app, true, true));
2074 app.low_motion = true;
2075 assert!(!super::launch_motion_active(&app, false, true));
2076 app.low_motion = false;
2077 app.launch.mark_reveal_started_at =
2078 Some(std::time::Instant::now() - std::time::Duration::from_millis(361));
2079 assert!(!super::launch_motion_active(&app, false, true));
2080 }
2081
2082 /// The founder's own shape: many servers, a couple genuinely broken, a
2083 /// pile sitting unauthenticated, the rest fine.
2084 fn with_mcp(mut app: App) -> App {
2085 use crate::mcp::{McpManagerSnapshot, McpServerCapabilityMetadata, McpServerSnapshot};
2086 let mut servers = Vec::new();
2087 let mut push = |name: &str, connected: bool, error: Option<&str>, auth: bool| {
2088 servers.push(McpServerSnapshot {
2089 name: name.to_string(),
2090 enabled: true,
2091 required: false,
2092 transport: "stdio".to_string(),
2093 command_or_url: format!("cmd-{name}"),
2094 connect_timeout: 5,
2095 execute_timeout: 5,
2096 read_timeout: 5,
2097 connected,
2098 error: error.map(str::to_string),
2099 auth_required: auth,
2100 capability_metadata: McpServerCapabilityMetadata::NotObserved,
2101 tools: Vec::new(),
2102 resources: Vec::new(),
2103 prompts: Vec::new(),
2104 });
2105 };
2106 for name in ["github", "linear", "supabase", "posthog", "vercel"] {
2107 push(name, true, None, false);
2108 }
2109 push(
2110 "alibaba-cloud-ops",
2111 false,
2112 Some("Invalid request parameters"),
2113 false,
2114 );
2115 push("aws-mcp", false, Some("Stdio transport closed"), false);
2116 for name in ["slack", "notion", "stripe", "figma", "excalidraw"] {
2117 push(name, false, Some("401 Unauthorized"), true);
2118 }
2119 app.mcp_configured_count = servers.len();
2120 app.mcp_snapshot = Some(McpManagerSnapshot {
2121 config_path: std::path::PathBuf::from("mcp.json"),
2122 config_exists: true,
2123 reload_required: false,
2124 servers,
2125 });
2126 app.mcp_initializing = false;
2127 app.mcp_connecting = Vec::new();
2128 app
2129 }
2130
2131 fn flatten(line: &Line<'_>) -> String {
2132 line.spans
2133 .iter()
2134 .map(|span| span.content.to_string())
2135 .collect::<String>()
2136 }
2137
2138 fn painted(app: &App, width: u16, height: u16) -> Vec<String> {
2139 launch_empty_state(app, Rect::new(0, 0, width, height))
2140 .lines
2141 .iter()
2142 .map(|line| flatten(line).trim_end().to_string())
2143 .collect()
2144 }
2145
2146 fn row_ids(app: &App) -> Vec<LaunchRowId> {
2147 app.launch
2148 .row_hitboxes
2149 .iter()
2150 .map(|(id, _)| id.clone())
2151 .collect()
2152 }
2153
2154 /// The recent row's own text, with the block indent stripped. Only used at
2155 /// widths narrow enough that the detail has shed, so what remains is the
2156 /// title alone.
2157 fn recent_row_title(app: &App, width: u16, height: u16) -> String {
2158 let state = launch_empty_state(app, Rect::new(0, 0, width, height));
2159 let (_, row) = state
2160 .rows
2161 .iter()
2162 .find(|(id, _)| matches!(id, LaunchRowId::Recent(_)))
2163 .expect("a recent row painted");
2164 flatten(&state.lines[*row])
2165 .trim()
2166 .trim_start_matches("› ")
2167 .trim_start_matches("> ")
2168 .to_string()
2169 }
2170
2171 fn is_grapheme_prefix(candidate: &str, full: &str) -> bool {
2172 let mut source = full.graphemes(true);
2173 candidate.graphemes(true).all(|g| source.next() == Some(g))
2174 }
2175
2176 // --- one ordering for paint, mouse, and keyboard -------------------
2177
2178 #[test]
2179 fn keyboard_rows_are_exactly_the_rows_the_pane_painted() {
2180 let mut app = app_with_recent(&["one", "two", "three", "four", "five"], 9);
2181
2182 refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, 30));
2183 let tall = launch_rows_for_app(&app);
2184 assert_eq!(
2185 tall.iter().map(|row| row.id.clone()).collect::<Vec<_>>(),
2186 row_ids(&app),
2187 "keyboard list must be the painted list",
2188 );
2189 assert!(tall.len() >= 7, "{:?}", row_ids(&app));
2190
2191 // Highlight the last row, then shrink the pane under it.
2192 app.launch.menu_selected = Some(tall.len() - 1);
2193 refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, 4));
2194 let short = launch_rows_for_app(&app);
2195 assert_eq!(
2196 short.iter().map(|row| row.id.clone()).collect::<Vec<_>>(),
2197 row_ids(&app),
2198 );
2199 assert!(short.len() < tall.len(), "the short pane shed nothing");
2200
2201 // The stale highlight is gone, so Enter cannot resume a row that is
2202 // no longer on screen.
2203 assert_eq!(app.launch.menu_selected, None);
2204 assert_eq!(
2205 run_launch_card_row(&short, app.launch.menu_selected),
2206 LaunchAction::None,
2207 );
2208 }
2209
2210 #[test]
2211 fn no_keyboard_row_names_a_session_the_pane_is_not_showing() {
2212 let mut app = app_with_recent(&["one", "two", "three", "four", "five"], 5);
2213 for height in 0u16..=14 {
2214 refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, height));
2215 let painted: Vec<LaunchRowId> = row_ids(&app);
2216 for row in launch_rows_for_app(&app) {
2217 assert!(
2218 painted.contains(&row.id),
2219 "height {height}: {:?} is runnable but was not painted",
2220 row.id,
2221 );
2222 }
2223 // Every arrow position runs a painted row or nothing at all.
2224 for index in 0..painted.len() + 3 {
2225 let rows = launch_rows_for_app(&app);
2226 match run_launch_card_row(&rows, Some(index)) {
2227 LaunchAction::None => {}
2228 LaunchAction::ResumeSession(id) => assert!(
2229 painted.contains(&LaunchRowId::Recent(id.clone())),
2230 "height {height}: Enter at {index} would resume unpainted {id}",
2231 ),
2232 _ => {}
2233 }
2234 }
2235 }
2236 }
2237
2238 #[test]
2239 fn shed_sessions_stay_reachable_through_the_overflow_row() {
2240 let mut app = app_with_recent(&["one", "two", "three", "four", "five"], 5);
2241 // Five sessions, none behind the inline list: a tall pane needs no
2242 // overflow row, a short one sheds and therefore must offer it.
2243 refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, 30));
2244 assert!(!row_ids(&app).contains(&LaunchRowId::SeeAll));
2245 refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, 4));
2246 let ids = row_ids(&app);
2247 assert!(ids.contains(&LaunchRowId::SeeAll), "{ids:?}");
2248 assert!(
2249 launch_rows_for_app(&app)
2250 .iter()
2251 .any(|row| row.id == LaunchRowId::SeeAll)
2252 );
2253 }
2254
2255 // --- the card fits the pane it is drawn into -----------------------
2256
2257 #[test]
2258 fn the_card_fits_every_pane_it_is_drawn_into() {
2259 // Both shapes: no MCP servers at all, and the twelve-server workspace
2260 // whose status block is the widest thing the card paints.
2261 for app in [
2262 app_with_recent(&["one", "two", "three", "four", "five"], 9),
2263 with_mcp(app_with_recent(&["one", "two", "three", "four", "five"], 9)),
2264 ] {
2265 for width in [0u16, 1, 2, 3, 8, 12, 20, 31, 32, 40, 44, 64, 80, 120, 200] {
2266 for height in 0u16..=30 {
2267 let state = launch_empty_state(&app, Rect::new(0, 0, width, height));
2268 assert!(
2269 state.lines.len() <= usize::from(height),
2270 "{width}x{height}: {} lines",
2271 state.lines.len(),
2272 );
2273 for line in &state.lines {
2274 let painted = text_display_width(&flatten(line));
2275 assert!(
2276 painted <= usize::from(width),
2277 "{width}x{height}: row of {painted} cells",
2278 );
2279 }
2280 for (id, row) in &state.rows {
2281 assert!(
2282 *row < state.lines.len(),
2283 "{width}x{height}: hitbox {id:?} has no row",
2284 );
2285 }
2286 if width > 0 && height > 0 {
2287 assert!(
2288 state
2289 .rows
2290 .iter()
2291 .any(|(id, _)| matches!(id, LaunchRowId::NewSession)),
2292 "{width}x{height}: nothing actionable painted",
2293 );
2294 }
2295 }
2296 }
2297 }
2298 }
2299
2300 // --- MCP status, under the recent list ------------------------------
2301
2302 /// The defect this block was built for: the footer chip named one server
2303 /// out of twenty-three and reported every other state as a bare count, so
2304 /// ten servers sitting unauthenticated were invisible. Failed and
2305 /// needs-login are different problems with different fixes and must never
2306 /// collapse into one clause — and the block is two lines at most now:
2307 /// the summary owns the counts, one problems row names what broke.
2308 #[test]
2309 fn the_mcp_block_names_what_broke_and_separates_it_from_what_needs_a_login() {
2310 let app = with_mcp(app_with_recent(&["one", "two"], 2));
2311 let lines = painted(&app, 120, 30);
2312 // The summary owns the totals, and every non-zero state is on it.
2313 let summary = lines
2314 .iter()
2315 .find(|line| line.contains("MCP"))
2316 .expect("the MCP summary");
2317 assert!(summary.contains("connection failed (2)"), "{summary:?}");
2318 assert!(
2319 summary.contains("authorization required (5)"),
2320 "ten servers at not-logged-in were invisible before this: {summary:?}",
2321 );
2322 // One problems row answers *which*: ✕ groups the failures, ⚠ groups
2323 // the logins, and the remedy rides at the tail.
2324 let problems: Vec<_> = lines
2325 .iter()
2326 .filter(|line| {
2327 line.contains(crate::tui::glyphs::FAILED)
2328 || line.contains(crate::tui::glyphs::ATTENTION)
2329 })
2330 .collect();
2331 assert_eq!(problems.len(), 1, "one problems row: {lines:#?}");
2332 let row = problems[0];
2333 assert!(row.contains("alibaba-cloud-ops"), "{row:?}");
2334 assert!(row.contains("aws-mcp"), "{row:?}");
2335 assert!(row.contains("slack"), "{row:?}");
2336 // The remedy is the command to type, not advice about typing one —
2337 // the named form sheds to bare `/mcp` before any name does.
2338 assert!(row.contains("/mcp"), "{row:?}");
2339 }
2340
2341 /// While the boot is in flight the block must not read as a finished one.
2342 #[test]
2343 fn a_boot_in_flight_says_connecting_rather_than_connected() {
2344 let mut app = app_with_recent(&["one"], 1);
2345 app.mcp_initializing = true;
2346 app.mcp_configured_count = 23;
2347 app.mcp_connecting = Vec::new();
2348 let lines = painted(&app, 120, 30);
2349 let summary = lines
2350 .iter()
2351 .find(|line| line.contains("MCP"))
2352 .expect("the MCP summary");
2353 assert!(summary.contains("connecting (23)"), "{summary:?}");
2354 assert!(
2355 !summary.contains("connected"),
2356 "a boot with nothing connected yet claimed a count: {summary:?}",
2357 );
2358 }
2359
2360 #[test]
2361 fn the_fit_ladder_never_sheds_the_one_actionable_row() {
2362 for height in 1usize..=16 {
2363 for recent in 0usize..=5 {
2364 for has_more in [false, true] {
2365 for notice in [false, true] {
2366 for mcp in 0usize..=4 {
2367 for setup in [false, true] {
2368 let fit = launch_fit(height, recent, has_more, notice, mcp, setup);
2369 assert!(fit.rows() <= height.max(1), "{height} {recent}: {fit:?}");
2370 assert!(fit.shown <= recent);
2371 assert!(fit.mcp <= mcp);
2372 if fit.shown < recent {
2373 assert!(
2374 fit.see_all || fit.rows() >= height,
2375 "shed rows became unreachable: {fit:?}",
2376 );
2377 }
2378 if setup && !fit.setup {
2379 assert_eq!(
2380 (fit.shown, fit.mcp, fit.see_all),
2381 (0, 0, false),
2382 "the no-model line outlived other rows: {fit:?}",
2383 );
2384 }
2385 }
2386 }
2387 }
2388 }
2389 }
2390 }
2391 }
2392
2393 #[test]
2394 fn a_keyless_launch_says_no_model_is_connected_and_how_to_fix_it() {
2395 let mut app = app_with_recent(&["Fix the parser"], 1);
2396 app.onboarding_needs_api_key = true;
2397 let lines = painted(&app, 100, 30).join("\n");
2398 assert!(lines.contains("no model connected"), "{lines}");
2399 assert!(lines.contains("/provider"), "{lines}");
2400 // Even a pane too short for the recent list keeps the recovery line.
2401 let short = painted(&app, 100, 3).join("\n");
2402 assert!(short.contains("no model connected"), "{short}");
2403
2404 app.onboarding_needs_api_key = false;
2405 let lines = painted(&app, 100, 30).join("\n");
2406 assert!(!lines.contains("no model connected"), "{lines}");
2407 }
2408
2409 #[test]
2410 fn empty_workspace_omits_recent_section_but_hidden_history_stays_reachable() {
2411 let app = app_with_recent(&[], 0);
2412 let text = painted(&app, 100, 24).join("\n");
2413 assert!(text.contains("New session"));
2414 assert!(!text.contains("Recent"));
2415 assert!(!text.contains("No recent sessions"));
2416 assert!(!launch_fit(24, 0, false, false, 0, false).heading);
2417 assert!(launch_fit(24, 0, true, false, 0, false).heading);
2418 assert!(launch_fit(24, 0, true, false, 0, false).see_all);
2419 }
2420
2421 // --- the row reads as one object -----------------------------------
2422
2423 #[test]
2424 fn a_row_keeps_its_detail_beside_its_title() {
2425 let app = app_with_recent(&["Ship the launch card"], 1);
2426 let lines = painted(&app, 200, 24);
2427 let row = lines
2428 .iter()
2429 .find(|line| line.contains("Ship the launch card"))
2430 .expect("recent row painted");
2431 assert!(
2432 text_display_width(row.trim_start()) <= LAUNCH_CARD_MEASURE + 2,
2433 "row runs to the terminal edge: {row:?}",
2434 );
2435 let (entries, _) = launch_recent_entries(&app);
2436 assert!(
2437 row.contains(&entries[0].detail),
2438 "row lost its age: {row:?}"
2439 );
2440 assert!(
2441 !row.contains("msgs"),
2442 "message counts belong in session details: {row:?}"
2443 );
2444 }
2445
2446 #[test]
2447 fn a_narrow_pane_spends_its_lane_on_the_title() {
2448 let app = app_with_recent(&["Ship the launch card"], 1);
2449 let row = recent_row_title(&app, 40, 24);
2450 let (entries, _) = launch_recent_entries(&app);
2451 assert!(
2452 !row.contains(&entries[0].detail),
2453 "age should have shed: {row:?}"
2454 );
2455 assert!(row.starts_with("Ship the launch"), "{row:?}");
2456 }
2457
2458 // --- scripts --------------------------------------------------------
2459
2460 #[test]
2461 fn titles_truncate_on_grapheme_boundaries_in_several_scripts() {
2462 // Not a claim about every script: these are the four shapes that break
2463 // char-indexed truncation — wide cells, RTL runs, ZWJ/skin-tone emoji,
2464 // and combining marks.
2465 let samples = [
2466 (
2467 "latin",
2468 "Ship the launch card and verify truncation behaviour",
2469 ),
2470 ("cjk", "部署新的会话启动卡片并验证宽字符截断行为"),
2471 ("arabic", "تهيئة بطاقة إطلاق الجلسة والتحقق من سلوك الاقتطاع"),
2472 ("hebrew", "הגדרת כרטיס פתיחת הפעלה ואימות התנהגות הקיצוץ"),
2473 ("emoji", "👩🏽‍🚀 crew 👨‍👩‍👧‍👦 families and flags 🇯🇵 shipping today"),
2474 (
2475 "combining",
2476 "cafe\u{301} de\u{301}ja\u{300} vu\u{308} with combining marks throughout",
2477 ),
2478 ];
2479 for (name, title) in samples {
2480 let app = app_with_recent(&[title], 1);
2481 for width in [8u16, 12, 20, 32, 40, 60, 80, 120, 200] {
2482 for line in painted(&app, width, 24) {
2483 assert!(
2484 text_display_width(&line) <= usize::from(width),
2485 "{name} at {width}: {line:?} overruns the pane",
2486 );
2487 }
2488 }
2489 // At these widths the detail has shed, so the row is the title
2490 // alone: what is painted must be whole graphemes off its front.
2491 for width in [12u16, 20, 32, 40] {
2492 let row = recent_row_title(&app, width, 24);
2493 let body = row.strip_suffix('…').unwrap_or(&row);
2494 assert!(
2495 is_grapheme_prefix(body, title),
2496 "{name} at {width}: {body:?} splits a grapheme of {title:?}",
2497 );
2498 }
2499 }
2500 }
2501
2502 // --- rhythm ---------------------------------------------------------
2503
2504 #[test]
2505 fn a_tall_pane_breathes_and_a_short_one_gives_the_rhythm_up_first() {
2506 // Counting blank *painted* rows would be wrong: the mark column sits
2507 // behind the first six of them. The rhythm is the gap between the
2508 // new-session entry and the heading below it.
2509 let app = app_with_recent(&["one", "two", "three", "four", "five"], 9);
2510
2511 let tall = painted(&app, 120, 30);
2512 let entry = tall
2513 .iter()
2514 .position(|line| line.contains("New session"))
2515 .expect("new-session row painted");
2516 assert!(
2517 !tall[entry + 1].contains("Recent"),
2518 "a tall pane should breathe: {tall:#?}",
2519 );
2520
2521 let tight = painted(&app, 120, 12);
2522 let entry = tight
2523 .iter()
2524 .position(|line| line.contains("New session"))
2525 .expect("new-session row painted");
2526 assert!(
2527 tight[entry + 1].contains("Recent"),
2528 "a tight pane kept rhythm it cannot afford: {tight:#?}",
2529 );
2530 }
2531
2532 /// Print the card as the renderer actually paints it, for the evidence
2533 /// fixture. Ignored by default; run with
2534 /// `cargo test -p codewhale-tui --lib render_launch_card_fixture -- --ignored --nocapture`.
2535 #[test]
2536 #[ignore = "fixture generator, not an assertion"]
2537 #[allow(clippy::print_stdout)] // the fixture's whole job is its stdout
2538 fn render_launch_card_fixture() {
2539 let app = with_mcp(app_with_recent(
2540 &[
2541 "Fix the diagnostic display",
2542 "Hunter has explicitly authorized setting up Codewhale",
2543 "部署新的会话启动卡片并验证宽字符截断行为",
2544 "👩🏽\u{200d}🚀 crew 👨\u{200d}👩\u{200d}👧\u{200d}👦 families and flags 🇯🇵",
2545 "תהיה כרטיס פתיחת הפעלה",
2546 ],
2547 9,
2548 ));
2549 for (width, height) in [(170u16, 24u16), (120, 24), (80, 24), (40, 12), (20, 8)] {
2550 println!("\n{width}x{height}");
2551 println!("+{}+", "-".repeat(usize::from(width)));
2552 for line in painted(&app, width, height) {
2553 let pad = usize::from(width).saturating_sub(text_display_width(&line));
2554 println!("|{line}{}|", " ".repeat(pad));
2555 }
2556 println!("+{}+", "-".repeat(usize::from(width)));
2557 }
2558 }
2559
2560 // --- the problems row runs the remedy it prints (#6085) -------------
2561
2562 #[test]
2563 fn mcp_problems_row_joins_the_shared_row_ordering() {
2564 let mut app = with_mcp(app_with_recent(&["one", "two"], 9));
2565 refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, 30));
2566
2567 let ids = row_ids(&app);
2568 assert_eq!(
2569 ids.last(),
2570 Some(&LaunchRowId::McpRemedy),
2571 "the problems row is the last row in the painted ordering"
2572 );
2573
2574 // Keyboard: arrowing onto the last row and pressing Enter runs the
2575 // remedy action, through the same arm a click reaches.
2576 let rows = launch_rows_for_app(&app);
2577 assert_eq!(
2578 rows.last().map(|row| row.id.clone()),
2579 Some(LaunchRowId::McpRemedy),
2580 "Up/Down must be able to land on the painted problems row"
2581 );
2582 assert_eq!(
2583 run_launch_card_row(&rows, Some(rows.len() - 1)),
2584 LaunchAction::McpRemedy
2585 );
2586 assert_eq!(
2587 launch_row_click_action(&LaunchRowId::McpRemedy),
2588 LaunchAction::McpRemedy,
2589 "click and Enter share one contract"
2590 );
2591 }
2592
2593 #[test]
2594 fn launch_healthy_mcp_and_recent_rows_have_visible_focus_in_their_click_lane() {
2595 let mut app = with_mcp(app_with_recent(&["Recent proof"], 1));
2596 app.mcp_snapshot
2597 .as_mut()
2598 .unwrap()
2599 .servers
2600 .retain(|s| s.connected);
2601 app.mcp_configured_count = 5;
2602 for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
2603 let area = Rect::new(3, 2, width, height);
2604 refresh_launch_row_hitboxes(&mut app, area);
2605 let rows = launch_rows_for_app(&app);
2606 let mcp = rows
2607 .iter()
2608 .position(|r| r.id == LaunchRowId::McpManager)
2609 .unwrap();
2610 assert_eq!(
2611 run_launch_card_row(&rows, Some(mcp)),
2612 LaunchAction::McpManager
2613 );
2614 assert!(!row_ids(&app).contains(&LaunchRowId::McpRemedy));
2615
2616 for index in [1, mcp] {
2617 app.launch.menu_selected = None;
2618 app.launch.hovered_row = Some(index);
2619 let hovered = launch_empty_state(&app, area);
2620 let (_, y) = hovered.rows[index];
2621 let hit = app.launch.row_hitboxes[index].1;
2622 assert_eq!(hit.x, area.x + hovered.text_column.x);
2623 assert_eq!(hit.y, area.y + y as u16);
2624 assert!(hit.right() <= area.right());
2625 let text = hovered.lines[y].spans.last().unwrap();
2626 assert_eq!(
2627 text.style.bg,
2628 crate::tui::menu_style::hovered_row_style().bg
2629 );
2630
2631 app.launch.menu_selected = Some(index);
2632 let selected = launch_empty_state(&app, area);
2633 assert_eq!(
2634 selected.lines[y].spans.last().unwrap().style,
2635 crate::tui::menu_style::selected_row_bg_style().bold()
2636 );
2637 // Neither the whale nor the leading whitespace changes color.
2638 assert_eq!(selected.lines[y].spans[0].style.bg, None);
2639 }
2640 }
2641 }
2642
2643 #[test]
2644 fn mcp_warning_ink_survives_selection_and_compact_layout() {
2645 let mut app = with_mcp(app_with_recent(&["Recent proof"], 1));
2646 for (width, height) in [(40, 12), (80, 24), (140, 40)] {
2647 let area = Rect::new(0, 0, width, height);
2648 let layout = launch_empty_state(&app, area);
2649 let index = layout
2650 .rows
2651 .iter()
2652 .position(|(id, _)| *id == LaunchRowId::McpManager)
2653 .unwrap();
2654 app.launch.menu_selected = Some(index);
2655 let selected = launch_empty_state(&app, area);
2656 let (_, y) = selected.rows[index];
2657 let summary = selected.lines[y]
2658 .spans
2659 .iter()
2660 .find(|span| span.content.starts_with("MCP"))
2661 .unwrap();
2662 assert_eq!(summary.style.fg, Some(app.ui_theme.error_fg));
2663 assert_eq!(
2664 summary.style.bg,
2665 crate::tui::menu_style::selected_row_bg_style().bg
2666 );
2667 }
2668 }
2669
2670 #[test]
2671 fn mcp_remedy_action_types_the_command_into_the_composer() {
2672 let mut app = with_mcp(app_with_recent(&["one"], 9));
2673 app.launch.menu_selected = Some(0);
2674
2675 crate::tui::ui::type_launch_mcp_remedy(&mut app);
2676
2677 // `slack` is the fixture's first needs-login server; typing the
2678 // printed remedy beats copying it — no clipboard to depend on.
2679 assert_eq!(app.input, "/mcp login slack");
2680 assert_eq!(app.cursor_position, app.input.chars().count());
2681 assert_eq!(app.launch.menu_selected, None);
2682 }
2683
2684 #[test]
2685 fn mcp_remedy_preserves_a_draft_and_opens_the_manager() {
2686 let mut app = with_mcp(app_with_recent(&["one"], 9));
2687 app.launch.return_to_session = true;
2688 app.input = "unsent draft".into();
2689 app.cursor_position = 4;
2690 crate::tui::ui::type_launch_mcp_remedy(&mut app);
2691 assert_eq!(app.input, "unsent draft");
2692 assert_eq!(app.cursor_position, 4);
2693 assert_eq!(
2694 app.view_stack.top_kind(),
2695 Some(crate::tui::views::ModalKind::Extensions),
2696 );
2697 }
2698
2699 #[test]
2700 fn mcp_remedy_action_is_a_noop_when_nothing_is_wrong() {
2701 let mut app = app_with_recent(&["one"], 9);
2702 crate::tui::ui::type_launch_mcp_remedy(&mut app);
2703 assert!(app.input.is_empty());
2704 }
2705
2706 /// The migration notice ends with the reassurance that nothing applies
2707 /// unapproved; a truncated notice drops exactly that half. The English
2708 /// line must fit the card measure whole at a wide terminal.
2709 #[test]
2710 fn claude_notice_is_never_truncated_on_a_wide_terminal() {
2711 let notice = codewhale_localization::tr(
2712 codewhale_localization::Locale::En,
2713 codewhale_localization::MessageId::LaunchNoticeClaude,
2714 );
2715 assert!(
2716 text_display_width(&notice) <= LAUNCH_CARD_MEASURE,
2717 "notice is {} cells; the card lane is {LAUNCH_CARD_MEASURE}: {notice:?}",
2718 text_display_width(&notice),
2719 );
2720 let mut app = app_with_recent(&["one"], 1);
2721 app.launch.claude_code_detected = true;
2722 let state = launch_empty_state(&app, Rect::new(0, 0, 160, 40));
2723 assert!(
2724 state
2725 .lines
2726 .iter()
2727 .any(|line| flatten(line).contains(notice.as_ref())),
2728 "the full notice must paint at 160 columns",
2729 );
2730 }
2731
2732 #[test]
2733 fn every_hitbox_points_at_the_row_that_painted() {
2734 let app = app_with_recent(&["one", "two", "three"], 9);
2735 for height in 1u16..=24 {
2736 let state = launch_empty_state(&app, Rect::new(0, 0, 120, height));
2737 for (id, row) in &state.rows {
2738 let text = flatten(&state.lines[*row]);
2739 assert!(
2740 !text.trim().is_empty(),
2741 "height {height}: hitbox for {id:?} points at a blank row",
2742 );
2743 }
2744 }
2745 }
2746 }
2747
2748 #[cfg(test)]
2749 mod empty_state_caption_tests {
2750 use super::{empty_state_caption, shorten_workspace};
2751 use unicode_width::UnicodeWidthStr;
2752
2753 const DEEP: &str = "/private/tmp/claude-501/-Volumes-VIXinSSD-CW-codewhale/34267917-11f4-4d15-911a-2a8acd5c49e1/scratchpad/surface/ws2";
2754
2755 #[test]
2756 fn caption_stays_narrow_enough_to_actually_centre() {
2757 // The caller centres this line with `(width - caption.width()) / 2`.
2758 // Building it at full length and truncating to `width` made that inset
2759 // zero, so the caption rendered flush-left and full-bleed straight
2760 // through the centred whale/wordmark/prompt composition.
2761 for width in [60usize, 80, 100, 120] {
2762 let caption = empty_state_caption(DEEP, "no git", "MCP", 0, width);
2763 assert!(
2764 caption.width() <= width,
2765 "width {width}: caption {caption:?} overflows the lane",
2766 );
2767 assert!(
2768 width.saturating_sub(caption.width()) / 2 > 0,
2769 "width {width}: caption {caption:?} would render flush-left",
2770 );
2771 }
2772 }
2773
2774 #[test]
2775 fn caption_keeps_the_folder_you_are_standing_in() {
2776 let long = "/a/very/deeply/nested/checkout/somewhere/far/away/myproject";
2777 for width in [40usize, 60, 80, 120] {
2778 let caption = empty_state_caption(long, "main", "MCP", 2, width);
2779 assert!(
2780 caption.contains("myproject"),
2781 "width {width}: {caption:?} dropped the current folder",
2782 );
2783 }
2784 }
2785
2786 #[test]
2787 fn caption_sheds_the_least_important_detail_first() {
2788 let ws = "~/code/app";
2789 let wide = empty_state_caption(ws, "main", "MCP", 3, 120);
2790 assert!(wide.contains("MCP 3") && wide.contains("main") && wide.contains(ws));
2791
2792 let mid = empty_state_caption(ws, "main", "MCP", 3, 24);
2793 assert!(
2794 !mid.contains("MCP"),
2795 "{mid:?} should shed the MCP count first"
2796 );
2797 assert!(mid.contains("main"), "{mid:?} should still name the branch");
2798
2799 let tight = empty_state_caption(ws, "main", "MCP", 3, 16);
2800 assert!(
2801 tight.contains("app"),
2802 "{tight:?} should still name the folder"
2803 );
2804 }
2805
2806 #[test]
2807 fn elision_lands_on_a_separator_not_mid_component() {
2808 // The old line ended in an ellipsis mid-directory
2809 // ("…/34267917-11f4-4d15-911a-"), which told the reader nothing.
2810 let caption = empty_state_caption(DEEP, "no git", "MCP", 0, 60);
2811 assert!(
2812 !caption.contains("2a8acd5c49e1"),
2813 "{caption:?} clipped mid-component"
2814 );
2815 if caption.starts_with('…') {
2816 assert!(
2817 caption.starts_with("…/"),
2818 "elision must land on a separator: {caption:?}",
2819 );
2820 }
2821 }
2822
2823 #[test]
2824 fn caption_margin_scales_so_it_is_always_visibly_a_caption() {
2825 // The flat four-column margin only looked like a margin at 60 columns.
2826 // At 119 it let a 114-column path through with an inset of two — a
2827 // full-bleed banner cutting the centred composition in half, which is
2828 // the exact failure the shedding ladder exists to prevent.
2829 for width in [40usize, 60, 80, 100, 119, 120, 200] {
2830 for workspace in [DEEP, "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/project"] {
2831 let caption = empty_state_caption(workspace, "main", "MCP", 2, width);
2832 let inset = width.saturating_sub(caption.width()) / 2;
2833 assert!(
2834 inset * 12 >= width,
2835 "width {width}: caption {caption:?} insets by only {inset}",
2836 );
2837 }
2838 }
2839 }
2840
2841 #[test]
2842 fn shorten_workspace_is_a_no_op_when_it_already_fits() {
2843 assert_eq!(shorten_workspace("~/code/app", 2), "~/code/app".to_string());
2844 assert_eq!(shorten_workspace("app", 2), "app".to_string());
2845 }
2846 }
2847
2848 // ---------------------------------------------------------------------------
2849 // Launch motion scheduling: the bounded mark reveal, a real dissolve, or
2850 // an active water field requests frames through the existing scheduler.
2851 // ---------------------------------------------------------------------------
2852
2853 /// Whether the launch screen has a visible transition or ambient scene.
2854 #[must_use]
2855 pub fn launch_motion_active(app: &App, obscured: bool, ambient_settled: bool) -> bool {
2856 if !app.launch.visible
2857 || obscured
2858 || app.onboarding != OnboardingState::None
2859 || !app.view_stack.is_empty()
2860 || !app.motion_policy().allows_decorative()
2861 {
2862 return false;
2863 }
2864 let now = app.ambient_clock_ms;
2865 let dissolve = app.launch.card_dissolve_progress(now, true);
2866 let dissolving = dissolve > 0.0 && dissolve < 1.0;
2867 let water_alive = app.theme_id == codewhale_palette::ThemeId::Underwater && !ambient_settled;
2868 let revealing = !app.launch.return_to_session
2869 && !crate::tui::color_compat::ascii_safe_enabled()
2870 && app
2871 .launch
2872 .mark_reveal_started_at
2873 .is_some_and(|started| started.elapsed().as_millis() < crate::tui::mark::REVEAL_MS);
2874 revealing || dissolving || water_alive
2875 }
2876
2876 lines RUST