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;
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.prompt_accepts_input() && !state.show_help,
theme,
);
state.visible_prompt_history_revision.set(None);
if let Some(suffix) = state.prompt_history_suffix() {
let area = pane_text_area(inner);
if let Some(cursor) = state
.prompt_editor
.rendered_cursor_position()
.filter(|position| area.contains(*position))
{
let ghost_area =
Rect::new(cursor.x, cursor.y, area.right().saturating_sub(cursor.x), 1);
let visible_suffix = suffix.trim_start();
let leading_width = suffix.width().saturating_sub(visible_suffix.width());
if visible_suffix
.graphemes(true)
.next()
.is_some_and(|first| leading_width + first.width() <= usize::from(ghost_area.width))
{
state
.visible_prompt_history_revision
.set(Some(state.prompt_content_revision()));
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(suffix, theme.muted(true).add_modifier(Modifier::DIM)),
Span::styled(" →", Style::default().fg(theme.text_primary())),
])),
ghost_area,
);
}
}
}
}
pub(super) fn draw_subagent_prompt(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let theme = state.theme;
let editable = state.subagent_prompt_editable();
let pending = state
.selected_subagent_control()
.and_then(|control| control.steering.try_pending_count())
.unwrap_or(0);
let status = if pending > 0 {
format!(" {pending} steering queued • [Alt-C] recall ")
} else if editable {
" [Alt-C] cancel ".to_owned()
} else {
" Read-only ".to_owned()
};
let name = state
.subagent_viewer_selected_task_id()
.map(|id| {
state.subagent_viewer_tab_label(
&id,
usize::from(area.width.saturating_sub(2)).saturating_sub(status.len() + 3),
)
})
.unwrap_or_default();
let notice = state
.selected_subagent_prompt()
.map(|draft| draft.notice.as_str())
.filter(|notice| !notice.is_empty());
let name = Line::styled(format!(" {name} "), theme.muted(editable));
let block = prompt_block(theme, editable, false)
.title(footer::truncate_line(
super::hotkey_line_with_theme(theme, &status, theme.muted(editable)),
usize::from(area.width.saturating_sub(2)).saturating_sub(name.width() + 1),
))
.title(name.alignment(Alignment::Right))
.title_bottom(footer::truncate_line(
super::hotkey_line_with_theme(
theme,
if let Some(notice) = notice {
notice
} else if editable {
" [Enter] steer • $ skills • no slash commands "
} else {
" Subagent is not accepting input "
},
theme.muted(editable),
),
usize::from(area.width.saturating_sub(2)),
));
let inner = pane_text_area(block.inner(area));
frame.render_widget(block, area);
if let Some(draft) = state.selected_subagent_prompt() {
draft
.editor
.render_with_focus(frame, inner, editable, 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, "[Alt-P]"),
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(
if state.pending_steering_count > 0 {
" to edit steering"
} else {
" 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)
}
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()
&& state.codex_quota_label.is_none()
&& state.claude_quota_label.is_none()
{
return Line::default();
}
let mut spans = vec![Span::styled(" ", muted)];
if let Some(label) = &state.claude_quota_label {
spans.push(Span::styled(format!("{label} • "), muted));
}
if let Some(label) = &state.codex_quota_label {
spans.push(Span::styled(format!("{label} • "), 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;
};
draw_prompt_autocomplete(frame, terminal_area, prompt_area, autocomplete, state.theme);
}
pub(super) fn draw_prompt_autocomplete(
frame: &mut Frame<'_>,
terminal_area: Rect,
prompt_area: Rect,
autocomplete: &crate::tui::state::AutocompleteState,
theme: MissionControlTheme,
) {
if autocomplete.candidates.is_empty() || terminal_area.width == 0 || terminal_area.height == 0 {
return;
}
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)]
}