use ratatui::{
layout::Position,
text::{Line, Span},
};
use super::{
advisor_status::AdvisorStatus,
approval_frame, char_prefix_display_width,
composer_chrome::ComposerDividerSlot,
composer_layout::{content_width, prompt_width, PROMPT_PREFIX},
composer_pointer::{ComposerChoice, ComposerHit},
config_number_input_lines,
copy_interaction::CopyHit,
display_width,
divider::{labeled_divider_line, DividerCaption},
file_picker,
inline_choice::inline_choice_frame,
inline_shell, input_frame, list_picker_frame,
login::secret_input_lines,
login_presentation::login_composer_view,
palette::{ActivePalette, PaletteFrame, PaletteRow},
questionnaire_frame, styled_line,
text_input::text_input_lines,
truncate_one_line, App, ComposerMode, InputFrame, LineFill, Theme, MAX_COMMAND_SUGGESTIONS,
MIN_COMMAND_DESCRIPTION_WIDTH,
};
#[derive(Debug)]
pub(super) struct ComposerFrame {
pub(super) lines: Vec<Line<'static>>,
pub(super) cursor: Position,
pub(super) copy_hit: Option<CopyHit>,
pub(super) choice_hits: Vec<ComposerHit<ComposerChoice>>,
}
fn overlay_editor_caret(value: &str, cursor: usize, width: usize) -> Position {
Position {
x: char_prefix_display_width(value, cursor).min(width.max(1)) as u16,
y: 1,
}
}
impl ComposerFrame {
pub(super) fn new(lines: Vec<Line<'static>>, cursor: Position) -> Self {
Self {
lines,
cursor,
copy_hit: None,
choice_hits: Vec::new(),
}
}
}
impl App {
pub(super) fn divider_line(&self, width: usize, slot: ComposerDividerSlot) -> Line<'static> {
let width = width.max(1);
let style = match self.input_ui.composer() {
ComposerMode::Input => Theme::reasoning_input_border(self.info.runtime.reasoning),
ComposerMode::Picker(_)
| ComposerMode::Panel(_)
| ComposerMode::Side
| ComposerMode::Questionnaire(_)
| ComposerMode::Approval(_)
| ComposerMode::InlineChoice(_) => Theme::input_prompt(),
ComposerMode::SecretInput(_)
| ComposerMode::ConfigNumberInput(_)
| ComposerMode::TextInput(_)
| ComposerMode::InteractivePending(_) => Theme::dim(),
};
let left = (slot == ComposerDividerSlot::Top
&& matches!(self.input_ui.composer(), ComposerMode::Input))
.then(|| self.input_ui.shell_mode())
.flatten()
.and_then(|mode| {
DividerCaption::new(
inline_shell::mode_divider_labels(mode).iter().copied(),
style,
)
});
let right = (slot == ComposerDividerSlot::Top)
.then(|| AdvisorStatus::from_runtime(&self.info.runtime))
.and_then(|status| {
DividerCaption::new(
status.divider_labels(),
if status.needs_model() {
Theme::warning()
} else {
style
},
)
});
labeled_divider_line(left, right, style, width)
}
pub(super) fn composer_frame(&mut self, width: usize, viewport_height: usize) -> ComposerFrame {
self.refresh_composer_attachment_layout_cache(width);
let composer_copy_hovered = self.input_ui.hovered_composer_copy();
match self.input_ui.composer() {
ComposerMode::Input => {
let focused_paste = self
.focused_paste_segment()
.map(|segment| segment.start..segment.end());
let highlighted = self.input_ui.selection_range().or(focused_paste);
let mut lines = self.composer_attachment_lines(width);
let InputFrame {
lines: text_lines,
cursor,
} = input_frame(
self.input_ui.text(),
self.input_ui.cursor(),
content_width(width),
highlighted,
);
let mut text_lines = text_lines;
for (index, line) in text_lines.iter_mut().enumerate() {
line.spans.insert(
0,
if index == 0 {
Span::styled(PROMPT_PREFIX, Theme::dim())
} else {
Span::raw(" ".repeat(prompt_width()))
},
);
}
if self.input_ui.text().is_empty() && self.input_ui.shell_mode().is_none() {
let placeholder = if self.composer_shows_abort_hint() {
"esc abort"
} else {
"Type a message"
};
text_lines[0]
.spans
.push(Span::styled(placeholder, Theme::dim()));
}
lines.extend(text_lines);
ComposerFrame::new(
lines,
Position {
x: (cursor.x + prompt_width() as u16).min(width.saturating_sub(1) as u16),
y: cursor
.y
.saturating_add(self.composer_attachment_row_count(width) as u16),
},
)
}
ComposerMode::Picker(picker) => {
let cursor = Position {
x: display_width(&picker.filter)
.saturating_add(2)
.min(width.saturating_sub(1)) as u16,
y: 0,
};
if picker.is_overlay() {
return ComposerFrame::new(Vec::new(), cursor);
}
let frame = list_picker_frame(picker, width, viewport_height);
ComposerFrame {
choice_hits: frame
.hits
.into_iter()
.map(|hit| hit.map_target(ComposerChoice::PickerRow))
.collect(),
..ComposerFrame::new(frame.lines, cursor)
}
}
ComposerMode::SecretInput(secret) => ComposerFrame::new(
secret_input_lines(secret, width),
overlay_editor_caret(&secret.editor.value, secret.editor.cursor, width),
),
ComposerMode::ConfigNumberInput(input) => ComposerFrame::new(
config_number_input_lines(input, width),
overlay_editor_caret(&input.editor.value, input.editor.cursor, width),
),
ComposerMode::TextInput(input) => ComposerFrame::new(
text_input_lines(input, width),
overlay_editor_caret(&input.editor.value, input.editor.cursor, width),
),
ComposerMode::InteractivePending(pending) => {
let view =
login_composer_view(pending, width, composer_copy_hovered);
ComposerFrame {
copy_hit: view.copy_hit,
..ComposerFrame::new(view.lines, Position { x: 0, y: 0 })
}
}
ComposerMode::InlineChoice(modal) => inline_choice_frame(
&modal.choice,
width,
modal.parent_picker.is_some(),
),
ComposerMode::Questionnaire(questionnaire) => {
let frame = questionnaire_frame(questionnaire, width);
ComposerFrame {
choice_hits: frame
.hits
.into_iter()
.map(|hit| hit.map_target(ComposerChoice::Questionnaire))
.collect(),
..ComposerFrame::new(frame.lines, frame.cursor)
}
}
ComposerMode::Approval(approval) => {
let frame = approval_frame(approval, width, viewport_height);
ComposerFrame {
choice_hits: frame
.hits
.into_iter()
.map(|hit| hit.map_target(ComposerChoice::Approval))
.collect(),
..ComposerFrame::new(frame.lines, Position { x: 0, y: 0 })
}
}
ComposerMode::Panel(_) | ComposerMode::Side => {
ComposerFrame::new(Vec::new(), Position { x: 0, y: 0 })
}
}
}
pub(super) fn command_suggestion_lines(&mut self, width: usize) -> PaletteFrame {
let mut frame = PaletteFrame::default();
match self.active_palette() {
Some(ActivePalette::Command(matches)) => {
let selected_index = self
.input_ui
.command_selection()
.min(matches.len().saturating_sub(1));
let (start, _, _) = file_picker::file_palette_scroll_counts(
matches.len(),
selected_index,
MAX_COMMAND_SUGGESTIONS,
self.input_ui.palette_window_start(),
);
self.input_ui.set_palette_window_start(start);
let usage_width = matches
.iter()
.skip(start)
.take(MAX_COMMAND_SUGGESTIONS)
.map(|command| display_width(&command.usage))
.max()
.unwrap_or(1)
.min(
width
.saturating_sub(MIN_COMMAND_DESCRIPTION_WIDTH + 3)
.max(1),
);
for (index, command) in matches
.into_iter()
.enumerate()
.skip(start)
.take(MAX_COMMAND_SUGGESTIONS)
{
let selected = index == selected_index;
let marker = if selected { ">" } else { " " };
let description_width = width.saturating_sub(usage_width + 3).max(1);
let usage = truncate_one_line(&command.usage, usage_width);
let description = truncate_one_line(&command.description, description_width);
let usage_padding =
" ".repeat(usage_width.saturating_sub(display_width(&usage)));
let text = format!("{marker} {usage}{usage_padding} {description}");
frame.push_row(
styled_line(
text,
width.max(1),
palette_row_style(selected),
LineFill::Natural,
),
PaletteRow::Command(index),
selected,
);
}
}
Some(ActivePalette::File(matches)) => {
let selected_index = self
.input_ui
.file_selection()
.min(matches.len().saturating_sub(1));
let (start, above, below) = file_picker::file_palette_scroll_counts(
matches.len(),
selected_index,
MAX_COMMAND_SUGGESTIONS,
self.input_ui.palette_window_start(),
);
self.input_ui.set_palette_window_start(start);
for (index, entry) in matches.rows(start, MAX_COMMAND_SUGGESTIONS) {
let selected = index == selected_index;
let marker = if selected { ">" } else { " " };
let text = format!("{marker} {}", file_palette_row(&entry, matches.source));
frame.push_row(
styled_line(
truncate_one_line(&text, width.max(1)),
width.max(1),
palette_row_style(selected),
LineFill::Natural,
),
PaletteRow::File(index),
selected,
);
}
if let Some(footer) = file_picker::file_palette_scroll_footer(
above,
below,
matches.len(),
matches.incomplete,
) {
frame.push_line(styled_line(
truncate_one_line(&footer, width.max(1)),
width.max(1),
Theme::dim(),
LineFill::Natural,
));
}
}
None => {}
}
frame
}
}
fn palette_row_style(selected: bool) -> ratatui::style::Style {
if selected {
Theme::brand()
} else {
Theme::dim()
}
}
fn file_palette_row(
entry: &file_picker::FilePaletteEntry,
source: file_picker::PathTokenSource,
) -> String {
let prefix = match source {
file_picker::PathTokenSource::Mention => "@",
file_picker::PathTokenSource::ShellWord => "",
};
match entry {
file_picker::FilePaletteEntry::WorkspaceFile(path) => format!("{prefix}{path}"),
file_picker::FilePaletteEntry::McpResource(resource) => {
let suffix = if resource.templated {
" ยท template"
} else {
""
};
format!(
"{prefix}{} {}:{}{suffix}",
resource.uri,
resource.server,
resource.label()
)
}
}
}