aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/components
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-30 15:20:31 -0700
committerGitHub <noreply@github.com>2026-03-30 15:20:31 -0700
commit0478a05320ff7bc4257633bc945bd750864d68d4 (patch)
tree2e1abf5b0a49fe0270b70bd6385062ea81fb0ba1 /crates/atuin-ai/src/tui/components
parentfix: replace `e>|` with `|` in nushell integration to restore history recordi... (diff)
downloadatuin-0478a05320ff7bc4257633bc945bd750864d68d4.zip
chore: Update to eye-declare 0.3.0 (#3365)
Diffstat (limited to 'crates/atuin-ai/src/tui/components')
-rw-r--r--crates/atuin-ai/src/tui/components/atuin_ai.rs71
-rw-r--r--crates/atuin-ai/src/tui/components/input_box.rs175
-rw-r--r--crates/atuin-ai/src/tui/components/markdown.rs16
3 files changed, 111 insertions, 151 deletions
diff --git a/crates/atuin-ai/src/tui/components/atuin_ai.rs b/crates/atuin-ai/src/tui/components/atuin_ai.rs
index b2239a70..fab29502 100644
--- a/crates/atuin-ai/src/tui/components/atuin_ai.rs
+++ b/crates/atuin-ai/src/tui/components/atuin_ai.rs
@@ -7,7 +7,7 @@
use std::sync::mpsc;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
-use eye_declare::{Component, EventResult, Hooks, Tracked, impl_slot_children};
+use eye_declare::{Elements, EventResult, Hooks, component, props};
use crate::tui::events::AiTuiEvent;
use crate::tui::state::AppMode;
@@ -16,60 +16,31 @@ use crate::tui::state::AppMode;
///
/// Props carry the current mode so `handle_event` can translate keys
/// into the right `AiTuiEvent`. Children are rendered via slot children.
-pub struct AtuinAi {
+#[props]
+pub(crate) struct AtuinAi {
pub mode: AppMode,
pub has_command: bool,
pub is_input_blank: bool,
pub pending_confirmation: bool,
}
-impl Default for AtuinAi {
- fn default() -> Self {
- Self {
- mode: AppMode::Input,
- has_command: false,
- is_input_blank: false,
- pending_confirmation: false,
- }
- }
-}
-
-impl_slot_children!(AtuinAi);
-
#[derive(Default)]
pub struct AtuinAiState {
tx: Option<mpsc::Sender<AiTuiEvent>>,
}
-impl Component for AtuinAi {
- type State = AtuinAiState;
-
- fn initial_state(&self) -> Option<Self::State> {
- Some(AtuinAiState::default())
- }
-
- fn lifecycle(&self, hooks: &mut Hooks<Self::State>, _state: &Self::State) {
- hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, state| {
- state.tx = tx.cloned();
- });
- }
-
- fn render(
- &self,
- _area: ratatui::layout::Rect,
- _buf: &mut ratatui::buffer::Buffer,
- _state: &Self::State,
- ) {
- // Rendering is handled by slot children
- }
-
- fn desired_height(&self, _width: u16, _state: &Self::State) -> u16 {
- 0
- }
-
- fn handle_event_capture(&self, event: &Event, state: &mut Tracked<Self::State>) -> EventResult {
- let state = state.read();
+#[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::<mpsc::Sender<AiTuiEvent>>(|tx, _, state| {
+ state.tx = tx.cloned();
+ });
+ hooks.use_event_capture(move |event, props, state| {
let Event::Key(KeyEvent {
code,
kind: KeyEventKind::Press,
@@ -80,7 +51,7 @@ impl Component for AtuinAi {
return EventResult::Ignored;
};
- let Some(ref tx) = state.tx else {
+ let Some(ref tx) = state.read().tx else {
return EventResult::Ignored;
};
@@ -90,10 +61,10 @@ impl Component for AtuinAi {
return EventResult::Consumed;
}
- match self.mode {
+ match props.mode {
AppMode::Input => match code {
KeyCode::Esc => {
- if self.pending_confirmation {
+ if props.pending_confirmation {
let _ = tx.send(AiTuiEvent::CancelConfirmation);
return EventResult::Consumed;
}
@@ -102,7 +73,7 @@ impl Component for AtuinAi {
EventResult::Consumed
}
KeyCode::Tab => {
- if self.has_command && self.is_input_blank {
+ if props.has_command && props.is_input_blank {
let _ = tx.send(AiTuiEvent::InsertCommand);
return EventResult::Consumed;
}
@@ -110,7 +81,7 @@ impl Component for AtuinAi {
EventResult::Ignored
}
KeyCode::Enter => {
- if self.has_command && self.is_input_blank {
+ if props.has_command && props.is_input_blank {
let _ = tx.send(AiTuiEvent::ExecuteCommand);
return EventResult::Consumed;
}
@@ -138,5 +109,7 @@ impl Component for AtuinAi {
_ => 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
index 3167ecc1..f5e0fe2b 100644
--- a/crates/atuin-ai/src/tui/components/input_box.rs
+++ b/crates/atuin-ai/src/tui/components/input_box.rs
@@ -6,13 +6,12 @@
//!
//! On Enter, sends `AiTuiEvent::SubmitInput` via the context-provided channel.
-use std::sync::{Mutex, mpsc};
+use std::sync::{Arc, Mutex, mpsc};
use crossterm::event::KeyModifiers;
-use eye_declare::{Component, EventResult, Hooks, Tracked};
+use eye_declare::{Canvas, Elements, EventResult, Hooks, component, element, props};
use ratatui::widgets::{Block, Borders, Padding};
use ratatui_core::{
- buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::Line,
@@ -26,8 +25,8 @@ use crate::tui::events::AiTuiEvent;
///
/// Props configure the chrome (title, footer). The TextArea itself lives
/// in the component's State so it owns cursor, wrapping, and rendering.
-#[derive(Default)]
-pub struct InputBox {
+#[props]
+pub(crate) struct InputBox {
/// Title shown in top-left border
pub title: String,
/// Right-side label in top border
@@ -38,8 +37,8 @@ pub struct InputBox {
pub active: bool,
}
-pub struct InputBoxState {
- textarea: Mutex<TextArea<'static>>,
+pub(crate) struct InputBoxState {
+ textarea: Arc<Mutex<TextArea<'static>>>,
tx: Option<mpsc::Sender<AiTuiEvent>>,
}
@@ -55,109 +54,58 @@ impl Default for InputBoxState {
.add_modifier(ratatui::style::Modifier::ITALIC),
);
Self {
- textarea: Mutex::new(textarea),
+ textarea: Arc::new(Mutex::new(textarea)),
tx: None,
}
}
}
-impl InputBox {
- /// Build the ratatui Block with current titles/footer.
- fn make_block(&self) -> Block<'_> {
- let border_style = Style::default().fg(Color::DarkGray);
- let title_style = Style::default()
- .fg(Color::Gray)
- .add_modifier(Modifier::BOLD);
+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));
+ let mut block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(border_style)
+ .padding(Padding::horizontal(1));
- if !self.title.is_empty() {
- block = block
- .title_top(Line::styled(format!(" {} ", self.title), title_style).left_aligned());
- }
- if !self.title_right.is_empty() {
- block = block.title_top(
- Line::styled(format!(" {} ", self.title_right), border_style).right_aligned(),
- );
- }
- if !self.footer.is_empty() {
- block = block.title_bottom(
- Line::styled(format!(" {} ", self.footer), border_style).right_aligned(),
- );
- }
-
- block
+ if !props.title.is_empty() {
+ block =
+ block.title_top(Line::styled(format!(" {} ", props.title), title_style).left_aligned());
}
-}
-
-impl Component for InputBox {
- type State = InputBoxState;
-
- fn initial_state(&self) -> Option<InputBoxState> {
- Some(InputBoxState::default())
+ if !props.title_right.is_empty() {
+ block = block.title_top(
+ Line::styled(format!(" {} ", props.title_right), border_style).right_aligned(),
+ );
}
-
- fn lifecycle(&self, hooks: &mut Hooks<Self::State>, _state: &Self::State) {
- if self.active {
- hooks.use_autofocus();
- }
- hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, state| {
- state.tx = tx.cloned();
- });
+ if !props.footer.is_empty() {
+ block = block.title_bottom(
+ Line::styled(format!(" {} ", props.footer), border_style).right_aligned(),
+ );
}
- fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
- if area.height < 3 || area.width < 4 {
- return;
- }
- // Configure the block on each render so titles/footer stay current.
- // Note: set_block takes ownership, but the block is cheap to rebuild.
- // We can't call set_block here since we only have &self/&state,
- // so we render block + textarea separately.
- let block = self.make_block();
- let inner = block.inner(area);
- block.render(area, buf);
-
- let mut textarea = state.textarea.lock().unwrap();
- if self.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);
- }
+ block
+}
- fn desired_height(&self, width: u16, state: &Self::State) -> u16 {
- if width < 4 {
- return 3;
- }
- // TextArea handles scrolling internally if content overflows.
- let block = self.make_block();
- let inner = block.inner(Rect::new(0, 0, width, u16::MAX));
- let chrome = (u16::MAX).saturating_sub(inner.height);
- let content = state.textarea.lock().unwrap().measure(width - 4);
- chrome + content.preferred_rows
- }
+#[component(props = InputBox, state = InputBoxState)]
+fn input_box(
+ props: &InputBox,
+ state: &InputBoxState,
+ hooks: &mut Hooks<InputBox, InputBoxState>,
+) -> Elements {
+ hooks.use_focusable(props.active);
+ hooks.use_autofocus();
- fn is_focusable(&self, _state: &Self::State) -> bool {
- self.active
- }
+ hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, _, state| {
+ state.tx = tx.cloned();
+ });
- fn handle_event(
- &self,
- event: &crossterm::event::Event,
- state: &mut Tracked<Self::State>,
- ) -> EventResult {
+ hooks.use_event(move |event, props, state| {
let state = state.read();
- if !self.active {
+ if !props.active {
return EventResult::Ignored;
}
@@ -213,5 +161,42 @@ impl Component for InputBox {
}
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
index e1551a7f..1cd7dbcf 100644
--- a/crates/atuin-ai/src/tui/components/markdown.rs
+++ b/crates/atuin-ai/src/tui/components/markdown.rs
@@ -3,7 +3,7 @@
//! 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;
+use eye_declare::{Component, props};
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
use ratatui_core::{
buffer::Buffer,
@@ -15,7 +15,7 @@ use ratatui_core::{
use ratatui_widgets::paragraph::{Paragraph, Wrap};
/// A markdown rendering component backed by pulldown-cmark.
-#[derive(Default)]
+#[props]
pub struct Markdown {
pub source: String,
}
@@ -73,14 +73,16 @@ impl Component for Markdown {
.render(area, buf);
}
- fn desired_height(&self, width: u16, state: &Self::State) -> u16 {
+ fn desired_height(&self, width: u16, state: &Self::State) -> Option<u16> {
if self.source.is_empty() || width == 0 {
- return 0;
+ return Some(0);
}
let text = parse_markdown(&self.source, state);
- Paragraph::new(text)
- .wrap(Wrap { trim: false })
- .line_count(width) as u16
+ Some(
+ Paragraph::new(text)
+ .wrap(Wrap { trim: false })
+ .line_count(width) as u16,
+ )
}
fn initial_state(&self) -> Option<MarkdownStyles> {