use super::{
bordered_block, hotkey_span, overlay_block, pane_text_area, render_overlay_fill_horizontal,
render_scroll_view_persistent,
};
use crate::tui::{
PADDED_INLINE_SEPARATOR,
layout::TuiPane,
state::{MAX_AUTOCOMPLETE_VISIBLE_ROWS, MissionControlState, PROMPT_CURSOR_GLYPH},
theme::MATRIX_GREEN,
};
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
pub(super) fn draw_prompt(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let is_bash_mode = crate::bash_mode::parse_bash_mode_prompt(&state.input).is_some();
let block = prompt_block(state.is_prompt_focused(), is_bash_mode)
.title(prompt_status_title(state, is_bash_mode))
.title_bottom(prompt_agent_title(state, is_bash_mode))
.title_bottom(prompt_metadata_title(state).alignment(Alignment::Right));
let inner = block.inner(area);
frame.render_widget(block, area);
let text_area = pane_text_area(inner);
let wrap_width = super::scroll_view_content_width(text_area).max(1);
let content_height =
crate::tui::transcript::ratatui_wrapped_visual_rows(&state.input, wrap_width);
render_scroll_view_persistent(
frame,
text_area,
super::usize_to_u16_saturating(if state.prompt_cursor >= state.input.len() {
content_height.saturating_sub(usize::from(text_area.height))
} else {
state.prompt_offset()
}),
super::usize_to_u16_saturating(content_height),
Paragraph::new(prompt_lines(state))
.style(MATRIX_GREEN.pane())
.wrap(Wrap { trim: false }),
&state.scroll_views.prompt,
);
}
fn prompt_lines(state: &MissionControlState) -> Vec<Line<'static>> {
let prompt_style = MATRIX_GREEN.pane();
let selection = state
.selection
.as_ref()
.filter(|selection| selection.pane == TuiPane::Prompt)
.and_then(|selection| selection.byte_range());
let cursor =
(state.is_prompt_focused() && state.prompt_cursor_visible).then_some(state.prompt_cursor);
lines_with_selection_and_cursor(
&state.input,
selection,
cursor,
prompt_style,
state.is_prompt_focused(),
)
}
fn lines_with_selection_and_cursor(
text: &str,
selection: Option<(usize, usize)>,
cursor: Option<usize>,
base_style: Style,
prompt_focused: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let mut line_start = 0usize;
for segment in text.split_inclusive('\n') {
let line = segment.strip_suffix('\n').unwrap_or(segment);
lines.push(line_with_selection_and_cursor(
line,
line_start,
selection,
cursor,
base_style,
prompt_focused,
));
line_start += segment.len();
}
if text.is_empty() || text.ends_with('\n') {
lines.push(line_with_selection_and_cursor(
"",
text.len(),
selection,
cursor,
base_style,
prompt_focused,
));
}
lines
}
fn line_with_selection_and_cursor(
line: &str,
line_start: usize,
selection: Option<(usize, usize)>,
cursor: Option<usize>,
base_style: Style,
prompt_focused: bool,
) -> Line<'static> {
let line_end = line_start + line.len();
let mut points = vec![line_start, line_end];
if let Some((start, end)) = selection {
points.push(start.clamp(line_start, line_end));
points.push(end.clamp(line_start, line_end));
}
if let Some(cursor) = cursor {
points.push(cursor.clamp(line_start, line_end));
}
points.sort_unstable();
points.dedup();
let mut spans = Vec::new();
for window in points.windows(2) {
let start = window[0];
let end = window[1];
if let Some(cursor) = cursor.filter(|cursor| *cursor == start) {
let _ = cursor;
spans.push(Span::styled(
PROMPT_CURSOR_GLYPH.to_string(),
MATRIX_GREEN.cursor(prompt_focused),
));
}
if start < end {
let selected =
selection.is_some_and(|(sel_start, sel_end)| start >= sel_start && end <= sel_end);
spans.push(Span::styled(
line[start - line_start..end - line_start].to_string(),
if selected {
MATRIX_GREEN.selection()
} else {
base_style
},
));
}
}
if cursor == Some(line_end) {
spans.push(Span::styled(
PROMPT_CURSOR_GLYPH.to_string(),
MATRIX_GREEN.cursor(prompt_focused),
));
}
Line::from(spans)
}
fn prompt_block(active: bool, is_bash_mode: bool) -> Block<'static> {
bordered_block()
.border_style(if is_bash_mode {
MATRIX_GREEN.bash_border(active)
} else {
MATRIX_GREEN.border(active)
})
.style(MATRIX_GREEN.app())
}
fn prompt_agent_title(state: &MissionControlState, is_bash_mode: bool) -> Line<'static> {
let title_style = if is_bash_mode {
MATRIX_GREEN.bash_title(state.is_prompt_focused())
} else {
MATRIX_GREEN.title(state.is_prompt_focused())
};
let accent_style = if is_bash_mode {
MATRIX_GREEN.bash_accent(state.is_prompt_focused())
} else {
MATRIX_GREEN.muted(state.is_prompt_focused())
};
let prompt_label = if is_bash_mode {
"Bash Mode "
} else {
"Prompt "
};
Line::from(vec![
Span::styled(" ", title_style),
Span::styled(prompt_label, title_style),
hotkey_span("[Alt-P]"),
Span::styled(PADDED_INLINE_SEPARATOR, accent_style),
hotkey_span("[Shift-Tab]"),
Span::styled(
format!(" Agent: {}", state.selected_primary_agent_name()),
MATRIX_GREEN.muted(state.is_prompt_focused()),
),
Span::styled(" ", MATRIX_GREEN.muted(state.is_prompt_focused())),
])
}
fn prompt_status_title(state: &MissionControlState, is_bash_mode: bool) -> Line<'static> {
if state.status.is_empty()
&& !state.running_prompt_cancelable()
&& state.pending_steering_count == 0
{
return Line::default();
}
let title_style = if is_bash_mode {
MATRIX_GREEN.bash_title(state.is_prompt_focused())
} else {
MATRIX_GREEN.title(state.is_prompt_focused())
};
let mut spans = Vec::new();
spans.push(Span::styled(" ", title_style));
if state.pending_steering_count > 0 {
spans.push(Span::styled(
format!("{} steering queued", state.pending_steering_count),
MATRIX_GREEN.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.push(Span::styled(state.status.clone(), title_style));
}
if state.running_prompt_cancelable() {
spans.push(Span::styled(" ", title_style));
spans.push(hotkey_span("[Alt-C]"));
spans.push(Span::styled(" to cancel", title_style));
}
spans.push(Span::styled(" ", title_style));
Line::from(spans)
}
fn prompt_metadata_title(state: &MissionControlState) -> Line<'static> {
let muted = MATRIX_GREEN.muted(state.is_prompt_focused());
if !state.provider_ready {
return Line::styled(" Use /login to add a provider ", muted);
}
let provider = state.provider.trim();
let model = state.model.trim();
if provider.is_empty() && model.is_empty() {
return Line::default();
}
let mut parts = Vec::new();
if !provider.is_empty() {
parts.push(provider.to_string());
}
if !model.is_empty() {
parts.push(model.to_string());
}
let show_setmodel_hint = state.running_prompt.is_none();
let prefix = if show_setmodel_hint {
format!(
" {PADDED_INLINE_SEPARATOR}{}{PADDED_INLINE_SEPARATOR}Thinking: {} ",
parts.join(PADDED_INLINE_SEPARATOR),
state.thinking_label()
)
} else {
format!(
" {}{PADDED_INLINE_SEPARATOR}Thinking: {} ",
parts.join(PADDED_INLINE_SEPARATOR),
state.thinking_label()
)
};
let mut spans = Vec::new();
spans.push(Span::styled(" ", muted));
if show_setmodel_hint {
spans.push(hotkey_span("[Alt-M]"));
spans.push(Span::styled(" ", muted));
}
spans.push(Span::styled(prefix, muted));
spans.push(hotkey_span("[Alt-T]"));
spans.push(Span::styled(" ", muted));
Line::from(spans)
}
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 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 {
MATRIX_GREEN.selection()
} else {
MATRIX_GREEN.panel_alt()
};
let name_style = if selected_style {
MATRIX_GREEN.selection().add_modifier(Modifier::BOLD)
} else {
Style::new()
.fg(MATRIX_GREEN.green_bright)
.bg(MATRIX_GREEN.panel_alt)
.add_modifier(Modifier::BOLD)
};
let description_style = if selected_style {
MATRIX_GREEN.selection()
} else {
Style::new()
.fg(MATRIX_GREEN.green_muted)
.bg(MATRIX_GREEN.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(title);
let inner = block.inner(area);
frame.render_widget(block, area);
let text_area = render_overlay_fill_horizontal(frame, inner);
frame.render_widget(
Paragraph::new(lines).style(MATRIX_GREEN.panel_alt()),
text_area,
);
}
#[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 prompt_bash_mode_border_turns_yellow_for_bang_input() {
let state = MissionControlState {
input: "!git status".to_string(),
provider_ready: true,
..MissionControlState::default()
};
let buffer = rendered_prompt_buffer(&state);
assert_eq!(buffer[(0, 0)].fg, MATRIX_GREEN.bash_yellow);
assert!(contains_text(&buffer, "Bash Mode"));
}
#[test]
fn prompt_normal_border_stays_green_without_bang_input() {
let state = MissionControlState {
input: "git status".to_string(),
provider_ready: true,
..MissionControlState::default()
};
let buffer = rendered_prompt_buffer(&state);
assert_eq!(buffer[(0, 0)].fg, MATRIX_GREEN.green_bright);
assert!(contains_text(&buffer, "Prompt"));
assert!(!contains_text(&buffer, "Bash Mode"));
}
}