use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::{prelude::*, widgets::Clear};
use crate::config::constants::ui;
use crate::core_tui::app::session::transient::TransientSurface;
use crate::core_tui::session::inline_list::{InlineListRow, selection_padding};
use crate::core_tui::session::list_panel::{
ListPanelLayout, SharedListPanelSections, SharedListPanelStyles, SharedSearchField,
StaticRowsListPanelModel, fixed_section_rows_with_divider, render_shared_list_panel,
rows_to_u16,
};
use crate::core_tui::style::{ratatui_color_from_ansi, ratatui_style_from_inline};
use super::super::types::InlineTextStyle;
use super::{
Session,
slash_palette::{self, SlashPaletteUpdate, command_prefix, command_range},
};
pub(crate) fn split_inline_slash_area(session: &mut Session, area: Rect) -> (Rect, Option<Rect>) {
if area.height == 0 || area.width == 0 {
session.slash_palette.clear_visible_rows();
return (area, None);
}
let Some(layout) = slash_panel_layout(session) else {
session.slash_palette.clear_visible_rows();
return (area, None);
};
let (transcript_area, panel_area) = layout.split(area);
if panel_area.is_none() {
session.slash_palette.clear_visible_rows();
return (transcript_area, None);
}
(transcript_area, panel_area)
}
pub fn render_slash_palette(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
if area.height == 0
|| area.width == 0
|| !session.slash_palette_visible()
|| !session.inline_lists_visible()
{
session.slash_palette.clear_visible_rows();
return;
}
let suggestions = session.slash_palette.suggestions();
if suggestions.is_empty() {
session.slash_palette.clear_visible_rows();
return;
}
frame.render_widget(Clear, area);
let rows = slash_rows(session);
let item_count = rows.len();
let default_style = session.core.styles.default_style();
let highlight_style = slash_highlight_style(session);
let name_style = slash_name_style(session);
let description_style = slash_description_style(session);
let prefix = selection_padding();
let rendered_rows = rows
.into_iter()
.map(|row| {
(
InlineListRow::single(
Line::from(vec![
Span::styled(prefix.clone(), default_style),
Span::styled(format!("/{}", row.name), name_style),
Span::raw(" "),
Span::styled(row.description, description_style),
]),
default_style,
),
1_u16,
)
})
.collect::<Vec<_>>();
let selected = session
.slash_palette
.selected_index()
.filter(|index| *index < item_count);
let offset = session.slash_palette.scroll_offset();
let search_line = command_prefix(
session.core.input_manager.content(),
session.core.input_manager.cursor(),
)
.map(|prefix| {
let filter = prefix.trim_start_matches('/');
SharedSearchField {
label: "Search commands".to_owned(),
placeholder: Some("command name or description".to_owned()),
query: filter.to_owned(),
}
});
let sections = SharedListPanelSections {
header: vec![Line::from(Span::styled(
"Slash Commands".to_owned(),
session.core.section_title_style(),
))],
info: slash_palette_instructions(session),
search: search_line,
};
let mut model = StaticRowsListPanelModel {
rows: rendered_rows,
selected,
offset,
visible_rows: 0,
};
render_shared_list_panel(
frame,
area,
sections,
SharedListPanelStyles {
base_style: default_style,
selected_style: Some(highlight_style),
text_style: default_style,
divider_style: Some(session.core.styles.border_style()),
},
&mut model,
);
session
.slash_palette
.set_visible_rows(model.visible_rows.min(ui::INLINE_LIST_MAX_ROWS));
session.slash_palette.set_selected(model.selected);
session.slash_palette.set_scroll_offset(model.offset);
}
fn slash_palette_instructions(session: &Session) -> Vec<Line<'static>> {
vec![Line::from(Span::styled(
"Navigation: ↑/↓ select • Enter apply • Esc dismiss".to_owned(),
session.core.styles.default_style(),
))]
}
pub(crate) fn slash_panel_layout(session: &Session) -> Option<ListPanelLayout> {
if !session.slash_palette_visible()
|| !session.inline_lists_visible()
|| session.slash_palette.is_empty()
{
return None;
}
let info_rows = slash_palette_instructions(session).len();
let has_search_row = command_prefix(
session.core.input_manager.content(),
session.core.input_manager.cursor(),
)
.is_some();
let fixed_rows = fixed_section_rows_with_divider(1, info_rows, has_search_row, true);
let desired_list_rows = rows_to_u16(ui::INLINE_LIST_MAX_ROWS);
Some(ListPanelLayout::new(fixed_rows, desired_list_rows))
}
pub(super) fn handle_slash_palette_change(session: &mut Session) {
session.core.recalculate_transcript_rows();
session.core.enforce_scroll_bounds();
session.core.mark_dirty();
}
pub(super) fn clear_slash_suggestions(session: &mut Session) {
let changed = session.slash_palette.clear();
session.close_transient_surface(TransientSurface::SlashPalette);
if changed {
handle_slash_palette_change(session);
}
}
pub(super) fn update_slash_suggestions(session: &mut Session) {
if !session.core.input_enabled() {
return;
}
let Some(prefix) = command_prefix(
session.core.input_manager.content(),
session.core.input_manager.cursor(),
) else {
clear_slash_suggestions(session);
return;
};
session.ensure_inline_lists_visible_for_trigger();
match session.slash_palette.update(Some(&prefix)) {
SlashPaletteUpdate::NoChange => {}
SlashPaletteUpdate::Cleared | SlashPaletteUpdate::Changed { .. } => {
if !session.slash_palette.is_empty() {
session.show_transient_surface(TransientSurface::SlashPalette);
} else {
session.close_transient_surface(TransientSurface::SlashPalette);
}
handle_slash_palette_change(session);
}
}
}
pub(crate) fn slash_navigation_available(session: &Session) -> bool {
let has_prefix = command_prefix(
session.core.input_manager.content(),
session.core.input_manager.cursor(),
)
.is_some();
session.core.input_enabled()
&& session.inline_lists_visible()
&& session.slash_palette_visible()
&& has_prefix
}
pub(super) fn move_slash_selection_up(session: &mut Session) -> bool {
let changed = session.slash_palette.move_up();
handle_slash_selection_change(session, changed)
}
pub(super) fn move_slash_selection_down(session: &mut Session) -> bool {
let changed = session.slash_palette.move_down();
handle_slash_selection_change(session, changed)
}
pub(super) fn select_first_slash_suggestion(session: &mut Session) -> bool {
let changed = session.slash_palette.select_first();
handle_slash_selection_change(session, changed)
}
pub(super) fn select_last_slash_suggestion(session: &mut Session) -> bool {
let changed = session.slash_palette.select_last();
handle_slash_selection_change(session, changed)
}
pub(super) fn page_up_slash_suggestion(session: &mut Session) -> bool {
let changed = session.slash_palette.page_up();
handle_slash_selection_change(session, changed)
}
pub(super) fn page_down_slash_suggestion(session: &mut Session) -> bool {
let changed = session.slash_palette.page_down();
handle_slash_selection_change(session, changed)
}
pub(super) fn handle_slash_selection_change(session: &mut Session, changed: bool) -> bool {
if changed {
preview_selected_slash_suggestion(session);
session.core.recalculate_transcript_rows();
session.core.enforce_scroll_bounds();
session.core.mark_dirty();
true
} else {
false
}
}
pub(super) fn select_slash_suggestion_index(session: &mut Session, index: usize) -> bool {
let changed = session.slash_palette.select_index(index);
handle_slash_selection_change(session, changed)
}
fn preview_selected_slash_suggestion(session: &mut Session) {
let Some(command) = session.slash_palette.selected_command() else {
return;
};
let Some(range) = command_range(
session.core.input_manager.content(),
session.core.input_manager.cursor(),
) else {
return;
};
let current_input = session.core.input_manager.content().to_owned();
let prefix = ¤t_input[..range.start];
let suffix = ¤t_input[range.end..];
let mut new_input = String::new();
new_input.push_str(prefix);
new_input.push('/');
new_input.push_str(command.name.as_str());
let cursor_position = new_input.len();
if !suffix.is_empty() {
if !suffix.chars().next().is_some_and(char::is_whitespace) {
new_input.push(' ');
}
new_input.push_str(suffix);
}
session.core.input_manager.set_content(new_input.clone());
session
.input_manager
.set_cursor(cursor_position.min(new_input.len()));
session.mark_dirty();
}
pub(super) fn apply_selected_slash_suggestion(session: &mut Session) -> bool {
let Some(command) = session.slash_palette.selected_command() else {
return false;
};
let command_name = command.name.to_owned();
let input_content = session.core.input_manager.content();
let cursor_pos = session.core.input_manager.cursor();
let Some(range) = command_range(input_content, cursor_pos) else {
return false;
};
let suffix = input_content[range.end..].to_owned();
let mut new_input = format!("/{}", command_name);
let cursor_position = if suffix.is_empty() {
new_input.push(' ');
new_input.len()
} else {
if !suffix.chars().next().is_some_and(char::is_whitespace) {
new_input.push(' ');
}
let position = new_input.len();
new_input.push_str(&suffix);
position
};
session.core.input_manager.set_content(new_input);
session.core.input_manager.set_cursor(cursor_position);
clear_slash_suggestions(session);
session.mark_dirty();
true
}
pub(super) fn autocomplete_slash_suggestion(session: &mut Session) -> bool {
let input_content = session.core.input_manager.content();
let cursor_pos = session.core.input_manager.cursor();
let Some(range) = command_range(input_content, cursor_pos) else {
return false;
};
let prefix_text = command_prefix(input_content, cursor_pos).unwrap_or_default();
if prefix_text.is_empty() {
return false;
}
let suggestions = session.slash_palette.suggestions();
if suggestions.is_empty() {
return false;
}
let Some(best_command) = suggestions.first().map(|suggestion| match suggestion {
slash_palette::SlashPaletteSuggestion::Static(command) => command.name.as_str(),
}) else {
return false;
};
let suffix = &input_content[range.end..];
let mut new_input = format!("/{}", best_command);
let cursor_position = if suffix.is_empty() {
new_input.push(' ');
new_input.len()
} else {
if !suffix.chars().next().is_some_and(char::is_whitespace) {
new_input.push(' ');
}
let position = new_input.len();
new_input.push_str(suffix);
position
};
session.core.input_manager.set_content(new_input);
session.core.input_manager.set_cursor(cursor_position);
clear_slash_suggestions(session);
session.mark_dirty();
true
}
pub(super) fn try_handle_slash_navigation(
session: &mut Session,
key: &KeyEvent,
has_control: bool,
has_alt: bool,
has_command: bool,
) -> bool {
if !slash_navigation_available(session) {
return false;
}
if has_control {
return false;
}
if has_alt && !matches!(key.code, KeyCode::Up | KeyCode::Down) {
return false;
}
let handled = match key.code {
KeyCode::Up => {
if has_alt && !has_command {
return false;
}
if has_command {
select_first_slash_suggestion(session)
} else {
move_slash_selection_up(session)
}
}
KeyCode::Down => {
if has_alt && !has_command {
return false;
}
if has_command {
select_last_slash_suggestion(session)
} else {
move_slash_selection_down(session)
}
}
KeyCode::PageUp => page_up_slash_suggestion(session),
KeyCode::PageDown => page_down_slash_suggestion(session),
KeyCode::Tab => autocomplete_slash_suggestion(session),
KeyCode::BackTab => move_slash_selection_up(session),
KeyCode::Enter => {
let applied = apply_selected_slash_suggestion(session);
if !applied {
return false;
}
let should_submit_now = should_submit_immediately_from_palette(session);
if should_submit_now {
return false;
}
true
}
_ => return false,
};
if handled {
session.mark_dirty();
}
handled
}
pub(crate) fn should_submit_immediately_from_palette(session: &Session) -> bool {
let Some(command) = session
.core
.input_manager
.content()
.split_whitespace()
.next()
else {
return false;
};
matches!(
command,
"/files"
| "/ide"
| "/mode"
| "/status"
| "/stop"
| "/pause"
| "/doctor"
| "/model"
| "/mcp"
| "/skills"
| "/new"
| "/review"
| "/git"
| "/docs"
| "/copy"
| "/help"
| "/clear"
| "/compact"
| "/login"
| "/logout"
| "/auth"
| "/refresh-oauth"
| "/resume"
| "/fork"
| "/history"
| "/exit"
)
}
#[derive(Clone)]
struct SlashRow {
name: String,
description: String,
}
fn slash_rows(session: &Session) -> Vec<SlashRow> {
session
.slash_palette
.suggestions()
.iter()
.map(|suggestion| match suggestion {
slash_palette::SlashPaletteSuggestion::Static(command) => SlashRow {
name: command.name.to_owned(),
description: command.description.to_owned(),
},
})
.collect()
}
fn slash_highlight_style(session: &Session) -> Style {
let mut style = Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
if let Some(primary) = session.core.theme.primary.or(session.core.theme.secondary) {
style = style.fg(ratatui_color_from_ansi(primary));
}
style
}
fn slash_name_style(session: &Session) -> Style {
let style = InlineTextStyle::default()
.bold()
.with_color(session.core.theme.primary.or(session.core.theme.foreground));
ratatui_style_from_inline(&style, session.core.theme.foreground)
}
fn slash_description_style(session: &Session) -> Style {
session
.core
.styles
.default_style()
.add_modifier(Modifier::DIM)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::tui::InlineTheme;
#[test]
fn immediate_submit_matcher_accepts_immediate_commands() {
let mut session = Session::new(InlineTheme::default(), None, 20);
session.set_input("/files".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/ide".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input(" /status ".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/mode".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/history".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/mcp".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/skills".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/review".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/resume".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/fork".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/stop".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/pause".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/login".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/logout".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/auth".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/refresh-oauth".to_string());
assert!(should_submit_immediately_from_palette(&session));
session.set_input("/compact".to_string());
assert!(should_submit_immediately_from_palette(&session));
}
#[test]
fn immediate_submit_matcher_rejects_argument_driven_commands() {
let mut session = Session::new(InlineTheme::default(), None, 20);
session.set_input("/command echo hello".to_string());
assert!(!should_submit_immediately_from_palette(&session));
session.set_input("/review-template src/lib.rs".to_string());
assert!(!should_submit_immediately_from_palette(&session));
}
#[test]
fn slash_palette_instructions_hide_filter_hint_row() {
let session = Session::new(InlineTheme::default(), None, 20);
let instructions = slash_palette_instructions(&session);
assert_eq!(instructions.len(), 1);
let text: String = instructions[0]
.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect();
assert!(text.contains("Navigation:"));
assert!(!text.contains("Type to filter slash commands"));
}
}