aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/components/input_box.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-ai/src/tui/components/input_box.rs')
-rw-r--r--crates/atuin-ai/src/tui/components/input_box.rs220
1 files changed, 0 insertions, 220 deletions
diff --git a/crates/atuin-ai/src/tui/components/input_box.rs b/crates/atuin-ai/src/tui/components/input_box.rs
deleted file mode 100644
index 6b81322c..00000000
--- a/crates/atuin-ai/src/tui/components/input_box.rs
+++ /dev/null
@@ -1,220 +0,0 @@
-//! Bordered input box component for the AI TUI.
-//!
-//! Wraps tui-textarea's TextArea, which handles rendering, wrapping, cursor
-//! positioning, and height measurement natively. The component configures the
-//! TextArea's block (border + titles) and forwards events to it.
-//!
-//! On Enter, sends `AiTuiEvent::SubmitInput` via the context-provided channel.
-
-use std::sync::{Arc, Mutex};
-
-use crossterm::event::KeyModifiers;
-use eye_declare::{Canvas, Elements, EventResult, Hooks, component, element, props};
-use ratatui::widgets::{Block, Borders, Padding};
-use ratatui_core::{
- layout::Rect,
- style::{Color, Modifier, Style},
- text::Line,
- widgets::Widget,
-};
-use tui_textarea::TextArea;
-
-use crate::commands::inline::DriverEventSender;
-use crate::tui::{events::AiTuiEvent, slash::SlashCommandSearchResult};
-
-/// A bordered text input box backed by tui-textarea.
-///
-/// Props configure the chrome (title, footer). The TextArea itself lives
-/// in the component's State so it owns cursor, wrapping, and rendering.
-#[props]
-pub(crate) struct InputBox {
- /// Title shown in top-left border
- pub title: String,
- /// Right-side label in top border
- pub title_right: String,
- /// Footer text shown in bottom border (keybinding hints)
- pub footer: String,
- /// Whether the input is currently active (shows cursor, accepts input)
- pub active: bool,
- /// If the user has typed a slash command, this holds the best match for it.
- pub slash_suggestion: Option<SlashCommandSearchResult>,
-}
-
-pub(crate) struct InputBoxState {
- textarea: Arc<Mutex<TextArea<'static>>>,
- tx: Option<DriverEventSender>,
-}
-
-impl Default for InputBoxState {
- fn default() -> Self {
- let mut textarea = TextArea::default();
- textarea.set_cursor_line_style(ratatui::style::Style::default());
- textarea.set_wrap_mode(tui_textarea::WrapMode::Word);
- textarea.set_placeholder_text("Type a message...");
- textarea.set_placeholder_style(
- ratatui::style::Style::default()
- .fg(ratatui::style::Color::DarkGray)
- .add_modifier(ratatui::style::Modifier::ITALIC),
- );
- Self {
- textarea: Arc::new(Mutex::new(textarea)),
- tx: None,
- }
- }
-}
-
-fn make_block(props: &InputBox) -> Block<'static> {
- let border_style = Style::default().fg(Color::DarkGray);
- let title_style = Style::default()
- .fg(Color::Gray)
- .add_modifier(Modifier::BOLD);
-
- let mut block = Block::default()
- .borders(Borders::ALL)
- .border_style(border_style)
- .padding(Padding::horizontal(1));
-
- if !props.title.is_empty() {
- block =
- block.title_top(Line::styled(format!(" {} ", props.title), title_style).left_aligned());
- }
- if !props.title_right.is_empty() {
- block = block.title_top(
- Line::styled(format!(" {} ", props.title_right), border_style).right_aligned(),
- );
- }
- if !props.footer.is_empty() {
- block = block.title_bottom(
- Line::styled(format!(" {} ", props.footer), border_style).right_aligned(),
- );
- }
-
- block
-}
-
-#[component(props = InputBox, state = InputBoxState)]
-fn input_box(
- props: &InputBox,
- state: &InputBoxState,
- hooks: &mut Hooks<InputBox, InputBoxState>,
-) -> Elements {
- // Always focusable so focus isn't lost when the permission Select is
- // removed from the tree. The `active` prop controls visual state and
- // whether keystrokes are processed, not focusability.
- hooks.use_focusable(true);
- hooks.use_autofocus();
-
- hooks.use_context::<DriverEventSender>(|tx, _, state| {
- state.tx = tx.cloned();
- });
-
- hooks.use_event(move |event, props, state| {
- let state = state.read();
-
- if !props.active {
- return EventResult::Ignored;
- }
-
- if let crossterm::event::Event::Paste(text) = event {
- let mut textarea = state.textarea.lock().unwrap();
- textarea.insert_str(text);
- return EventResult::Consumed;
- }
-
- if let crossterm::event::Event::Key(key) = event {
- if key.kind != crossterm::event::KeyEventKind::Press {
- return EventResult::Ignored;
- }
-
- let mut textarea = state.textarea.lock().unwrap();
-
- match key.code {
- crossterm::event::KeyCode::Char('j')
- if key.modifiers.contains(KeyModifiers::CONTROL) =>
- {
- textarea.insert_newline();
- return EventResult::Consumed;
- }
- crossterm::event::KeyCode::Tab if props.slash_suggestion.is_some() => {
- // If there's a slash command suggestion, Tab accepts it.
- if let Some(suggestion) = &props.slash_suggestion {
- textarea.clear();
- textarea.insert_str(format!("/{}", suggestion.command.name));
- // Manually trigger an input update event so the slash suggestion box can update immediately
- if let Some(ref tx) = state.tx {
- let _ = tx.send(AiTuiEvent::InputUpdated(textarea.lines().join("\n")));
- }
- return EventResult::Consumed;
- }
- }
- crossterm::event::KeyCode::Enter => {
- if key.modifiers.contains(KeyModifiers::SHIFT) {
- textarea.insert_newline();
- return EventResult::Consumed;
- } else {
- let text = textarea.lines().join("\n");
- if text.trim().is_empty() {
- return EventResult::Ignored;
- }
-
- textarea.clear();
-
- if let Some(ref tx) = state.tx {
- let _ = tx.send(AiTuiEvent::SubmitInput(text));
- }
- return EventResult::Consumed;
- }
- }
- _ => {}
- }
-
- // All other keys: forward to textarea.
- // tui-textarea can convert crossterm events itself.
- textarea.input(*key);
-
- if let Some(ref tx) = state.tx {
- let _ = tx.send(AiTuiEvent::InputUpdated(textarea.lines().join("\n")));
- }
- return EventResult::Consumed;
- }
-
- EventResult::Ignored
- });
-
- let textarea = state.textarea.clone();
- let block = make_block(props);
- let active = props.active;
- element!(
- Canvas(render_fn: move |area, buf| {
- let mut area = area;
-
- if area.height < 3 || area.width < 4 {
- return;
- }
-
- let height = {
- // TextArea handles scrolling internally if content overflows.
- let inner = block.inner(Rect::new(0, 0, area.width, u16::MAX));
- let chrome = (u16::MAX).saturating_sub(inner.height);
- let content = textarea.lock().unwrap().measure(area.width - 4);
- chrome + content.preferred_rows
- };
-
- area.height = height.min(7);
- let inner = block.clone().inner(area);
- block.clone().render(area, buf);
-
- let mut textarea = textarea.lock().unwrap();
- if active {
- textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
- textarea.set_placeholder_text("Type a message...");
- } else {
- textarea.set_cursor_style(Style::default());
- textarea.set_placeholder_text("");
- }
-
- // Render textarea into the inner area
- textarea.render(inner, buf);
- })
- )
-}