Skip to main content

codetether_agent/tui/
symbol_search.rs

1//! Symbol search view for the TUI
2//!
3//! Provides a fuzzy finder-style popup for searching LSP workspace symbols.
4//! Triggered by Ctrl+T or /symbols command.
5
6use ratatui::{
7    Frame,
8    layout::{Constraint, Direction, Layout, Rect},
9    style::{Color, Modifier, Style},
10    text::{Line, Span},
11    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
12};
13use std::path::PathBuf;
14
15/// A symbol result from LSP workspace/symbol query
16#[derive(Debug, Clone)]
17pub struct SymbolEntry {
18    /// Symbol name (e.g., "LspManager", "run_app")
19    pub name: String,
20    /// Symbol kind (e.g., "Struct", "Function", "Enum")
21    pub kind: String,
22    /// File path where the symbol is defined
23    pub path: PathBuf,
24    /// URI from LSP (e.g., "file:///path/to/file.rs")
25    pub uri: Option<String>,
26    /// Line number (1-based)
27    pub line: Option<u32>,
28    /// Container name (e.g., module or parent struct)
29    pub container: Option<String>,
30}
31
32/// State for the symbol search popup
33#[derive(Debug, Default)]
34pub struct SymbolSearchState {
35    /// Search query string
36    pub query: String,
37    /// Search results from LSP
38    pub results: Vec<SymbolEntry>,
39    /// Currently selected result index
40    pub selected: usize,
41    /// Whether LSP query is in progress
42    pub loading: bool,
43    /// Error message if LSP failed
44    pub error: Option<String>,
45    /// List state for ratatui
46    pub list_state: ListState,
47}
48
49impl SymbolSearchState {
50    pub fn new() -> Self {
51        Self {
52            query: String::new(),
53            results: Vec::new(),
54            selected: 0,
55            loading: false,
56            error: None,
57            list_state: ListState::default(),
58        }
59    }
60
61    /// Clear all state
62    pub fn reset(&mut self) {
63        self.query.clear();
64        self.results.clear();
65        self.selected = 0;
66        self.loading = false;
67        self.error = None;
68        self.list_state = ListState::default();
69    }
70
71    /// Add a character to the query
72    pub fn push_char(&mut self, c: char) {
73        self.query.push(c);
74        self.selected = 0;
75    }
76
77    /// Remove the last character from the query
78    pub fn pop_char(&mut self) {
79        self.query.pop();
80        self.selected = 0;
81    }
82
83    /// Move selection up
84    pub fn select_prev(&mut self) {
85        if !self.results.is_empty() {
86            self.selected = self.selected.saturating_sub(1);
87            self.list_state.select(Some(self.selected));
88        }
89    }
90
91    /// Move selection down
92    pub fn select_next(&mut self) {
93        if !self.results.is_empty() {
94            self.selected = (self.selected + 1).min(self.results.len() - 1);
95            self.list_state.select(Some(self.selected));
96        }
97    }
98
99    /// Get the currently selected symbol
100    pub fn selected_symbol(&self) -> Option<&SymbolEntry> {
101        self.results.get(self.selected)
102    }
103
104    /// Update results from LSP query
105    pub fn set_results(&mut self, results: Vec<SymbolEntry>) {
106        self.results = results;
107        self.selected = 0;
108        self.loading = false;
109        self.error = None;
110        if !self.results.is_empty() {
111            self.list_state.select(Some(0));
112        }
113    }
114
115    /// Set error state
116    pub fn set_error(&mut self, error: String) {
117        self.error = Some(error);
118        self.loading = false;
119        self.results.clear();
120    }
121
122    /// Open the symbol search popup
123    pub fn open(&mut self) {
124        self.reset();
125    }
126
127    /// Close the symbol search popup
128    pub fn close(&mut self) {
129        self.reset();
130    }
131
132    /// Handle character input
133    pub fn handle_char(&mut self, c: char) {
134        self.push_char(c);
135    }
136
137    /// Handle backspace
138    pub fn handle_backspace(&mut self) {
139        self.pop_char();
140    }
141}
142
143/// Render the symbol search popup overlay
144pub fn render_symbol_search(f: &mut Frame, state: &mut SymbolSearchState, area: Rect) {
145    // Center the popup
146    let popup_area = centered_rect(80, 60, area);
147
148    // Clear the area underneath
149    f.render_widget(Clear, popup_area);
150
151    let chunks = Layout::default()
152        .direction(Direction::Vertical)
153        .constraints([
154            Constraint::Length(3), // Search input
155            Constraint::Min(3),    // Results list
156            Constraint::Length(1), // Status/footer
157        ])
158        .split(popup_area);
159
160    // Search input box
161    let input_style = Style::default().fg(Color::Yellow);
162    let input = Paragraph::new(Line::from(vec![
163        Span::styled("๐Ÿ” ", Style::default().fg(Color::Cyan)),
164        Span::styled(&state.query, input_style),
165        Span::raw("โ–"), // Cursor
166    ]))
167    .block(
168        Block::default()
169            .borders(Borders::ALL)
170            .title(" Symbol Search (Esc: close, Enter: jump) ")
171            .border_style(Style::default().fg(Color::Cyan)),
172    );
173
174    f.render_widget(input, chunks[0]);
175
176    // Results list
177    let items: Vec<ListItem> = state
178        .results
179        .iter()
180        .map(|sym| {
181            let kind_color = symbol_kind_color(&sym.kind);
182            let mut spans = vec![
183                Span::styled(format!(" {:8} ", sym.kind), Style::default().fg(kind_color)),
184                Span::styled(&sym.name, Style::default().fg(Color::White).bold()),
185            ];
186
187            if let Some(ref container) = sym.container {
188                spans.push(Span::styled(
189                    format!(" ({})", container),
190                    Style::default().fg(Color::DarkGray),
191                ));
192            }
193
194            spans.push(Span::styled(
195                format!(
196                    " โ†’ {}",
197                    sym.path
198                        .file_name()
199                        .map(|n| n.to_string_lossy().to_string())
200                        .unwrap_or_else(|| sym.path.display().to_string())
201                ),
202                Style::default().fg(Color::DarkGray),
203            ));
204
205            if let Some(line) = sym.line {
206                spans.push(Span::styled(
207                    format!(":{}", line),
208                    Style::default().fg(Color::Yellow),
209                ));
210            }
211
212            if let Some(uri) = &sym.uri {
213                let uri_label = uri
214                    .strip_prefix("file://")
215                    .unwrap_or(uri)
216                    .rsplit('/')
217                    .next()
218                    .unwrap_or(uri.as_str());
219                spans.push(Span::styled(
220                    format!(" ยท {uri_label}"),
221                    Style::default().fg(Color::DarkGray),
222                ));
223            }
224
225            ListItem::new(Line::from(spans))
226        })
227        .collect();
228
229    let list = List::new(items)
230        .block(
231            Block::default()
232                .borders(Borders::ALL)
233                .title(format!(" Results ({}) ", state.results.len()))
234                .border_style(Style::default().fg(Color::DarkGray)),
235        )
236        .highlight_style(
237            Style::default()
238                .bg(Color::DarkGray)
239                .add_modifier(Modifier::BOLD),
240        )
241        .highlight_symbol("โ–ถ ");
242
243    // Sync list state
244    state.list_state.select(Some(state.selected));
245    f.render_stateful_widget(list, chunks[1], &mut state.list_state);
246
247    // Status bar
248    let status_text = if state.loading {
249        Span::styled(" Searching...", Style::default().fg(Color::Yellow))
250    } else if let Some(ref err) = state.error {
251        Span::styled(format!(" Error: {}", err), Style::default().fg(Color::Red))
252    } else if state.results.is_empty() && !state.query.is_empty() {
253        Span::styled(" No symbols found", Style::default().fg(Color::DarkGray))
254    } else {
255        Span::styled(
256            " โ†‘โ†“:navigate  Enter:open  Esc:close",
257            Style::default().fg(Color::DarkGray),
258        )
259    };
260
261    let status = Paragraph::new(Line::from(status_text));
262    f.render_widget(status, chunks[2]);
263}
264
265/// Get color for symbol kind
266fn symbol_kind_color(kind: &str) -> Color {
267    match kind {
268        "Function" | "Method" => Color::Yellow,
269        "Struct" | "Class" | "Enum" | "Interface" => Color::Cyan,
270        "Module" | "Namespace" => Color::Magenta,
271        "Constant" | "Field" | "Property" => Color::Green,
272        "Variable" | "Parameter" => Color::Blue,
273        _ => Color::White,
274    }
275}
276
277/// Helper to create a centered rectangle
278fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
279    let popup_layout = Layout::default()
280        .direction(Direction::Vertical)
281        .constraints([
282            Constraint::Percentage((100 - percent_y) / 2),
283            Constraint::Percentage(percent_y),
284            Constraint::Percentage((100 - percent_y) / 2),
285        ])
286        .split(r);
287
288    Layout::default()
289        .direction(Direction::Horizontal)
290        .constraints([
291            Constraint::Percentage((100 - percent_x) / 2),
292            Constraint::Percentage(percent_x),
293            Constraint::Percentage((100 - percent_x) / 2),
294        ])
295        .split(popup_layout[1])[1]
296}