diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-10 22:01:45 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-10 22:01:45 +0200 |
| commit | 5e31a81cd2207f053b8cd8ad84ebe2a2f691b29d (patch) | |
| tree | 5d76811ab0d693c01fa472d41aa2ceaf3bd0b415 /crates/atuin-ai/src/tui/components | |
| parent | chore: Remove unneeded files (diff) | |
| download | atuin-5e31a81cd2207f053b8cd8ad84ebe2a2f691b29d.zip | |
chore: Remove some unused rust code
Diffstat (limited to 'crates/atuin-ai/src/tui/components')
| -rw-r--r-- | crates/atuin-ai/src/tui/components/atuin_ai.rs | 143 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/input_box.rs | 220 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/markdown.rs | 210 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/mod.rs | 5 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/select.rs | 95 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/session_continue.rs | 49 |
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), - ) - } - } -} |
