Skip to main content

st/terminal/
mod.rs

1//! Smart Tree Terminal Interface (STTI) - Your Coding Companion 🌳
2//!
3//! Like a construction helper who hands you tools before you ask!
4//! This module provides context-aware terminal assistance.
5
6use anyhow::Result;
7use crossterm::{
8    event::{self, Event, KeyCode, KeyEvent},
9    terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
10    ExecutableCommand,
11};
12use ratatui::{
13    backend::CrosstermBackend,
14    layout::{Constraint, Direction, Layout, Rect},
15    style::{Color, Modifier, Style},
16    text::{Line, Span, Text},
17    widgets::{Block, Borders, List, ListItem, Paragraph},
18    Frame, Terminal,
19};
20use std::{
21    io::{self, Stdout},
22    path::PathBuf,
23    sync::{Arc, Mutex},
24    time::{Duration, Instant},
25};
26use tokio::sync::mpsc;
27
28use crate::smart::ProjectType;
29
30/// Terminal UI state
31#[derive(Debug, Clone)]
32pub struct TerminalState {
33    /// Current working directory
34    pub cwd: PathBuf,
35
36    /// Active file being edited
37    pub active_file: Option<PathBuf>,
38
39    /// Recent file changes
40    pub recent_changes: Vec<FileChange>,
41
42    /// Current suggestions
43    pub suggestions: Vec<Suggestion>,
44
45    /// Command history
46    pub command_history: Vec<String>,
47
48    /// Current input buffer
49    pub input: String,
50
51    /// Cursor position in input
52    pub cursor_pos: usize,
53
54    /// Project context
55    pub project_type: Option<ProjectType>,
56
57    /// Status message
58    pub status_message: Option<StatusMessage>,
59}
60
61/// File change event
62#[derive(Debug, Clone)]
63pub struct FileChange {
64    pub path: PathBuf,
65    pub change_type: ChangeType,
66    pub timestamp: Instant,
67}
68
69#[derive(Debug, Clone)]
70pub enum ChangeType {
71    Created,
72    Modified,
73    Deleted,
74    Renamed { from: PathBuf },
75}
76
77/// Suggestion from the AI assistant
78#[derive(Debug, Clone)]
79pub struct Suggestion {
80    pub icon: &'static str,
81    pub title: String,
82    pub description: String,
83    pub action: SuggestionAction,
84    pub confidence: f32,
85}
86
87#[derive(Debug, Clone)]
88pub enum SuggestionAction {
89    InsertText(String),
90    RunCommand(String),
91    OpenFile(PathBuf),
92    CreateFile { path: PathBuf, content: String },
93    RefactorCode { file: PathBuf, operation: String },
94}
95
96/// Status message with severity
97#[derive(Debug, Clone)]
98pub struct StatusMessage {
99    pub text: String,
100    pub severity: MessageSeverity,
101    pub timestamp: Instant,
102}
103
104#[derive(Debug, Clone, Copy)]
105pub enum MessageSeverity {
106    Info,
107    Success,
108    Warning,
109    Error,
110}
111
112/// Main terminal interface
113pub struct SmartTreeTerminal {
114    /// Terminal handle
115    terminal: Terminal<CrosstermBackend<Stdout>>,
116
117    /// Current state
118    state: Arc<Mutex<TerminalState>>,
119
120    /// Context watcher
121    context_watcher: ContextWatcher,
122
123    /// Pattern analyzer
124    pattern_analyzer: PatternAnalyzer,
125
126    /// Suggestion receiver
127    suggestion_rx: mpsc::Receiver<Suggestion>,
128
129    /// Suggestion sender (for background tasks)
130    _suggestion_tx: mpsc::Sender<Suggestion>,
131}
132
133impl SmartTreeTerminal {
134    /// Create new terminal interface
135    pub fn new() -> Result<Self> {
136        // Setup terminal
137        terminal::enable_raw_mode()?;
138        let mut stdout = io::stdout();
139        stdout.execute(EnterAlternateScreen)?;
140
141        let backend = CrosstermBackend::new(stdout);
142        let terminal = Terminal::new(backend)?;
143
144        // Create channels
145        let (suggestion_tx, suggestion_rx) = mpsc::channel(100);
146
147        // Initial state
148        let state = Arc::new(Mutex::new(TerminalState {
149            cwd: std::env::current_dir()?,
150            active_file: None,
151            recent_changes: Vec::new(),
152            suggestions: Vec::new(),
153            command_history: Vec::new(),
154            input: String::new(),
155            cursor_pos: 0,
156            project_type: None,
157            status_message: None,
158        }));
159
160        Ok(Self {
161            terminal,
162            state: state.clone(),
163            context_watcher: ContextWatcher::new(state.clone(), suggestion_tx.clone()),
164            pattern_analyzer: PatternAnalyzer::new(state.clone(), suggestion_tx.clone()),
165            suggestion_rx,
166            _suggestion_tx: suggestion_tx,
167        })
168    }
169
170    /// Run the terminal interface
171    pub async fn run(&mut self) -> Result<()> {
172        // Start background tasks
173        self.context_watcher.start().await?;
174        self.pattern_analyzer.start().await?;
175
176        loop {
177            // Draw UI
178            self.draw()?;
179
180            // Handle events
181            if event::poll(Duration::from_millis(100))? {
182                if let Event::Key(key) = event::read()? {
183                    if self.handle_key(key).await? {
184                        break;
185                    }
186                }
187            }
188
189            // Process suggestions
190            while let Ok(suggestion) = self.suggestion_rx.try_recv() {
191                let mut state = self.state.lock().unwrap();
192                state.suggestions.push(suggestion);
193                // Keep only the 5 most recent suggestions
194                if state.suggestions.len() > 5 {
195                    state.suggestions.remove(0);
196                }
197            }
198        }
199
200        // Cleanup
201        terminal::disable_raw_mode()?;
202        self.terminal.backend_mut().execute(LeaveAlternateScreen)?;
203
204        Ok(())
205    }
206
207    /// Draw the UI
208    fn draw(&mut self) -> Result<()> {
209        let state = self.state.lock().unwrap().clone();
210
211        self.terminal.draw(|f| {
212            let chunks = Layout::default()
213                .direction(Direction::Vertical)
214                .constraints([
215                    Constraint::Length(3), // Header
216                    Constraint::Length(3), // Context bar
217                    Constraint::Min(10),   // Main area
218                    Constraint::Length(3), // Input
219                    Constraint::Length(1), // Status bar
220                ])
221                .split(f.size());
222
223            // Header
224            Self::draw_header(f, chunks[0], &state);
225
226            // Context bar
227            Self::draw_context(f, chunks[1], &state);
228
229            // Main area (split into suggestions and history)
230            let main_chunks = Layout::default()
231                .direction(Direction::Horizontal)
232                .constraints([
233                    Constraint::Percentage(60), // History/Output
234                    Constraint::Percentage(40), // Suggestions
235                ])
236                .split(chunks[2]);
237
238            Self::draw_history(f, main_chunks[0], &state);
239            Self::draw_suggestions(f, main_chunks[1], &state);
240
241            // Input area
242            Self::draw_input(f, chunks[3], &state);
243
244            // Status bar
245            Self::draw_status(f, chunks[4], &state);
246        })?;
247
248        Ok(())
249    }
250
251    /// Draw header
252    fn draw_header(f: &mut Frame, area: Rect, _state: &TerminalState) {
253        let show_banner =
254            std::env::var("ST_BANNER").is_ok_and(|v| v == "1" || v.to_lowercase() == "true");
255
256        let mut lines: Vec<Line> = Vec::new();
257        if show_banner {
258            lines.push(Line::from(vec![
259                Span::styled("⚡ ", Style::default().fg(Color::Yellow)),
260                Span::styled(
261                    "SMART TREE TERMINAL",
262                    Style::default()
263                        .fg(Color::Green)
264                        .add_modifier(Modifier::BOLD),
265                ),
266                Span::styled("  🌊  ", Style::default().fg(Color::Cyan)),
267                Span::styled("rocking your repo", Style::default().fg(Color::Magenta)),
268                Span::raw("  🎸"),
269            ]));
270        }
271
272        lines.push(Line::from(vec![
273            Span::styled(
274                "Smart Tree Terminal",
275                Style::default()
276                    .fg(Color::Green)
277                    .add_modifier(Modifier::BOLD),
278            ),
279            Span::raw(" v5.5 - "),
280            Span::styled("Your Coding Companion ", Style::default().fg(Color::Cyan)),
281            Span::raw("🌳"),
282        ]));
283
284        let header = Paragraph::new(Text::from(lines))
285            .block(Block::default().borders(Borders::ALL))
286            .alignment(ratatui::layout::Alignment::Center);
287
288        f.render_widget(header, area);
289    }
290
291    /// Draw context information
292    fn draw_context(f: &mut Frame, area: Rect, state: &TerminalState) {
293        let mut context_items = vec![Span::styled("Context: ", Style::default().fg(Color::Gray))];
294
295        if let Some(file) = &state.active_file {
296            context_items.push(Span::styled(
297                format!("Editing: {} ", file.display()),
298                Style::default().fg(Color::Yellow),
299            ));
300        }
301
302        if let Some(project) = &state.project_type {
303            context_items.push(Span::styled(
304                format!("| Project: {:?} ", project),
305                Style::default().fg(Color::Blue),
306            ));
307        }
308
309        if std::env::var("HOT_TUB").is_ok_and(|v| v == "1" || v.to_lowercase() == "true") {
310            context_items.push(Span::styled(
311                "| 🛁 Hot Tub Mode ",
312                Style::default()
313                    .fg(Color::Magenta)
314                    .add_modifier(Modifier::BOLD),
315            ));
316        }
317
318        let context = Paragraph::new(Line::from(context_items))
319            .block(Block::default().borders(Borders::LEFT | Borders::RIGHT));
320
321        f.render_widget(context, area);
322    }
323
324    /// Draw command history
325    fn draw_history(f: &mut Frame, area: Rect, state: &TerminalState) {
326        let history_items: Vec<ListItem> = state
327            .command_history
328            .iter()
329            .rev()
330            .take(area.height as usize - 2)
331            .map(|cmd| ListItem::new(cmd.as_str()))
332            .collect();
333
334        let history =
335            List::new(history_items).block(Block::default().title("History").borders(Borders::ALL));
336
337        f.render_widget(history, area);
338    }
339
340    /// Draw suggestions panel
341    fn draw_suggestions(f: &mut Frame, area: Rect, state: &TerminalState) {
342        let suggestion_items: Vec<ListItem> = state
343            .suggestions
344            .iter()
345            .map(|s| {
346                ListItem::new(vec![
347                    Line::from(vec![
348                        Span::raw(s.icon),
349                        Span::raw(" "),
350                        Span::styled(&s.title, Style::default().add_modifier(Modifier::BOLD)),
351                    ]),
352                    Line::from(Span::styled(
353                        &s.description,
354                        Style::default().fg(Color::Gray),
355                    )),
356                ])
357            })
358            .collect();
359
360        let suggestions = List::new(suggestion_items).block(
361            Block::default()
362                .title("💡 Suggestions")
363                .borders(Borders::ALL),
364        );
365
366        f.render_widget(suggestions, area);
367    }
368
369    /// Draw input area
370    fn draw_input(f: &mut Frame, area: Rect, state: &TerminalState) {
371        let input = Paragraph::new(state.input.as_str()).block(
372            Block::default()
373                .title(format!("{}$ ", state.cwd.display()))
374                .borders(Borders::ALL),
375        );
376
377        f.render_widget(input, area);
378
379        // Set cursor position
380        f.set_cursor(area.x + state.cursor_pos as u16 + 1, area.y + 1);
381    }
382
383    /// Draw status bar
384    fn draw_status(f: &mut Frame, area: Rect, state: &TerminalState) {
385        let status_text = if let Some(msg) = &state.status_message {
386            let color = match msg.severity {
387                MessageSeverity::Info => Color::Blue,
388                MessageSeverity::Success => Color::Green,
389                MessageSeverity::Warning => Color::Yellow,
390                MessageSeverity::Error => Color::Red,
391            };
392
393            Span::styled(&msg.text, Style::default().fg(color))
394        } else {
395            Span::raw("Ready")
396        };
397
398        let status = Paragraph::new(Line::from(vec![
399            status_text,
400            Span::raw(" | "),
401            Span::raw("Press Ctrl+C to exit"),
402        ]));
403
404        f.render_widget(status, area);
405    }
406
407    /// Handle keyboard input
408    async fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
409        match key.code {
410            KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
411                return Ok(true); // Exit
412            }
413            KeyCode::Char(c) => {
414                let _cursor_pos = {
415                    let mut state = self.state.lock().unwrap();
416                    let cursor_pos_local = state.cursor_pos;
417                    state.input.insert(cursor_pos_local, c);
418                    state.cursor_pos += 1;
419                    cursor_pos_local
420                }; // lock dropped here before await
421
422                // Trigger pattern analysis on input change
423                self.pattern_analyzer.analyze_input().await?;
424            }
425            KeyCode::Backspace => {
426                let mut state = self.state.lock().unwrap();
427                if state.cursor_pos > 0 {
428                    let cursor_pos = state.cursor_pos;
429                    state.input.remove(cursor_pos - 1);
430                    state.cursor_pos -= 1;
431                }
432            }
433            KeyCode::Enter => {
434                let command = {
435                    let mut state = self.state.lock().unwrap();
436                    let command = state.input.clone();
437                    state.command_history.push(command.clone());
438                    state.input.clear();
439                    state.cursor_pos = 0;
440                    command
441                }; // lock dropped
442
443                // Process command
444                self.process_command(&command).await?;
445            }
446            KeyCode::Tab => {
447                // Accept top suggestion
448                let maybe_action = {
449                    let state = self.state.lock().unwrap();
450                    state.suggestions.first().map(|s| s.action.clone())
451                }; // drop lock before await
452                if let Some(action) = maybe_action {
453                    self.apply_suggestion(action).await?;
454                }
455            }
456            _ => {}
457        }
458
459        Ok(false)
460    }
461
462    /// Process a command
463    async fn process_command(&mut self, command: &str) -> Result<()> {
464        // This is where we'd integrate with the shell
465        // For now, just update status
466        {
467            let mut state = self.state.lock().unwrap();
468            state.status_message = Some(StatusMessage {
469                text: format!("Executed: {}", command),
470                severity: MessageSeverity::Info,
471                timestamp: Instant::now(),
472            });
473        }
474
475        Ok(())
476    }
477
478    /// Apply a suggestion
479    async fn apply_suggestion(&mut self, action: SuggestionAction) -> Result<()> {
480        match action {
481            SuggestionAction::InsertText(text) => {
482                let mut state = self.state.lock().unwrap();
483                let cursor_pos = state.cursor_pos;
484                state.input.insert_str(cursor_pos, &text);
485                state.cursor_pos += text.len();
486            }
487            SuggestionAction::RunCommand(cmd) => {
488                self.process_command(&cmd).await?;
489            }
490            _ => {
491                // TODO: Implement other actions
492            }
493        }
494
495        Ok(())
496    }
497}
498
499/// Context watcher - monitors file system and project state
500pub struct ContextWatcher {
501    state: Arc<Mutex<TerminalState>>,
502    _suggestion_tx: mpsc::Sender<Suggestion>,
503}
504
505impl ContextWatcher {
506    fn new(state: Arc<Mutex<TerminalState>>, _suggestion_tx: mpsc::Sender<Suggestion>) -> Self {
507        Self {
508            state,
509            _suggestion_tx,
510        }
511    }
512
513    async fn start(&self) -> Result<()> {
514        // TODO: Implement file watching
515        // For now, detect project type
516        let cwd = {
517            let state = self.state.lock().unwrap();
518            state.cwd.clone()
519        };
520
521        if cwd.join("Cargo.toml").exists() {
522            {
523                let mut state = self.state.lock().unwrap();
524                state.project_type = Some(ProjectType::Rust);
525            }
526
527            // Send a suggestion
528            let _ = self
529                ._suggestion_tx
530                .send(Suggestion {
531                    icon: "🦀",
532                    title: "Rust Project Detected".to_string(),
533                    description: "Run 'cargo build' to compile".to_string(),
534                    action: SuggestionAction::RunCommand("cargo build".to_string()),
535                    confidence: 0.9,
536                })
537                .await;
538        }
539
540        Ok(())
541    }
542}
543
544/// Pattern analyzer - analyzes coding patterns and suggests actions
545pub struct PatternAnalyzer {
546    state: Arc<Mutex<TerminalState>>,
547    _suggestion_tx: mpsc::Sender<Suggestion>,
548}
549
550impl PatternAnalyzer {
551    fn new(state: Arc<Mutex<TerminalState>>, _suggestion_tx: mpsc::Sender<Suggestion>) -> Self {
552        Self {
553            state,
554            _suggestion_tx,
555        }
556    }
557
558    async fn start(&self) -> Result<()> {
559        // TODO: Implement pattern learning
560        Ok(())
561    }
562
563    async fn analyze_input(&self) -> Result<()> {
564        let input = {
565            let state = self.state.lock().unwrap();
566            state.input.clone()
567        };
568
569        // Simple pattern matching for demo
570        if input.starts_with("git com") {
571            let _ = self
572                ._suggestion_tx
573                .send(Suggestion {
574                    icon: "📝",
575                    title: "Git Commit".to_string(),
576                    description: "Commit recent changes".to_string(),
577                    action: SuggestionAction::InsertText("mit -m \"".to_string()),
578                    confidence: 0.8,
579                })
580                .await;
581        } else if input.contains("import") {
582            let _ = self
583                ._suggestion_tx
584                .send(Suggestion {
585                    icon: "📦",
586                    title: "Import Suggestion".to_string(),
587                    description: "Add commonly used imports".to_string(),
588                    action: SuggestionAction::InsertText(" { useState } from 'react'".to_string()),
589                    confidence: 0.7,
590                })
591                .await;
592        }
593
594        Ok(())
595    }
596}
597
598// Trisha says: "This terminal is like having a personal assistant who knows
599// exactly which receipt you need before you even open the filing cabinet!" 📁