use super::{
bordered_block, hotkey_span_with_theme, overlay_block_with_theme, pane_text_area,
render_overlay_fill_horizontal_with_theme,
};
use crate::tui::theme::MissionControlTheme;
use crate::tui::{
PADDED_INLINE_SEPARATOR,
state::{MAX_AUTOCOMPLETE_VISIBLE_ROWS, MissionControlState},
};
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Clear, Paragraph},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[path = "prompt_footer.rs"]
mod footer;
use footer::draw_prompt_footer;
pub(super) fn draw_prompt(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let theme = state.theme;
let is_bash_mode = state.prompt_starts_with_bang();
let mut controls = prompt_metadata_title_with_theme(state, theme);
let agent = prompt_agent_title(state, theme);
if !controls.spans.is_empty() {
controls.spans.pop();
controls
.spans
.push(Span::styled(" •", theme.muted(state.is_prompt_focused())));
}
controls.spans.extend(agent.spans);
let available = usize::from(area.width.saturating_sub(2));
let minimum_status_width = if state.running_prompt_cancelable()
|| !state.status.is_empty()
|| state.pending_steering_count > 0
|| is_bash_mode
{
30
} else {
0
};
let controls_width = available.saturating_sub(minimum_status_width);
let controls = footer::truncate_line(controls, controls_width);
let status_width =
available.saturating_sub(controls.width() + usize::from(controls.width() > 0));
let mut block = prompt_block(theme, state.is_prompt_focused(), is_bash_mode).title(
prompt_status_title(state, is_bash_mode, theme, status_width),
);
if controls.width() > 0 {
block = block.title(controls.alignment(Alignment::Right));
}
let inner = block.inner(area);
frame.render_widget(block, area);
draw_prompt_footer(frame, area, state);
state.prompt_editor.render_with_focus(
frame,
pane_text_area(inner),
state.is_prompt_focused(),
theme,
);
}
fn prompt_block(theme: MissionControlTheme, active: bool, is_bash_mode: bool) -> Block<'static> {
bordered_block()
.border_style(if is_bash_mode {
theme.bash_border(active)
} else {
theme.border(active)
})
.style(theme.app())
}
fn prompt_agent_title(state: &MissionControlState, theme: MissionControlTheme) -> Line<'static> {
let muted = theme.muted(state.is_prompt_focused());
Line::from(vec![
Span::styled(" ", muted),
hotkey_span_with_theme(theme, "[Shift-Tab]"),
Span::styled(
format!(" Agent: {}", state.selected_primary_agent_name()),
muted,
),
Span::styled(" ", muted),
])
}
fn prompt_status_title(
state: &MissionControlState,
is_bash_mode: bool,
theme: MissionControlTheme,
width: usize,
) -> Line<'static> {
if state.status.is_empty()
&& !state.running_prompt_cancelable()
&& state.pending_steering_count == 0
{
if is_bash_mode {
return Line::styled(" Bash Mode ", theme.bash_title(state.is_prompt_focused()));
}
return Line::default();
}
let title_style = if is_bash_mode {
theme.bash_title(state.is_prompt_focused())
} else {
theme.title(state.is_prompt_focused())
};
let mut spans = Vec::new();
spans.push(Span::styled(" ", title_style));
if is_bash_mode {
spans.push(Span::styled("Bash Mode", title_style));
spans.push(Span::styled(PADDED_INLINE_SEPARATOR, title_style));
}
if state.pending_steering_count > 0 {
spans.push(Span::styled(
format!("{} steering queued", state.pending_steering_count),
theme.muted(state.is_prompt_focused()),
));
if !state.status.is_empty() || state.running_prompt_cancelable() {
spans.push(Span::styled(PADDED_INLINE_SEPARATOR, title_style));
}
}
if !state.status.is_empty() {
spans.extend(animated_status_spans(state, title_style));
}
if state.running_prompt_cancelable() {
spans.push(Span::styled(" ", title_style));
spans.push(hotkey_span_with_theme(theme, "[Alt-C]"));
spans.push(Span::styled(" to cancel", title_style));
}
spans.push(Span::styled(" ", title_style));
let title = Line::from(spans);
if title.width() > width {
return compact_status_title(state, title_style, width);
}
if state.activity_motion_running() && title.width() + 9 <= width {
let mut spans = title.spans;
spans.insert(
1,
Span::styled(format!("{} ", state.activity_scanner()), title_style),
);
return Line::from(spans);
}
title
}
fn compact_status_title(state: &MissionControlState, style: Style, width: usize) -> Line<'static> {
let cancel = state.running_prompt_cancelable() && width >= 7;
let cancel_suffix = if cancel && width >= 18 {
" to cancel "
} else {
""
};
let remaining = width.saturating_sub(if cancel { 7 + cancel_suffix.len() } else { 0 });
let mut text = String::new();
let mut used = 0;
for grapheme in state.status.graphemes(true) {
used += UnicodeWidthStr::width(grapheme);
if used > remaining {
break;
}
text.push_str(grapheme);
}
let mut spans = vec![Span::styled(text, style)];
if cancel {
spans.push(hotkey_span_with_theme(state.theme, "[Alt-C]"));
spans.push(Span::styled(cancel_suffix, style));
}
Line::from(spans)
}
#[cfg(test)]
fn prompt_metadata_title(state: &MissionControlState) -> Line<'static> {
prompt_metadata_title_with_theme(state, state.theme)
}
fn prompt_metadata_title_with_theme(
state: &MissionControlState,
theme: MissionControlTheme,
) -> Line<'static> {
let muted = theme.muted(state.is_prompt_focused());
let provider = state.provider.trim();
let model = state.model.trim();
let model_metadata = (!provider.is_empty() || !model.is_empty()).then(|| {
if !provider.is_empty() && !model.is_empty() {
format!("{provider}/{model}")
} else {
format!("{provider}{model}")
}
});
let show_login = !state.provider_ready;
if !show_login && model_metadata.is_none() {
return Line::default();
}
let mut spans = vec![Span::styled(" ", muted)];
if show_login {
spans.push(Span::styled("Use /login to add a provider", muted));
} else if let Some(model_metadata) = model_metadata {
if state.running_prompt.is_none() {
spans.push(hotkey_span_with_theme(theme, "[Alt-M]"));
spans.push(Span::styled(" ", muted));
}
let rainbow_active = state.fast_mode_rainbow_active();
if rainbow_active {
spans.extend(fast_mode_rainbow_spans(
&model_metadata,
state.fast_mode_rainbow_phase,
muted,
theme,
));
} else {
let model_style = if state.fast_mode_effective {
theme.fast_mode_label(state.color_enabled)
} else {
muted
};
spans.push(Span::styled(model_metadata, model_style));
}
spans.push(Span::styled(" ", muted));
spans.push(hotkey_span_with_theme(theme, "[Alt-T]"));
spans.push(Span::styled(
format!(" ({})", state.thinking_label()),
muted,
));
}
spans.push(Span::styled(" ", muted));
Line::from(spans)
}
pub(super) fn fast_mode_rainbow_spans(
label: &str,
phase: usize,
muted: Style,
theme: MissionControlTheme,
) -> Vec<Span<'static>> {
let palette_len = theme.fast_mode_rainbow_len().max(1);
label
.graphemes(true)
.enumerate()
.map(|(index, grapheme)| {
let color = theme.fast_mode_rainbow_color(phase.wrapping_add(index) % palette_len);
Span::styled(grapheme.to_string(), muted.fg(color))
})
.collect()
}
pub(super) fn draw_autocomplete(
frame: &mut Frame<'_>,
terminal_area: Rect,
prompt_area: Rect,
state: &MissionControlState,
) {
let Some(autocomplete) = &state.autocomplete else {
return;
};
if autocomplete.candidates.is_empty() || terminal_area.width == 0 || terminal_area.height == 0 {
return;
}
let theme = state.theme;
let visible_count = autocomplete
.candidates
.len()
.min(MAX_AUTOCOMPLETE_VISIBLE_ROWS);
let popup_height = (visible_count as u16)
.saturating_add(2)
.min(terminal_area.height);
if popup_height == 0 {
return;
}
let min_width = 24;
let popup_width = prompt_area.width.max(min_width).min(terminal_area.width);
let x = prompt_area.x.min(
terminal_area
.x
.saturating_add(terminal_area.width.saturating_sub(popup_width)),
);
let y = if prompt_area.y.saturating_sub(terminal_area.y) >= popup_height {
prompt_area.y.saturating_sub(popup_height)
} else {
prompt_area.y.saturating_add(prompt_area.height).min(
terminal_area
.y
.saturating_add(terminal_area.height.saturating_sub(popup_height)),
)
};
let area = Rect::new(x, y, popup_width, popup_height);
let selected = autocomplete
.selected
.min(autocomplete.candidates.len().saturating_sub(1));
let start = selected.saturating_add(1).saturating_sub(visible_count);
const AUTOCOMPLETE_NAME_GAP: usize = 2;
let name_width = autocomplete
.candidates
.iter()
.skip(start)
.take(visible_count)
.map(|candidate| UnicodeWidthStr::width(candidate.display().as_str()))
.max()
.unwrap_or(0);
let name_field = name_width + AUTOCOMPLETE_NAME_GAP;
let lines: Vec<Line<'_>> = autocomplete
.candidates
.iter()
.enumerate()
.skip(start)
.take(visible_count)
.map(|(index, candidate)| {
let selected_style = index == selected;
let base = if selected_style {
theme.selection()
} else {
theme.panel_alt()
};
let name_style = if selected_style {
theme.selection().add_modifier(Modifier::BOLD)
} else {
Style::new()
.fg(theme.text_accent())
.bg(theme.surface_panel_alt())
.add_modifier(Modifier::BOLD)
};
let description_style = if selected_style {
theme.selection()
} else {
Style::new()
.fg(theme.text_muted())
.bg(theme.surface_panel_alt())
};
Line::from(vec![
Span::styled(
format!("{:<width$}", candidate.display(), width = name_field),
name_style,
),
Span::styled(candidate.description.clone(), description_style),
Span::styled(" ", base),
])
})
.collect();
frame.render_widget(Clear, area);
let title = match autocomplete
.candidates
.first()
.map(|candidate| candidate.kind)
{
Some(crate::tui::state::AutocompleteKind::FileTag) => "File tags",
Some(crate::tui::state::AutocompleteKind::SkillTag) => "Skill tags",
Some(crate::tui::state::AutocompleteKind::ContextInjection) => "Context tags",
_ => "Slash commands",
};
let block = overlay_block_with_theme(theme, title);
let inner = block.inner(area);
frame.render_widget(block, area);
let text_area = render_overlay_fill_horizontal_with_theme(frame, inner, theme);
frame.render_widget(Paragraph::new(lines).style(theme.panel_alt()), text_area);
}
fn animated_status_spans(state: &MissionControlState, title_style: Style) -> Vec<Span<'static>> {
let style = if state.completion_highlight_visible() && state.status == "run complete" {
title_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
title_style
};
vec![Span::styled(state.status.clone(), style)]
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};
fn rendered_prompt_buffer(state: &MissionControlState) -> ratatui::buffer::Buffer {
let backend = TestBackend::new(80, 6);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_prompt(frame, Rect::new(0, 0, 80, 6), state))
.unwrap();
terminal.backend().buffer().clone()
}
fn contains_text(buffer: &ratatui::buffer::Buffer, needle: &str) -> bool {
(0..buffer.area.height).any(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.contains(needle)
})
}
#[test]
fn narrow_prompt_preserves_cancel_and_shimmer_without_border_overlap() {
let mut state = rainbow_state(0);
state.start_running_prompt("inspect".into());
state.status = "working on a long task".into();
for width in [9, 20, 32, 40, 60, 80, 120] {
let mut terminal = Terminal::new(TestBackend::new(width, 6)).unwrap();
terminal
.draw(|frame| draw_prompt(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
let row = (0..width)
.map(|x| buffer[(x, 0)].symbol())
.collect::<String>();
assert!(row.starts_with('┌') && row.ends_with('┐'), "{width}: {row}");
assert!(row.contains("[Alt-C]"), "{width}: {row}");
assert!(!row.contains("[Alt-M]") && !row.contains("Fast:"), "{row}");
if width >= 60 {
let byte = row.find("openai-codex/gpt-5.5").expect("model fits");
let x = row[..byte].width() as u16;
assert_eq!(buffer[(x, 0)].fg, state.theme.fast_mode_rainbow_color(0));
state.fast_mode_rainbow_phase = 1;
terminal
.draw(|frame| draw_prompt(frame, frame.area(), &state))
.unwrap();
assert_eq!(
terminal.backend().buffer()[(x, 0)].fg,
state.theme.fast_mode_rainbow_color(1)
);
state.fast_mode_rainbow_phase = 0;
}
}
}
#[test]
fn narrow_prompt_keeps_queued_steering_visible_before_model_controls() {
let mut state = rainbow_state(0);
state.pending_steering_count = 3;
let mut terminal = Terminal::new(TestBackend::new(60, 6)).unwrap();
terminal
.draw(|frame| draw_prompt(frame, frame.area(), &state))
.unwrap();
assert!(contains_text(
terminal.backend().buffer(),
"3 steering queued"
));
}
fn metadata_text(state: &MissionControlState) -> String {
prompt_metadata_title(state)
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
fn context_usage(
current_tokens: usize,
max_tokens: usize,
) -> crate::tui::state::ContextUsageState {
crate::tui::state::ContextUsageState {
current_tokens,
max_tokens,
reasoning_tokens: None,
source: crate::output::ContextUsageSource::ProviderExact,
request_sequence: 1,
}
}
fn rainbow_state(phase: usize) -> MissionControlState {
MissionControlState {
provider: "openai-codex".to_string(),
model: "gpt-5.5".to_string(),
provider_ready: true,
color_enabled: true,
fast_mode_enabled: true,
fast_mode_request_state: crate::fast::FastRequestState::Fast {
service_tier: "priority".to_string(),
},
fast_mode_effective: true,
fast_mode_rainbow_phase: phase,
..Default::default()
}
}
fn rainbow_colors(title: &Line<'static>) -> Vec<ratatui::style::Color> {
title
.spans
.iter()
.filter_map(|span| {
span.style.fg.filter(|color| {
(0..MissionControlTheme::default().fast_mode_rainbow_len()).any(|index| {
MissionControlTheme::default().fast_mode_rainbow_color(index) == *color
})
})
})
.collect()
}
#[test]
fn prompt_bash_mode_border_turns_yellow_for_bang_input() {
let state = MissionControlState {
prompt_editor: crate::tui::state::PromptEditor::new("!git status", "!git status".len()),
provider_ready: true,
..MissionControlState::default()
};
let buffer = rendered_prompt_buffer(&state);
assert_eq!(
buffer[(0, 0)].fg,
MissionControlTheme::default().text_accent()
);
assert!(contains_text(&buffer, "Bash Mode"));
}
#[test]
fn prompt_normal_border_stays_green_without_bang_input() {
let state = MissionControlState {
prompt_editor: crate::tui::state::PromptEditor::new("git status", "git status".len()),
provider_ready: true,
..MissionControlState::default()
};
let buffer = rendered_prompt_buffer(&state);
assert_eq!(
buffer[(0, 0)].fg,
MissionControlTheme::default().text_accent()
);
assert!(contains_text(&buffer, "Session:"));
assert!(!contains_text(&buffer, "Bash Mode"));
}
#[test]
fn prompt_metadata_shows_provider_without_fast_label() {
let state = MissionControlState {
provider: "openai-codex".to_string(),
model: "gpt-5.5".to_string(),
provider_ready: true,
..Default::default()
};
assert_eq!(
metadata_text(&state),
" [Alt-M] openai-codex/gpt-5.5 [Alt-T] (default) "
);
}
#[test]
fn prompt_metadata_keeps_login_separate_from_context() {
let mut state = MissionControlState {
context_usage: Some(context_usage(16_274, 272_000)),
provider_ready: true,
..Default::default()
};
assert_eq!(metadata_text(&state), "");
state.provider_ready = false;
assert_eq!(metadata_text(&state), " Use /login to add a provider ");
state.context_usage = None;
assert_eq!(metadata_text(&state), " Use /login to add a provider ");
}
#[test]
fn prompt_metadata_suppresses_alt_m_while_running() {
let mut state = rainbow_state(0);
state.start_running_prompt("inspect".to_string());
let text = metadata_text(&state);
assert!(!text.contains("[Alt-M]"));
assert!(text.contains("openai-codex/gpt-5.5 [Alt-T] (default)"));
assert!(!text.contains("Fast:"));
assert!(state.fast_mode_rainbow_active());
}
#[test]
fn prompt_metadata_uses_hotkey_styles_and_muted_separators() {
let state = MissionControlState {
provider: "zai".to_string(),
model: "glm-5.2".to_string(),
provider_ready: true,
context_usage: Some(context_usage(3_000, 100_000)),
..Default::default()
};
let title = prompt_metadata_title(&state);
let muted = MissionControlTheme::default().muted(state.is_prompt_focused());
for hotkey in ["[Alt-M]", "[Alt-T]"] {
let span = title
.spans
.iter()
.find(|span| span.content.as_ref() == hotkey)
.expect("hotkey span");
assert_eq!(span.style, crate::tui::render::hotkey_style());
}
let separator = title
.spans
.iter()
.find(|span| span.content.as_ref() == " ")
.expect("metadata separator");
assert_eq!(separator.style, muted);
}
#[test]
fn fast_mode_rainbow_phase_shifts_label_left_without_changing_text() {
let phase_zero = rainbow_state(0);
let phase_one = rainbow_state(1);
let title_zero = prompt_metadata_title(&phase_zero);
let title_one = prompt_metadata_title(&phase_one);
let colors_zero = rainbow_colors(&title_zero);
let colors_one = rainbow_colors(&title_one);
assert_eq!(
&colors_one[..colors_one.len().saturating_sub(1)],
&colors_zero[1..],
);
assert_eq!(metadata_text(&phase_zero), metadata_text(&phase_one));
assert_eq!(
colors_zero.len(),
"openai-codex/gpt-5.5".graphemes(true).count()
);
}
#[test]
fn fast_mode_rainbow_preserves_unicode_graphemes_and_surrounding_metadata() {
let mut state = rainbow_state(2);
state.provider = "e\u{301}".to_string();
state.model = "👩💻".to_string();
state.context_usage = Some(context_usage(3_000, 100_000));
let title = prompt_metadata_title(&state);
let label = "e\u{301}/👩💻";
let rainbow = title
.spans
.iter()
.filter(|span| {
span.style.fg.is_some_and(|color| {
(0..MissionControlTheme::default().fast_mode_rainbow_len()).any(|index| {
MissionControlTheme::default().fast_mode_rainbow_color(index) == color
})
})
})
.collect::<Vec<_>>();
assert_eq!(rainbow.len(), label.graphemes(true).count());
assert_eq!(
rainbow
.iter()
.map(|span| span.content.as_ref())
.collect::<String>(),
label
);
assert_eq!(
metadata_text(&state),
" [Alt-M] e\u{301}/👩💻 [Alt-T] (default) "
);
let muted = MissionControlTheme::default().muted(state.is_prompt_focused());
assert_eq!(
title
.spans
.iter()
.find(|span| span.content.as_ref() == " ")
.expect("metadata separator")
.style,
muted
);
}
#[test]
fn fast_mode_rainbow_colors_only_provider_model_label() {
let mut state = rainbow_state(0);
state.context_usage = Some(context_usage(3_000, 100_000));
let title = prompt_metadata_title(&state);
let muted = MissionControlTheme::default().muted(state.is_prompt_focused());
for content in [" ", " (default)"] {
assert_eq!(
title
.spans
.iter()
.find(|span| span.content.as_ref() == content)
.expect("muted metadata span")
.style,
muted
);
}
for hotkey in ["[Alt-M]", "[Alt-T]"] {
assert_eq!(
title
.spans
.iter()
.find(|span| span.content.as_ref() == hotkey)
.expect("hotkey")
.style,
crate::tui::render::hotkey_style()
);
}
assert!(
title
.spans
.iter()
.filter(|span| span.content.as_ref() == " ")
.all(|span| span.style == muted)
);
}
#[test]
fn no_color_fast_mode_uses_accent_label_with_modifier_cue() {
let mut state = rainbow_state(0);
state.color_enabled = false;
let title = prompt_metadata_title(&state);
let label = title
.spans
.iter()
.find(|span| span.content.as_ref() == "openai-codex/gpt-5.5")
.expect("provider/model label");
assert_eq!(label.style, state.theme.fast_mode_label(false));
assert_eq!(label.style.fg, Some(state.theme.text_accent()));
assert!(label.style.add_modifier.contains(Modifier::BOLD));
assert!(label.style.add_modifier.contains(Modifier::UNDERLINED));
assert_eq!(
metadata_text(&state),
" [Alt-M] openai-codex/gpt-5.5 [Alt-T] (default) "
);
}
#[test]
fn activity_scanner_bounces_and_yields_to_cancel_label_on_narrow_titles() {
let mut state = MissionControlState::default();
state.start_running_prompt("work".into());
state.status = "e\u{301}界working".into();
let first = prompt_status_title(&state, true, state.theme, 100);
assert!(first.to_string().contains("[>.....]"));
state.activity_motion.phase = 5;
assert_eq!(state.activity_scanner(), "[.....<]");
state.activity_motion.phase = 6;
assert_eq!(state.activity_scanner(), "[....<.]");
state.activity_motion.phase = 10;
assert_eq!(state.activity_scanner(), "[>.....]");
for width in [0, 1, 8, 18, 24, 40] {
let title = prompt_status_title(&state, true, state.theme, width);
assert!(title.width() <= width);
if width >= 18 {
assert!(title.to_string().contains("[Alt-C] to cancel"));
}
}
state.reduced_motion = true;
assert!(
!prompt_status_title(&state, true, state.theme, 100)
.to_string()
.contains("[>.....]")
);
state.reduced_motion = false;
state.request_running_prompt_cancel();
assert!(
!prompt_status_title(&state, true, state.theme, 100)
.to_string()
.contains("[>.....]")
);
}
#[test]
fn live_completion_accent_settles_without_changing_prompt_text() {
let mut state = MissionControlState::default();
state.start_running_prompt("work".into());
state.status = "run complete".into();
let now = std::time::Instant::now();
state.highlight_live_completion(now);
state.clear_running_prompt();
let style = state.theme.title(state.is_prompt_focused());
assert!(
animated_status_spans(&state, style)[0]
.style
.add_modifier
.contains(Modifier::UNDERLINED)
);
assert!(state.tick_activity_motion(now + std::time::Duration::from_secs(1)));
assert_eq!(
animated_status_spans(&state, style),
vec![Span::styled("run complete", style)]
);
}
}