summaryrefslogtreecommitdiff
path: root/makima/src/daemon/tui/ui.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-19 13:47:32 +0000
committerGitHub <noreply@github.com>2026-01-19 13:47:32 +0000
commit0833fb1f30c0c3b920157deb882e0e902c3af02a (patch)
tree45110fb8cb9277dfbaccfeb53ed9c1f76975022b /makima/src/daemon/tui/ui.rs
parent786510379bed060db2b3742b7dfca671552d2c34 (diff)
downloadsoryu-0833fb1f30c0c3b920157deb882e0e902c3af02a.tar.gz
soryu-0833fb1f30c0c3b920157deb882e0e902c3af02a.zip
Add interactive TUI browser for tasks, contracts, and files (makima view) (#7)
* feat(tui): Implement fuzzy search with real-time filtering and highlighting Adds comprehensive fuzzy search functionality to the TUI browser: ## Fuzzy Matching (fuzzy.rs) - FuzzyMatcher wrapper using SkimMatcherV2 from fuzzy-matcher crate - fuzzy_match() returns score and matched character indices - fuzzy_match_all() supports multi-term search (space-separated) - Recency-aware scoring to boost recent items in results - Unit tests for all matching scenarios ## App State (app.rs) - FilteredItem struct with index, score, and matched_indices - apply_filter() uses fuzzy matching with score-based sorting - match_count() and has_no_matches() helper methods - Results sorted by match score (highest first) ## List View (list_view.rs) - Highlighted matched characters in search results - Yellow bold styling for matched chars - Status icons with color coding ## Search Input (search_input.rs) - Real-time match count display (X/Y matches) - Visual feedback for no matches (red border) - Placeholder text when search is empty - Active search mode indication (yellow border) ## Event Handling (event.rs) - Arrow key navigation while in search mode - Ctrl+K/J for vim-style navigation during search - Delete key support alongside backspace - Ctrl+U to clear search query - Tab toggles preview while searching - Escape clears search and exits search mode Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Task completion checkpoint * [WIP] Heartbeat checkpoint - 2026-01-19 11:20:34 UTC * Task completion checkpoint * [WIP] Heartbeat checkpoint - 2026-01-19 11:31:19 UTC * Task completion checkpoint * [WIP] Heartbeat checkpoint - 2026-01-19 11:39:07 UTC * fix(tui): Fix module exports and main binary integration - Update mod.rs to properly export app, event, fuzzy, and ui modules - Add run() function for TUI entry point - Fix run_view() to use ViewCommand enum instead of ViewArgs - Fix event handling to use poll_event and handle_key_event Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'makima/src/daemon/tui/ui.rs')
-rw-r--r--makima/src/daemon/tui/ui.rs209
1 files changed, 209 insertions, 0 deletions
diff --git a/makima/src/daemon/tui/ui.rs b/makima/src/daemon/tui/ui.rs
new file mode 100644
index 0000000..4003344
--- /dev/null
+++ b/makima/src/daemon/tui/ui.rs
@@ -0,0 +1,209 @@
+//! TUI rendering.
+
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span, Text},
+ widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
+ Frame,
+};
+
+use super::app::{App, InputMode, ViewType};
+use super::event::get_help_text;
+
+/// Main render function
+pub fn render(frame: &mut Frame, app: &App) {
+ // Create main layout
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3), // Header
+ Constraint::Min(10), // Main content
+ Constraint::Length(3), // Status/Help
+ ])
+ .split(frame.area());
+
+ render_header(frame, app, chunks[0]);
+ render_main_content(frame, app, chunks[1]);
+ render_footer(frame, app, chunks[2]);
+
+ // Render confirmation dialog if in confirm mode
+ if app.input_mode == InputMode::Confirm {
+ render_confirm_dialog(frame, app);
+ }
+}
+
+/// Render header with title and search bar
+fn render_header(frame: &mut Frame, app: &App, area: Rect) {
+ let title = match app.view_type {
+ ViewType::Tasks => "Tasks",
+ ViewType::Contracts => "Contracts",
+ ViewType::Files => "Files",
+ };
+
+ let header_text = if app.input_mode == InputMode::Search || !app.search_query.is_empty() {
+ format!("{} [Search: {}]", title, app.search_query)
+ } else {
+ format!("{} ({} items)", title, app.filtered_items.len())
+ };
+
+ let header = Paragraph::new(header_text)
+ .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
+ .block(Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(if app.input_mode == InputMode::Search {
+ Color::Yellow
+ } else {
+ Color::White
+ })));
+
+ frame.render_widget(header, area);
+}
+
+/// Render main content (list + optional preview)
+fn render_main_content(frame: &mut Frame, app: &App, area: Rect) {
+ if app.preview_visible && !app.preview_content.is_empty() {
+ // Split horizontally: list on left, preview on right
+ let chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Percentage(50),
+ Constraint::Percentage(50),
+ ])
+ .split(area);
+
+ render_list(frame, app, chunks[0]);
+ render_preview(frame, app, chunks[1]);
+ } else {
+ render_list(frame, app, area);
+ }
+}
+
+/// Render the item list
+fn render_list(frame: &mut Frame, app: &App, area: Rect) {
+ let items: Vec<ListItem> = app.filtered_items
+ .iter()
+ .enumerate()
+ .map(|(i, item)| {
+ let is_selected = i == app.selected_index;
+
+ // Build the display line
+ let status_str = item.status
+ .as_ref()
+ .map(|s| format!(" [{}]", s))
+ .unwrap_or_default();
+
+ let content = format!("{}{}", item.name, status_str);
+
+ let style = if is_selected {
+ Style::default()
+ .fg(Color::Black)
+ .bg(Color::Cyan)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ let status_color = item.status.as_ref().map(|s| {
+ match s.to_lowercase().as_str() {
+ "running" | "active" => Color::Green,
+ "pending" | "waiting" => Color::Yellow,
+ "completed" | "done" => Color::Blue,
+ "failed" | "error" => Color::Red,
+ _ => Color::White,
+ }
+ }).unwrap_or(Color::White);
+
+ Style::default().fg(status_color)
+ };
+
+ ListItem::new(Line::from(vec![
+ Span::styled(content, style),
+ ]))
+ })
+ .collect();
+
+ let list = List::new(items)
+ .block(Block::default()
+ .borders(Borders::ALL)
+ .title(format!(" {} ", app.view_type.as_str())));
+
+ frame.render_widget(list, area);
+}
+
+/// Render the preview panel
+fn render_preview(frame: &mut Frame, app: &App, area: Rect) {
+ let preview = Paragraph::new(Text::raw(&app.preview_content))
+ .wrap(Wrap { trim: false })
+ .block(Block::default()
+ .borders(Borders::ALL)
+ .title(" Preview "));
+
+ frame.render_widget(preview, area);
+}
+
+/// Render footer with help text and status
+fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
+ let help_text = get_help_text(app.input_mode);
+
+ let status_text = app.status_message
+ .as_ref()
+ .map(|s| format!(" | {}", s))
+ .unwrap_or_default();
+
+ let footer_text = format!("{}{}", help_text, status_text);
+
+ let footer = Paragraph::new(footer_text)
+ .style(Style::default().fg(Color::DarkGray))
+ .block(Block::default().borders(Borders::ALL));
+
+ frame.render_widget(footer, area);
+}
+
+/// Render confirmation dialog as a centered popup
+fn render_confirm_dialog(frame: &mut Frame, app: &App) {
+ let item_name = app.get_pending_delete_name()
+ .unwrap_or_else(|| "this item".to_string());
+
+ // Calculate popup size and position
+ let area = frame.area();
+ let popup_width = 50.min(area.width.saturating_sub(4));
+ let popup_height = 7;
+
+ let popup_x = (area.width.saturating_sub(popup_width)) / 2;
+ let popup_y = (area.height.saturating_sub(popup_height)) / 2;
+
+ let popup_area = Rect {
+ x: popup_x,
+ y: popup_y,
+ width: popup_width,
+ height: popup_height,
+ };
+
+ // Clear the area behind the popup
+ frame.render_widget(Clear, popup_area);
+
+ // Build popup content
+ let text = vec![
+ Line::from(""),
+ Line::from(Span::styled(
+ "Delete Confirmation",
+ Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
+ )),
+ Line::from(""),
+ Line::from(format!("Delete '{}'?", item_name)),
+ Line::from(""),
+ Line::from(vec![
+ Span::styled("y", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
+ Span::raw(": confirm "),
+ Span::styled("n", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
+ Span::raw(": cancel"),
+ ]),
+ ];
+
+ let popup = Paragraph::new(text)
+ .alignment(Alignment::Center)
+ .block(Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Red))
+ .title(" Confirm "));
+
+ frame.render_widget(popup, popup_area);
+}