From 0478a05320ff7bc4257633bc945bd750864d68d4 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Mon, 30 Mar 2026 15:20:31 -0700 Subject: chore: Update to eye-declare 0.3.0 (#3365) --- crates/atuin-ai/Cargo.toml | 2 +- crates/atuin-ai/src/tui/components/atuin_ai.rs | 73 +++------ crates/atuin-ai/src/tui/components/input_box.rs | 177 ++++++++++------------ crates/atuin-ai/src/tui/components/markdown.rs | 16 +- crates/atuin-ai/src/tui/state.rs | 2 +- crates/atuin-ai/src/tui/view/mod.rs | 188 +++++++++--------------- 6 files changed, 182 insertions(+), 276 deletions(-) (limited to 'crates') diff --git a/crates/atuin-ai/Cargo.toml b/crates/atuin-ai/Cargo.toml index f4d6e8f2..6e7315cd 100644 --- a/crates/atuin-ai/Cargo.toml +++ b/crates/atuin-ai/Cargo.toml @@ -39,7 +39,7 @@ async-stream = "0.3" uuid = { workspace = true } tui-textarea-2 = "0.10.2" unicode-width = "0.2" -eye_declare = "0.2" +eye_declare = "0.3" ratatui-core = "0.1" ratatui-widgets = "0.3" 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>, } -impl Component for AtuinAi { - type State = AtuinAiState; - - fn initial_state(&self) -> Option { - Some(AtuinAiState::default()) - } - - fn lifecycle(&self, hooks: &mut Hooks, _state: &Self::State) { - hooks.use_context::>(|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) -> EventResult { - let state = state.read(); - +#[component(props = AtuinAi, state = AtuinAiState, children = Elements)] +fn atuin_ai( + _props: &AtuinAi, + _state: &AtuinAiState, + hooks: &mut Hooks, + children: Elements, +) -> Elements { + hooks.use_context::>(|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>, +pub(crate) struct InputBoxState { + textarea: Arc>>, tx: Option>, } @@ -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); - - 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(), - ); - } +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); - block - } -} - -impl Component for InputBox { - type State = InputBoxState; + let mut block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .padding(Padding::horizontal(1)); - fn initial_state(&self) -> Option { - Some(InputBoxState::default()) + if !props.title.is_empty() { + block = + block.title_top(Line::styled(format!(" {} ", props.title), title_style).left_aligned()); } - - fn lifecycle(&self, hooks: &mut Hooks, _state: &Self::State) { - if self.active { - hooks.use_autofocus(); - } - hooks.use_context::>(|tx, state| { - state.tx = tx.cloned(); - }); + if !props.title_right.is_empty() { + block = block.title_top( + Line::styled(format!(" {} ", props.title_right), 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); + if !props.footer.is_empty() { + block = block.title_bottom( + Line::styled(format!(" {} ", props.footer), border_style).right_aligned(), + ); } - 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 - } + block +} - fn is_focusable(&self, _state: &Self::State) -> bool { - self.active - } +#[component(props = InputBox, state = InputBoxState)] +fn input_box( + props: &InputBox, + state: &InputBoxState, + hooks: &mut Hooks, +) -> Elements { + hooks.use_focusable(props.active); + hooks.use_autofocus(); + + hooks.use_context::>(|tx, _, state| { + state.tx = tx.cloned(); + }); - fn handle_event( - &self, - event: &crossterm::event::Event, - state: &mut Tracked, - ) -> 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 { 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 { diff --git a/crates/atuin-ai/src/tui/state.rs b/crates/atuin-ai/src/tui/state.rs index c7271d29..4c5c2a1e 100644 --- a/crates/atuin-ai/src/tui/state.rs +++ b/crates/atuin-ai/src/tui/state.rs @@ -113,7 +113,7 @@ impl ConversationEvent { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Copy)] pub enum AppMode { /// User is typing input Input, diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs index a1b32518..0cd51dfa 100644 --- a/crates/atuin-ai/src/tui/view/mod.rs +++ b/crates/atuin-ai/src/tui/view/mod.rs @@ -1,8 +1,7 @@ //! View function that builds the eye-declare element tree from app state. use eye_declare::{ - Column, Component, Elements, HStack, Line, Span, Spinner, TextBlock, VStack, WidthConstraint, - element, impl_slot_children, + Cells, Column, Elements, HStack, Span, Spinner, Text, View, WidthConstraint, element, }; use ratatui_core::style::{Color, Modifier, Style}; @@ -13,40 +12,6 @@ use super::state::{AppMode, AppState}; mod turn; -#[derive(Default)] -struct Padding { - top: u16, - left: u16, - right: u16, - bottom: u16, -} - -impl Component for Padding { - type State = (); - - fn content_inset(&self, _state: &Self::State) -> eye_declare::Insets { - eye_declare::Insets::ZERO - .left(self.left) - .right(self.right) - .top(self.top) - .bottom(self.bottom) - } - - fn desired_height(&self, _width: u16, _state: &Self::State) -> u16 { - 0 - } - - fn render( - &self, - _area: ratatui::layout::Rect, - _buf: &mut ratatui::buffer::Buffer, - _state: &(), - ) { - } -} - -impl_slot_children!(Padding); - /// Build the element tree from current state. /// /// Layout (top to bottom): @@ -68,7 +33,7 @@ pub fn ai_view(state: &AppState) -> Elements { element! { AtuinAi( - mode: state.mode.clone(), + mode: state.mode, has_command: state.has_any_command(), is_input_blank: state.is_input_blank, pending_confirmation: state.confirmation_pending, @@ -88,22 +53,24 @@ pub fn ai_view(state: &AppState) -> Elements { }) #(if !state.is_exiting() { - TextBlock { Line { Span(text: "") } } - InputBox( - key: "input", - title: "Generate a command or ask a question", - title_right: "Atuin AI", - footer: state.footer_text(), - active: state.mode == AppMode::Input && !state.confirmation_pending, - ) + View(key: "input-box", padding_top: Cells::from(1)) { + InputBox( + key: "input", + title: "Generate a command or ask a question", + title_right: "Atuin AI", + footer: state.footer_text(), + active: state.mode == AppMode::Input && !state.confirmation_pending, + ) - #(if state.is_input_blank && state.has_any_command() && state.mode == AppMode::Input { - #(if state.confirmation_pending { - TextBlock { Line { Span(text: "[Enter] Confirm dangerous command [Esc] Cancel", style: Style::default().fg(Color::Gray)) } } - } else { - TextBlock { Line { Span(text: "[Enter] Execute suggested command [Tab] Insert Command", style: Style::default().fg(Color::Gray)) } } + #(if state.is_input_blank && state.has_any_command() && state.mode == AppMode::Input { + #(if state.confirmation_pending { + Text { Span(text: "[Enter] Confirm dangerous command [Esc] Cancel", style: Style::default().fg(Color::Gray)) } + } else { + Text { Span(text: "[Enter] Execute suggested command [Tab] Insert Command", style: Style::default().fg(Color::Gray)) } + }) }) - }) + + } }) } } @@ -114,25 +81,20 @@ fn user_turn_view(events: &[turn::UiEvent], first_turn: bool) -> Elements { .fg(Color::Cyan) .add_modifier(Modifier::BOLD); + let padding = if first_turn { 0 } else { 1 }; + element! { - VStack { - TextBlock { - #(if !first_turn { - Line { Span() } - }) - Line { - Span(text: "You", style: label_style) - } + View(padding_top: Cells::from(padding)) { + Text { + Span(text: "You", style: label_style) } #(for event in events { #(match event { turn::UiEvent::Text { content } => { element! { - Padding(left: 2u16) { - TextBlock { - Line { - Span(text: content, style: Style::default()) - } + View(padding_left: Cells::from(2)) { + Text { + Span(text: content, style: Style::default()) } } } @@ -150,7 +112,7 @@ fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements { .add_modifier(Modifier::BOLD); element! { - VStack { + View { Spinner( label: "Atuin AI", label_style: label_style, @@ -163,7 +125,7 @@ fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements { #(match event { turn::UiEvent::Text { content } => { element! { - Padding(left: 2u16) { + View(padding_left: Cells::from(2)) { Markdown(source: content) } } @@ -183,9 +145,9 @@ fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements { fn out_of_band_turn_view(events: &[turn::UiEvent]) -> Elements { element! { - VStack { - TextBlock { - Line { Span(text: "System", style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)) } + View { + Text { + Span(text: "System", style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)) } #(for event in events { #(match event { @@ -201,12 +163,10 @@ fn out_of_band_turn_view(events: &[turn::UiEvent]) -> Elements { fn out_of_band_output_view(details: &turn::OutOfBandOutputDetails) -> Elements { element! { - Padding(left: 2u16) { + View(padding_left: Cells::from(2)) { #(if details.command.is_some() { - TextBlock { - Line { - Span(text: details.command.as_ref().unwrap(), style: Style::default().fg(Color::Blue)) - } + Text { + Span(text: details.command.as_ref().unwrap(), style: Style::default().fg(Color::Blue)) } }) Markdown(source: details.content.clone()) @@ -254,82 +214,68 @@ fn suggested_command_view(details: &turn::SuggestedCommandDetails) -> Elements { let confidence_notes = details.confidence_level.notes(); element! { - VStack { - TextBlock { - #(if !details.first_event_in_turn { - Line { Span() } - }) - Line { - Span(text: " Suggested command:", style: Style::default().fg(Color::Cyan)) - } + View { + #(if !details.first_event_in_turn { + Text { Span(text: "") } + }) + Text { + Span(text: " Suggested command:", style: Style::default().fg(Color::Cyan)) } HStack { - Column(width: WidthConstraint::Fixed(2)) { - TextBlock { - Line { - #(if is_dangerous || low_confidence { - Span(text: "! ", style: Style::default().fg(Color::Yellow)) - } else { - Span(text: "$ ", style: Style::default().fg(Color::Blue)) - }) - } + View(width: WidthConstraint::Fixed(2)) { + Text { + #(if is_dangerous || low_confidence { + Span(text: "! ", style: Style::default().fg(Color::Yellow)) + } else { + Span(text: "$ ", style: Style::default().fg(Color::Blue)) + }) } } Column { - TextBlock { - Line { - Span(text: &details.command, style: Style::default().fg(Color::Green)) - } + Text { + Span(text: &details.command, style: Style::default().fg(Color::Green)) } } } #(if is_dangerous { - Padding(left: 2u16) { - TextBlock { - Line { - Span(text: "Danger: ", style: danger_style) - Span(text: danger_text, style: danger_style.add_modifier(Modifier::BOLD)) - } + View(padding_left: Cells::from(2)) { + Text { + Span(text: "Danger: ", style: danger_style) + Span(text: danger_text, style: danger_style.add_modifier(Modifier::BOLD)) } } }) #(if is_dangerous && danger_notes.is_some() { - Padding(left: 2u16) { + View(padding_left: Cells::from(2)) { HStack { - Column(width: WidthConstraint::Fixed(2)) { - TextBlock { - Line { - Span(text: "└") - } + View(width: WidthConstraint::Fixed(2)) { + Text { + Span(text: "└") } } - Column(width: WidthConstraint::Fill) { + View(width: WidthConstraint::Fill) { Markdown(source: danger_notes.unwrap()) } } } }) #(if low_confidence { - Padding(left: 2u16) { - TextBlock { - Line { - Span(text: "Confidence: ", style: Style::default().fg(Color::Blue)) - Span(text: confidence_level, style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)) - } + View(padding_left: Cells::from(2)) { + Text { + Span(text: "Confidence: ", style: Style::default().fg(Color::Blue)) + Span(text: confidence_level, style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)) } } }) #(if low_confidence && confidence_notes.is_some() { - Padding(left: 2u16) { + View(padding_left: Cells::from(2)) { HStack { - Column(width: WidthConstraint::Fixed(2)) { - TextBlock { - Line { - Span(text: "└") - } + View(width: WidthConstraint::Fixed(2)) { + Text { + Span(text: "└") } } - Column(width: WidthConstraint::Fill) { + View(width: WidthConstraint::Fill) { Markdown(source: confidence_notes.unwrap()) } } -- cgit v1.3.1