diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 7ec55e3569..c155aa2912 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -192,6 +192,9 @@ const RUNNING_COMMAND_DETACH_HINT: &str = "ctrl-c to return to command"; /// Replaces the exit hint when viewing a child agent conversation. pub(crate) const CTRL_C_KILL_CHILD_HINT: &str = "ctrl-c again to kill child agent"; const STARTING_SHELL_HINT: &str = "Starting shell..."; +/// The hint row plus its top padding. The zero state accounts for this temporary +/// chrome so its centered position already matches the post-bootstrap layout. +const STARTING_SHELL_CHROME_ROWS: u16 = 2; const SETTINGS_PARSE_FAILED_HINT: &str = "Settings failed to load: invalid syntax."; const SETTINGS_INVALID_VALUES_HINT: &str = "Settings failed to load: invalid values."; @@ -5145,6 +5148,8 @@ impl TuiTerminalSessionView { let builder = TuiUiBuilder::from_app(ctx); let orchestration_tabs_available = state.orchestration_available(); let blocker_active = state.has_blocking_interaction(); + let show_starting_shell_hint = + !blocker_active && matches!(input_target, TuiInputTarget::Disabled); if state.is_alt_screen() { self.zero_state_interaction.set_visible(false); @@ -5224,10 +5229,20 @@ impl TuiTerminalSessionView { let mut content = TuiFlex::column(); let transcript_is_empty = self.transcript.as_ref(ctx).is_empty(); self.zero_state_interaction.set_visible(transcript_is_empty); - if transcript_is_empty && self.session_state.as_ref(ctx).show_first_zero_state() { - content = content.flex_child(self.zero_state_view.as_ref(ctx).render_first_run(ctx)); - } else if transcript_is_empty { - content = content.flex_child(TuiChildView::new(&self.zero_state_view).finish()); + if transcript_is_empty { + let zero_state = if self.session_state.as_ref(ctx).show_first_zero_state() { + self.zero_state_view.as_ref(ctx).render_first_run(ctx) + } else { + TuiChildView::new(&self.zero_state_view).finish() + }; + let zero_state = if show_starting_shell_hint { + TuiContainer::new(zero_state) + .with_padding_top(STARTING_SHELL_CHROME_ROWS) + .finish() + } else { + zero_state + }; + content = content.flex_child(zero_state); } else { content = content.flex_child(TuiChildView::new(&self.transcript).finish()); } @@ -5239,7 +5254,7 @@ impl TuiTerminalSessionView { // fresh each pass — no stored suppression flag — and the hidden // input model is never written to, so its draft/cursor/selection/ // scroll survive untouched. - if !blocker_active && matches!(input_target, TuiInputTarget::Disabled) { + if show_starting_shell_hint { content = content.child( TuiContainer::new( TuiText::new(STARTING_SHELL_HINT) diff --git a/crates/warp_tui/src/terminal_session_view_tests.rs b/crates/warp_tui/src/terminal_session_view_tests.rs index 084bc5d400..bad45d0c3e 100644 --- a/crates/warp_tui/src/terminal_session_view_tests.rs +++ b/crates/warp_tui/src/terminal_session_view_tests.rs @@ -2996,6 +2996,49 @@ fn bootstrap_renders_starting_shell_above_input() { }); } +#[test] +fn zero_state_position_stays_stable_across_shell_bootstrap() { + App::test((), |mut app| async move { + let fixture = focus_test_fixture(&mut app); + let (view, _) = add_focus_test_session(&mut app, &fixture, true); + + let ready_lines = render_session(&mut app, &view, 80, 40); + assert!( + ready_lines + .iter() + .all(|line| line.trim() != "Starting shell..."), + "ready state must not render the bootstrap hint:\n{}", + ready_lines.join("\n") + ); + + view.update(&mut app, |view, _| { + view.terminal_model.lock().block_list_mut().reinit_shell(); + }); + let bootstrap_lines = render_session(&mut app, &view, 80, 40); + assert!( + bootstrap_lines + .iter() + .any(|line| line.trim() == "Starting shell..."), + "bootstrap state must render the starting-shell hint:\n{}", + bootstrap_lines.join("\n") + ); + + let title_row = |lines: &[String]| { + lines + .iter() + .position(|line| line.contains("Warp Agent CLI")) + .unwrap_or_else(|| panic!("zero-state title should render:\n{}", lines.join("\n"))) + }; + assert_eq!( + title_row(&bootstrap_lines), + title_row(&ready_lines), + "zero state must not shift when the bootstrap hint disappears\nbootstrap:\n{}\nready:\n{}", + bootstrap_lines.join("\n"), + ready_lines.join("\n") + ); + }); +} + /// The input child's rendered element is cached by the presenter, and /// transcript emptiness can flip without any input-owned event (a terminal /// block landing via the PTY wakeup path only invalidates the session view).