aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/components
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-ai/src/tui/components')
-rw-r--r--crates/atuin-ai/src/tui/components/atuin_ai.rs143
-rw-r--r--crates/atuin-ai/src/tui/components/input_box.rs220
-rw-r--r--crates/atuin-ai/src/tui/components/markdown.rs210
-rw-r--r--crates/atuin-ai/src/tui/components/mod.rs5
-rw-r--r--crates/atuin-ai/src/tui/components/select.rs95
-rw-r--r--crates/atuin-ai/src/tui/components/session_continue.rs49
6 files changed, 0 insertions, 722 deletions
diff --git a/crates/atuin-ai/src/tui/components/atuin_ai.rs b/crates/atuin-ai/src/tui/components/atuin_ai.rs
deleted file mode 100644
index 31dff1c3..00000000
--- a/crates/atuin-ai/src/tui/components/atuin_ai.rs
+++ /dev/null
@@ -1,143 +0,0 @@
-//! Top-level AtuinAi component that translates key events into AiTuiEvents.
-//!
-//! Global shortcuts (Ctrl+C, Esc) are handled in the capture phase so they
-//! fire regardless of which child is focused. Contextual shortcuts (Enter,
-//! Tab) are handled in the bubble phase so child components like the
-//! permission Select can consume them first.
-
-use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
-use eye_declare::{Elements, EventResult, Hooks, component, props};
-
-use crate::commands::inline::DriverEventSender;
-use crate::tui::events::AiTuiEvent;
-use crate::tui::state::AppMode;
-
-/// Top-level wrapper component for the AI TUI.
-///
-/// Props carry the current mode so `handle_event` can translate keys
-/// into the right `AiTuiEvent`. Children are rendered via slot children.
-#[props]
-pub(crate) struct AtuinAi {
- pub mode: AppMode,
- pub has_command: bool,
- pub is_input_blank: bool,
- pub pending_confirmation: bool,
- pub has_executing_preview: bool,
-}
-
-#[derive(Default)]
-pub(crate) struct AtuinAiState {
- tx: Option<DriverEventSender>,
-}
-
-#[component(props = AtuinAi, state = AtuinAiState, children = Elements)]
-fn atuin_ai(
- _props: &AtuinAi,
- _state: &AtuinAiState,
- hooks: &mut Hooks<AtuinAi, AtuinAiState>,
- children: Elements,
-) -> Elements {
- hooks.use_context::<DriverEventSender>(|tx, _, state| {
- state.tx = tx.cloned();
- });
-
- // Capture phase: global shortcuts that must fire regardless of child focus.
- hooks.use_event_capture(move |event, props, state| {
- let Event::Key(KeyEvent {
- code,
- kind: KeyEventKind::Press,
- modifiers,
- ..
- }) = event
- else {
- return EventResult::Ignored;
- };
-
- let Some(ref tx) = state.read().tx else {
- return EventResult::Ignored;
- };
-
- // Ctrl+C — interrupt executing command or exit
- if modifiers.contains(KeyModifiers::CONTROL) && *code == KeyCode::Char('c') {
- if props.has_executing_preview {
- let _ = tx.send(AiTuiEvent::InterruptToolExecution);
- } else {
- let _ = tx.send(AiTuiEvent::Exit);
- }
- return EventResult::Consumed;
- }
-
- // Esc — always handled at the top level
- if *code == KeyCode::Esc {
- match props.mode {
- AppMode::Input => {
- if props.has_executing_preview {
- let _ = tx.send(AiTuiEvent::InterruptToolExecution);
- } else if props.pending_confirmation {
- let _ = tx.send(AiTuiEvent::CancelConfirmation);
- } else {
- let _ = tx.send(AiTuiEvent::Exit);
- }
- }
- AppMode::Generating | AppMode::Streaming => {
- let _ = tx.send(AiTuiEvent::CancelGeneration);
- }
- AppMode::Error => {
- let _ = tx.send(AiTuiEvent::Exit);
- }
- }
- return EventResult::Consumed;
- }
-
- if *code == KeyCode::Tab
- && matches!(props.mode, AppMode::Input)
- && modifiers.contains(KeyModifiers::NONE)
- && props.has_command
- && props.is_input_blank
- {
- let _ = tx.send(AiTuiEvent::InsertCommand);
- return EventResult::Consumed;
- }
-
- EventResult::Ignored
- });
-
- // Bubble phase: contextual shortcuts that children (e.g. Select) may handle first.
- hooks.use_event(move |event, props, state| {
- let Event::Key(KeyEvent {
- code,
- kind: KeyEventKind::Press,
- ..
- }) = event
- else {
- return EventResult::Ignored;
- };
-
- let Some(ref tx) = state.read().tx else {
- return EventResult::Ignored;
- };
-
- match props.mode {
- AppMode::Input => match code {
- KeyCode::Enter => {
- if props.has_command && props.is_input_blank {
- let _ = tx.send(AiTuiEvent::ExecuteCommand);
- return EventResult::Consumed;
- }
- EventResult::Ignored
- }
- _ => EventResult::Ignored,
- },
- AppMode::Error => match code {
- KeyCode::Enter | KeyCode::Char('r') => {
- let _ = tx.send(AiTuiEvent::Retry);
- EventResult::Consumed
- }
- _ => EventResult::Ignored,
- },
- _ => EventResult::Ignored,
- }
- });
-
- children
-}
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);
- })
- )
-}
diff --git a/crates/atuin-ai/src/tui/components/markdown.rs b/crates/atuin-ai/src/tui/components/markdown.rs
deleted file mode 100644
index 607520b7..00000000
--- a/crates/atuin-ai/src/tui/components/markdown.rs
+++ /dev/null
@@ -1,210 +0,0 @@
-//! Markdown rendering component using pulldown-cmark.
-//!
-//! More robust than eye-declare's built-in Markdown component:
-//! uses a proper CommonMark parser rather than line-by-line regex.
-
-use eye_declare::{Component, props};
-use pulldown_cmark::{Event, Parser, Tag, TagEnd};
-use ratatui_core::{
- buffer::Buffer,
- layout::Rect,
- style::{Color, Modifier, Style},
- text::{Line, Span, Text},
- widgets::Widget,
-};
-use ratatui_widgets::paragraph::{Paragraph, Wrap};
-
-/// A markdown rendering component backed by pulldown-cmark.
-#[props]
-pub(crate) struct Markdown {
- pub source: String,
-}
-
-/// Style configuration for markdown rendering.
-pub(crate) struct MarkdownStyles {
- pub base: Style,
- pub code_inline: Style,
- pub code_block: Style,
- pub bold: Style,
- pub italic: Style,
- pub heading: Style,
-}
-
-impl MarkdownStyles {
- pub fn new() -> Self {
- let base = Style::default();
- Self {
- base,
- code_inline: Style::default().fg(Color::Yellow),
- code_block: Style::default().fg(Color::Green),
- bold: base.add_modifier(Modifier::BOLD),
- italic: base.add_modifier(Modifier::ITALIC),
- heading: Style::default()
- .fg(Color::Cyan)
- .add_modifier(Modifier::BOLD),
- }
- }
-}
-
-impl Default for MarkdownStyles {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl Component for Markdown {
- type State = MarkdownStyles;
-
- fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
- if self.source.is_empty() || area.width == 0 || area.height == 0 {
- return;
- }
- let text = parse_markdown(&self.source, state);
- Paragraph::new(text)
- .wrap(Wrap { trim: false })
- .render(area, buf);
- }
-
- fn desired_height(&self, width: u16, state: &Self::State) -> Option<u16> {
- if self.source.is_empty() || width == 0 {
- return Some(0);
- }
- let text = parse_markdown(&self.source, state);
- Some(
- Paragraph::new(text)
- .wrap(Wrap { trim: false })
- .line_count(width) as u16,
- )
- }
-
- fn initial_state(&self) -> Option<MarkdownStyles> {
- Some(MarkdownStyles::new())
- }
-}
-
-/// Parse markdown source into styled ratatui Text using pulldown-cmark.
-fn parse_markdown<'a>(source: &'a str, styles: &'a MarkdownStyles) -> Text<'static> {
- let parser = Parser::new(source);
- let mut lines: Vec<Vec<Span<'static>>> = vec![Vec::new()];
- let mut current_line = 0;
-
- let mut style_stack: Vec<Style> = vec![styles.base];
- let mut in_code_block = false;
- let mut in_list_item = false;
- // True until the first paragraph inside a list item has been opened.
- // The first paragraph should flow inline with the "- " prefix.
- let mut list_item_first_para = false;
-
- for event in parser {
- match event {
- Event::Start(Tag::Strong) => {
- let bold = style_stack.last().copied().unwrap_or(styles.bold);
- style_stack.push(bold);
- }
- Event::End(TagEnd::Strong) => {
- style_stack.pop();
- }
- Event::Start(Tag::Emphasis) => {
- let italic = style_stack.last().copied().unwrap_or(styles.italic);
- style_stack.push(italic);
- }
- Event::End(TagEnd::Emphasis) => {
- style_stack.pop();
- }
- Event::Start(Tag::CodeBlock(_)) => {
- in_code_block = true;
- if !lines[current_line].is_empty() {
- current_line += 1;
- lines.push(Vec::new());
- current_line += 1;
- lines.push(Vec::new());
- }
- }
- Event::End(TagEnd::CodeBlock) => {
- in_code_block = false;
- if !lines[current_line].is_empty() {
- current_line += 1;
- lines.push(Vec::new());
- }
- }
- Event::Code(code) => {
- lines[current_line].push(Span::styled(format!("{}", code), styles.code_inline));
- }
- Event::Text(text) => {
- let current_style = if in_code_block {
- styles.code_block
- } else {
- style_stack.last().copied().unwrap_or(styles.base)
- };
- let prefix = if in_code_block { " " } else { "" };
- let parts: Vec<&str> = text.split('\n').collect();
- for (i, part) in parts.iter().enumerate() {
- if i > 0 {
- current_line += 1;
- lines.push(Vec::new());
- }
- if !part.is_empty() {
- lines[current_line]
- .push(Span::styled(format!("{}{}", prefix, part), current_style));
- }
- }
- }
- Event::SoftBreak => {
- let current_style = style_stack.last().copied().unwrap_or(styles.base);
- lines[current_line].push(Span::styled(" ", current_style));
- }
- Event::HardBreak => {
- current_line += 1;
- lines.push(Vec::new());
- }
- Event::Start(Tag::Paragraph) => {
- if in_list_item && list_item_first_para {
- // First paragraph flows inline with the "- " prefix
- list_item_first_para = false;
- } else if current_line > 0 || !lines[0].is_empty() {
- current_line += 1;
- lines.push(Vec::new());
- if !in_list_item {
- // Blank separator between paragraphs (but not inside list items)
- current_line += 1;
- lines.push(Vec::new());
- }
- }
- }
- Event::End(TagEnd::Paragraph) => {}
- Event::Start(Tag::Heading { .. }) => {
- if current_line > 0 || !lines[0].is_empty() {
- current_line += 1;
- lines.push(Vec::new());
- current_line += 1;
- lines.push(Vec::new());
- }
- style_stack.push(styles.heading);
- }
- Event::End(TagEnd::Heading(_)) => {
- style_stack.pop();
- }
- Event::Start(Tag::Item) => {
- if current_line > 0 || !lines[0].is_empty() {
- current_line += 1;
- lines.push(Vec::new());
- }
- lines[current_line].push(Span::styled("- ", Style::default().fg(Color::DarkGray)));
- in_list_item = true;
- list_item_first_para = true;
- }
- Event::End(TagEnd::Item) => {
- in_list_item = false;
- }
- Event::Start(Tag::List(_)) if current_line > 0 || !lines[0].is_empty() => {
- current_line += 1;
- lines.push(Vec::new());
- }
- Event::End(TagEnd::List(_)) => {}
- _ => {}
- }
- }
-
- let text_lines: Vec<Line<'static>> = lines.into_iter().map(Line::from).collect();
- Text::from(text_lines)
-}
diff --git a/crates/atuin-ai/src/tui/components/mod.rs b/crates/atuin-ai/src/tui/components/mod.rs
deleted file mode 100644
index 9959dbad..00000000
--- a/crates/atuin-ai/src/tui/components/mod.rs
+++ /dev/null
@@ -1,5 +0,0 @@
-pub(crate) mod atuin_ai;
-pub(crate) mod input_box;
-pub(crate) mod markdown;
-pub(crate) mod select;
-pub(crate) mod session_continue;
diff --git a/crates/atuin-ai/src/tui/components/select.rs b/crates/atuin-ai/src/tui/components/select.rs
deleted file mode 100644
index 771d7830..00000000
--- a/crates/atuin-ai/src/tui/components/select.rs
+++ /dev/null
@@ -1,95 +0,0 @@
-use crossterm::event::KeyCode;
-use eye_declare::{Elements, EventResult, Hooks, Span, Text, View, component, element, props};
-use ratatui::style::Style;
-use typed_builder::TypedBuilder;
-
-use crate::commands::inline::DriverEventSender;
-use crate::tui::events::AiTuiEvent;
-
-type OnSelectFn = Box<dyn Fn(&SelectOption) -> Option<AiTuiEvent> + Send + Sync + 'static>;
-
-#[derive(TypedBuilder)]
-pub(crate) struct SelectOption {
- #[builder(setter(into))]
- pub label: String,
- #[builder(setter(into))]
- pub value: String,
- #[builder(default = Style::default())]
- pub label_style: Style,
- #[builder(default = Style::default().reversed())]
- pub selected_style: Style,
-}
-
-#[derive(Default)]
-pub(crate) struct PermissionSelectorState {
- selected_option: usize,
- tx: Option<DriverEventSender>,
-}
-
-#[props]
-pub(crate) struct Select {
- pub options: Vec<SelectOption>,
- pub on_select: OnSelectFn,
-}
-
-#[component(props = Select, state = PermissionSelectorState)]
-pub(crate) fn permission_selector(
- props: &Select,
- state: &PermissionSelectorState,
- hooks: &mut Hooks<Select, PermissionSelectorState>,
-) -> Elements {
- hooks.use_focusable(true);
- hooks.use_autofocus();
-
- hooks.use_context::<DriverEventSender>(|tx, _, state| {
- state.tx = tx.cloned();
- });
-
- hooks.use_event(move |event, props, state| {
- if !event.is_key_press() {
- return EventResult::Ignored;
- }
-
- if let crossterm::event::Event::Key(key) = event {
- if key.kind != crossterm::event::KeyEventKind::Press {
- return EventResult::Ignored;
- }
-
- match key.code {
- KeyCode::Up => {
- state.selected_option =
- (state.selected_option + props.options.len() - 1) % props.options.len();
- return EventResult::Consumed;
- }
- KeyCode::Down => {
- state.selected_option = (state.selected_option + 1) % props.options.len();
- return EventResult::Consumed;
- }
- KeyCode::Enter => {
- let option = &props.options[state.selected_option];
- if let Some(event) = (props.on_select)(option)
- && let Some(ref tx) = state.tx
- {
- let _ = tx.send(event);
- }
- return EventResult::Consumed;
- }
- _ => {}
- }
- }
-
- EventResult::Ignored
- });
-
- element!(
- View {
- #(for (index, option) in props.options.iter().enumerate() {
- Text { Span(text: &option.label, style: if index == state.selected_option {
- option.selected_style
- } else {
- option.label_style
- }) }
- })
- }
- )
-}
diff --git a/crates/atuin-ai/src/tui/components/session_continue.rs b/crates/atuin-ai/src/tui/components/session_continue.rs
deleted file mode 100644
index bfbfb191..00000000
--- a/crates/atuin-ai/src/tui/components/session_continue.rs
+++ /dev/null
@@ -1,49 +0,0 @@
-use chrono_humanize::HumanTime;
-use eye_declare::{Elements, Hooks, Span, Text, component, element, props};
-use ratatui::style::{Color, Modifier, Style};
-
-#[props]
-pub(crate) struct SessionContinue {
- pub continued_at: Option<chrono::DateTime<chrono::Utc>>,
-}
-
-#[derive(Default)]
-pub(crate) struct SessionContinueState {
- /// Frozen on mount so the label doesn't change on every render.
- label: Option<String>,
-}
-
-#[component(props = SessionContinue, state = SessionContinueState)]
-fn session_continue(
- _props: &SessionContinue,
- state: &SessionContinueState,
- hooks: &mut Hooks<SessionContinue, SessionContinueState>,
-) -> Elements {
- hooks.use_mount(|props, state| {
- state.label = Some(match props.continued_at {
- Some(t) => {
- let human = HumanTime::from(t - chrono::Utc::now());
- format!(
- " Continuing previous session (last active {human}) - type /new to start a new session"
- )
- }
- None => {
- " Continuing previous session - type /new to start a new session".to_string()
- }
- });
- });
-
- let resume_label = state
- .label
- .as_deref()
- .unwrap_or(" Continuing previous session - type /new to start a new session");
-
- element! {
- Text {
- Span(
- text: resume_label,
- style: Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
- )
- }
- }
-}