Skip to main content

ck_tui/
commands.rs

1use crate::chunks::IndexedChunkMeta;
2use crate::colors::*;
3use crate::state::TuiState;
4use crate::utils::find_repo_root;
5use anyhow::Result;
6use ck_index::load_index_entry;
7use ratatui::style::{Modifier, Style};
8use ratatui::text::{Line, Span};
9use std::path::Path;
10
11pub fn execute_command(state: &mut TuiState) -> Result<()> {
12    let cmd = state.query.trim();
13
14    match cmd {
15        "/help" | "/h" | "/?" => {
16            show_help(state);
17        }
18        "/clear" | "/c" => {
19            state.results.clear();
20            state.preview_content.clear();
21            state.preview_lines.clear();
22            state.query.clear();
23            state.command_mode = false;
24            state.status_message = "Cleared results".to_string();
25        }
26        "/history" => {
27            show_history(state);
28        }
29        "/stats" => {
30            show_stats(state);
31        }
32        _ => {
33            state.status_message = format!(
34                "Unknown command: {}. Type /help for available commands",
35                cmd
36            );
37        }
38    }
39
40    Ok(())
41}
42
43fn show_help(state: &mut TuiState) {
44    let help_text = vec![
45        "━━━ COMMAND MENU ━━━".to_string(),
46        "".to_string(),
47        "Available commands:".to_string(),
48        "  /help, /h, /?    - Show this help".to_string(),
49        "  /clear, /c       - Clear results and search".to_string(),
50        "  /history         - Show search history".to_string(),
51        "  /stats           - Show index statistics".to_string(),
52        "".to_string(),
53        "━━━ KEYBINDINGS ━━━".to_string(),
54        "".to_string(),
55        "  Tab              - Cycle search modes (SEM/REG/HYB)".to_string(),
56        "  Ctrl+V           - Cycle preview modes (Heatmap/Syntax/Chunks)".to_string(),
57        "  Ctrl+F           - Toggle snippet/full file view".to_string(),
58        "  Ctrl+D           - Show chunk metadata (debug)".to_string(),
59        "  Ctrl+Space       - Multi-select files".to_string(),
60        "  Ctrl+Up/Down     - Navigate search history".to_string(),
61        "  Up/Down          - Navigate results".to_string(),
62        "  PgUp/PgDn        - Scroll preview".to_string(),
63        "  Enter            - Open in $EDITOR".to_string(),
64        "  Esc, q, Ctrl+C   - Quit".to_string(),
65        "".to_string(),
66        "━━━ SEARCH MODES ━━━".to_string(),
67        "".to_string(),
68        "  SEM - Semantic: Find code by meaning".to_string(),
69        "  REG - Regex: Pattern matching".to_string(),
70        "  HYB - Hybrid: Combined semantic + regex".to_string(),
71        "".to_string(),
72        "━━━ PREVIEW MODES ━━━".to_string(),
73        "".to_string(),
74        "  Heatmap - Semantic similarity coloring".to_string(),
75        "  Syntax  - Syntax highlighting".to_string(),
76        "  Chunks  - Function/class boundaries".to_string(),
77        "".to_string(),
78        "Press Esc to close help".to_string(),
79    ];
80
81    // Convert help text to colored lines
82    state.preview_lines = help_text
83        .iter()
84        .map(|line| {
85            if line.starts_with("━━━") {
86                Line::from(Span::styled(
87                    line.clone(),
88                    Style::default().fg(COLOR_CYAN).add_modifier(Modifier::BOLD),
89                ))
90            } else if line.starts_with("  /")
91                || line.starts_with("  Ctrl")
92                || line.starts_with("  Tab")
93                || line.starts_with("  Up")
94                || line.starts_with("  PgUp")
95                || line.starts_with("  Enter")
96                || line.starts_with("  Esc")
97                || line.starts_with("  SEM")
98                || line.starts_with("  REG")
99                || line.starts_with("  HYB")
100                || line.starts_with("  Heatmap")
101                || line.starts_with("  Syntax")
102                || line.starts_with("  Chunks")
103            {
104                // Command/key on left, description on right
105                if let Some(dash_pos) = line.find(" - ") {
106                    let (key, desc) = line.split_at(dash_pos);
107                    Line::from(vec![
108                        Span::styled(
109                            key.to_string(),
110                            Style::default()
111                                .fg(COLOR_YELLOW)
112                                .add_modifier(Modifier::BOLD),
113                        ),
114                        Span::styled(desc.to_string(), Style::default().fg(COLOR_WHITE)),
115                    ])
116                } else {
117                    Line::from(Span::styled(line.clone(), Style::default().fg(COLOR_WHITE)))
118                }
119            } else if line.starts_with("Press") {
120                Line::from(Span::styled(
121                    line.clone(),
122                    Style::default()
123                        .fg(COLOR_DARK_GRAY)
124                        .add_modifier(Modifier::ITALIC),
125                ))
126            } else {
127                Line::from(Span::styled(line.clone(), Style::default().fg(COLOR_WHITE)))
128            }
129        })
130        .collect();
131
132    state.query.clear();
133    state.command_mode = false;
134    state.status_message = "Help - Press Esc to return to search".to_string();
135}
136
137pub fn show_chunks(state: &mut TuiState) {
138    // Get currently selected file
139    if state.results.is_empty() {
140        state.status_message = "No search results - run a search first".to_string();
141        state.query.clear();
142        state.command_mode = false;
143        return;
144    }
145
146    let selected_file = state.results[state.selected_idx].file.clone();
147
148    // Find repo root and load chunks
149    let repo_root = find_repo_root(&selected_file);
150    let all_chunks = if let Some(root) = repo_root {
151        load_chunk_spans(&root, &selected_file).unwrap_or_default()
152    } else {
153        Vec::new()
154    };
155
156    if all_chunks.is_empty() {
157        state.status_message = format!("No chunks found for {}", selected_file.display());
158        state.query.clear();
159        state.command_mode = false;
160        return;
161    }
162
163    // Build chunk metadata display
164    let mut chunks_text: Vec<String> = vec![
165        format!("━━━ CHUNK METADATA: {} ━━━", selected_file.display()),
166        "".to_string(),
167        format!("Total chunks: {}", all_chunks.len()),
168        "".to_string(),
169    ];
170
171    // Sort chunks by line_start for display
172    let mut sorted_chunks = all_chunks.clone();
173    sorted_chunks.sort_by_key(|c| c.span.line_start);
174
175    // Detect overlaps
176    for (i, chunk) in sorted_chunks.iter().enumerate() {
177        let chunk_type = chunk.chunk_type.as_deref().unwrap_or("unknown");
178
179        chunks_text.push(format!(
180            "Chunk #{}: {} [lines {}-{}]",
181            i + 1,
182            chunk_type,
183            chunk.span.line_start,
184            chunk.span.line_end
185        ));
186
187        // Check for overlaps with other chunks
188        let mut overlaps_with = Vec::new();
189        for (j, other) in sorted_chunks.iter().enumerate() {
190            if i == j {
191                continue;
192            }
193            // Check if chunks overlap
194            if chunk.span.line_start <= other.span.line_end
195                && chunk.span.line_end >= other.span.line_start
196            {
197                overlaps_with.push(j + 1);
198            }
199        }
200
201        if !overlaps_with.is_empty() {
202            chunks_text.push(format!(
203                "  Overlaps with: {}",
204                overlaps_with
205                    .iter()
206                    .map(|n| format!("#{}", n))
207                    .collect::<Vec<_>>()
208                    .join(", ")
209            ));
210        }
211
212        chunks_text.push("".to_string());
213    }
214
215    chunks_text.push("Press Esc to close".to_string());
216
217    // Convert to colored lines
218    state.preview_lines = chunks_text
219        .iter()
220        .map(|line| {
221            if line.starts_with("━━━") {
222                Line::from(Span::styled(
223                    line.clone(),
224                    Style::default().fg(COLOR_CYAN).add_modifier(Modifier::BOLD),
225                ))
226            } else if line.starts_with("Chunk #") {
227                Line::from(Span::styled(
228                    line.clone(),
229                    Style::default()
230                        .fg(COLOR_YELLOW)
231                        .add_modifier(Modifier::BOLD),
232                ))
233            } else if line.starts_with("  Overlaps") {
234                Line::from(Span::styled(
235                    line.clone(),
236                    Style::default().fg(COLOR_MAGENTA),
237                ))
238            } else if line.starts_with("Total chunks") {
239                Line::from(Span::styled(
240                    line.clone(),
241                    Style::default()
242                        .fg(COLOR_GREEN)
243                        .add_modifier(Modifier::BOLD),
244                ))
245            } else if line.starts_with("Press") {
246                Line::from(Span::styled(
247                    line.clone(),
248                    Style::default()
249                        .fg(COLOR_DARK_GRAY)
250                        .add_modifier(Modifier::ITALIC),
251                ))
252            } else {
253                Line::from(Span::styled(line.clone(), Style::default().fg(COLOR_WHITE)))
254            }
255        })
256        .collect();
257
258    state.query.clear();
259    state.command_mode = false;
260    state.scroll_offset = 0;
261    state.status_message = format!(
262        "Chunk metadata for {} - Press Esc to return",
263        selected_file.display()
264    );
265}
266
267fn load_chunk_spans(repo_root: &Path, file_path: &Path) -> Result<Vec<IndexedChunkMeta>, String> {
268    let standard_path = file_path
269        .strip_prefix(repo_root)
270        .unwrap_or(file_path)
271        .to_path_buf();
272    let index_dir = repo_root.join(".ck");
273    let sidecar_path = index_dir.join(format!("{}.ck", standard_path.display()));
274
275    if !sidecar_path.exists() {
276        return Ok(Vec::new());
277    }
278
279    let entry = load_index_entry(&sidecar_path)
280        .map_err(|err| format!("Failed to load chunk metadata: {}", err))?;
281    let mut metas: Vec<IndexedChunkMeta> = entry
282        .chunks
283        .iter()
284        .map(|chunk| IndexedChunkMeta {
285            span: chunk.span.clone(),
286            chunk_type: chunk.chunk_type.clone(),
287            breadcrumb: chunk.breadcrumb.clone(),
288            ancestry: chunk.ancestry.clone().unwrap_or_default(),
289            estimated_tokens: chunk.estimated_tokens,
290            byte_length: chunk.byte_length,
291            leading_trivia: chunk.leading_trivia.clone(),
292            trailing_trivia: chunk.trailing_trivia.clone(),
293        })
294        .collect();
295
296    let has_non_module = metas
297        .iter()
298        .any(|meta| meta.chunk_type.as_deref() != Some("module"));
299    if has_non_module {
300        metas.retain(|meta| meta.chunk_type.as_deref() != Some("module"));
301    }
302
303    Ok(metas)
304}
305
306fn show_history(state: &mut TuiState) {
307    if state.search_history.is_empty() {
308        state.status_message = "No search history".to_string();
309        state.query.clear();
310        state.command_mode = false;
311        return;
312    }
313
314    let history_text: Vec<String> = std::iter::once("━━━ SEARCH HISTORY ━━━".to_string())
315        .chain(std::iter::once("".to_string()))
316        .chain(
317            state
318                .search_history
319                .iter()
320                .rev()
321                .enumerate()
322                .map(|(i, query)| format!("  {}: {}", i + 1, query)),
323        )
324        .chain(std::iter::once("".to_string()))
325        .chain(std::iter::once(
326            "Use Ctrl+Up/Down to navigate history".to_string(),
327        ))
328        .collect();
329
330    state.preview_lines = history_text
331        .iter()
332        .map(|line| {
333            if line.starts_with("━━━") {
334                Line::from(Span::styled(
335                    line.clone(),
336                    Style::default().fg(COLOR_CYAN).add_modifier(Modifier::BOLD),
337                ))
338            } else if line.starts_with("  ") && line.contains(": ") {
339                Line::from(Span::styled(
340                    line.clone(),
341                    Style::default().fg(COLOR_YELLOW),
342                ))
343            } else {
344                Line::from(Span::styled(line.clone(), Style::default().fg(COLOR_WHITE)))
345            }
346        })
347        .collect();
348
349    state.query.clear();
350    state.command_mode = false;
351    state.status_message = "Search History".to_string();
352}
353
354fn show_stats(state: &mut TuiState) {
355    let stats_text = if let Some(stats) = state.index_stats.as_ref() {
356        vec![
357            "━━━ INDEX STATISTICS ━━━".to_string(),
358            "".to_string(),
359            format!("  Path: {}", state.search_path.display()),
360            format!("  Files: {}", stats.total_files),
361            format!(
362                "  Chunks: {} ({} embedded)",
363                stats.total_chunks, stats.embedded_chunks
364            ),
365            format!("  Total size: {} bytes", stats.total_size_bytes),
366            format!("  Index size: {} bytes", stats.index_size_bytes),
367            "".to_string(),
368        ]
369    } else if let Some(err) = state.index_stats_error.as_ref() {
370        vec![
371            "━━━ INDEX STATISTICS ━━━".to_string(),
372            "".to_string(),
373            format!("  Error: {}", err),
374            "".to_string(),
375        ]
376    } else {
377        vec![
378            "━━━ INDEX STATISTICS ━━━".to_string(),
379            "".to_string(),
380            "  Index data unavailable".to_string(),
381            "".to_string(),
382        ]
383    };
384
385    state.preview_lines = stats_text
386        .iter()
387        .map(|line| {
388            if line.starts_with("━━━") {
389                Line::from(Span::styled(
390                    line.clone(),
391                    Style::default().fg(COLOR_CYAN).add_modifier(Modifier::BOLD),
392                ))
393            } else if line.starts_with("  ") {
394                Line::from(Span::styled(
395                    line.clone(),
396                    Style::default().fg(COLOR_YELLOW),
397                ))
398            } else {
399                Line::from(Span::styled(line.clone(), Style::default().fg(COLOR_WHITE)))
400            }
401        })
402        .collect();
403
404    state.query.clear();
405    state.command_mode = false;
406    state.status_message = "Index Statistics".to_string();
407}