tenseleyflow/fackr / 5b2d0d0

Browse files

feat: integrate LSP into editor

Wire LSP functionality into the editor:
- F1: Hover information popup
- F2: Go to definition
- F3: Find references
- F4: Rename symbol
- Tab: Trigger/navigate completions
- Alt+M: Toggle LSP server manager panel

Add diagnostic rendering with underlines and gutter indicators.
Sync document changes to language servers on edit.
Authored by espadonne
SHA
5b2d0d05b90b873f247d192625b4bdfe26bfe473
Parents
05e93ba
Tree
ab962b8

4 changed files

StatusFile+-
M src/editor/state.rs 913 25
M src/main.rs 2 0
M src/render/screen.rs 1084 22
M src/workspace/state.rs 92 0
src/editor/state.rsmodified
1073 lines changed — click to load
@@ -6,6 +6,7 @@ use std::time::{Duration, Instant};
66
 
77
 use crate::buffer::Buffer;
88
 use crate::input::{Key, Modifiers, Mouse, Button};
9
+use crate::lsp::{CompletionItem, Diagnostic, HoverInfo, Location, ServerManagerPanel};
910
 use crate::render::{PaneBounds as RenderPaneBounds, PaneInfo, Screen, TabInfo};
1011
 use crate::workspace::{PaneDirection, Tab, Workspace};
1112
 
@@ -34,6 +35,36 @@ enum TextInputAction {
3435
     GitCommit,
3536
     /// Create a git tag
3637
     GitTag,
38
+    /// LSP rename symbol
39
+    LspRename { path: String, line: u32, col: u32 },
40
+}
41
+
42
+/// LSP UI state
43
+#[derive(Debug, Default)]
44
+struct LspState {
45
+    /// Current hover information to display
46
+    hover: Option<HoverInfo>,
47
+    /// Whether hover popup is visible
48
+    hover_visible: bool,
49
+    /// Current completion list
50
+    completions: Vec<CompletionItem>,
51
+    /// Selected completion index
52
+    completion_index: usize,
53
+    /// Whether completion popup is visible
54
+    completion_visible: bool,
55
+    /// Current diagnostics for the active file
56
+    diagnostics: Vec<Diagnostic>,
57
+    /// Go-to-definition results (for multi-result navigation)
58
+    definition_locations: Vec<Location>,
59
+    /// Pending request IDs (to match responses)
60
+    pending_hover: Option<i64>,
61
+    pending_completion: Option<i64>,
62
+    pending_definition: Option<i64>,
63
+    pending_references: Option<i64>,
64
+    /// Last known buffer hash (to detect changes)
65
+    last_buffer_hash: Option<u64>,
66
+    /// Last file path that was synced to LSP
67
+    last_synced_path: Option<PathBuf>,
3768
 }
3869
 
3970
 /// Main editor state
@@ -56,6 +87,10 @@ pub struct Editor {
5687
     prompt: PromptState,
5788
     /// Last time we wrote backups
5889
     last_backup: Instant,
90
+    /// LSP-related UI state
91
+    lsp_state: LspState,
92
+    /// LSP server manager panel
93
+    server_manager: ServerManagerPanel,
5994
 }
6095
 
6196
 impl Editor {
@@ -97,6 +132,8 @@ impl Editor {
97132
             escape_time,
98133
             prompt: PromptState::None,
99134
             last_backup: Instant::now(),
135
+            lsp_state: LspState::default(),
136
+            server_manager: ServerManagerPanel::new(),
100137
         };
101138
 
102139
         // If there are backups, show restore prompt
@@ -208,6 +245,21 @@ impl Editor {
208245
         tab.panes[pane_idx].viewport_line = line;
209246
     }
210247
 
248
+    /// Get current viewport column (horizontal scroll offset)
249
+    #[inline]
250
+    fn viewport_col(&self) -> usize {
251
+        let tab = self.workspace.active_tab();
252
+        tab.panes[tab.active_pane].viewport_col
253
+    }
254
+
255
+    /// Set current viewport column (horizontal scroll offset)
256
+    #[inline]
257
+    fn set_viewport_col(&mut self, col: usize) {
258
+        let tab = self.workspace.active_tab_mut();
259
+        let pane_idx = tab.active_pane;
260
+        tab.panes[pane_idx].viewport_col = col;
261
+    }
262
+
211263
     /// Get current filename
212264
     #[inline]
213265
     fn filename(&self) -> Option<PathBuf> {
@@ -222,19 +274,12 @@ impl Editor {
222274
         self.render()?;
223275
 
224276
         while self.running {
225
-            // Block until an event is available (no busy polling)
226
-            match event::read()? {
227
-                Event::Key(key_event) => self.process_key(key_event)?,
228
-                Event::Mouse(mouse_event) => self.process_mouse(mouse_event)?,
229
-                Event::Resize(cols, rows) => {
230
-                    self.screen.cols = cols;
231
-                    self.screen.rows = rows;
232
-                }
233
-                _ => {}
234
-            }
277
+            // Track whether we need to re-render
278
+            let mut needs_render = false;
235279
 
236
-            // Process any additional queued events before rendering
237
-            while event::poll(Duration::from_millis(0))? {
280
+            // Poll with a short timeout to allow LSP processing
281
+            // This balances responsiveness with CPU usage
282
+            if event::poll(Duration::from_millis(50))? {
238283
                 match event::read()? {
239284
                     Event::Key(key_event) => self.process_key(key_event)?,
240285
                     Event::Mouse(mouse_event) => self.process_mouse(mouse_event)?,
@@ -244,14 +289,40 @@ impl Editor {
244289
                     }
245290
                     _ => {}
246291
                 }
292
+                needs_render = true;
293
+
294
+                // Process any additional queued events before rendering
295
+                while event::poll(Duration::from_millis(0))? {
296
+                    match event::read()? {
297
+                        Event::Key(key_event) => self.process_key(key_event)?,
298
+                        Event::Mouse(mouse_event) => self.process_mouse(mouse_event)?,
299
+                        Event::Resize(cols, rows) => {
300
+                            self.screen.cols = cols;
301
+                            self.screen.rows = rows;
302
+                        }
303
+                        _ => {}
304
+                    }
305
+                }
306
+            }
307
+
308
+            // Process LSP messages from language servers
309
+            if self.process_lsp_messages() {
310
+                needs_render = true;
311
+            }
312
+
313
+            // Poll for completed server installations
314
+            if self.server_manager.poll_installs() {
315
+                needs_render = true;
247316
             }
248317
 
249318
             // Check if it's time to backup modified buffers
250319
             self.maybe_backup();
251320
 
252
-            // Only render after processing events
253
-            self.screen.refresh_size()?;
254
-            self.render()?;
321
+            // Only render if something changed
322
+            if needs_render {
323
+                self.screen.refresh_size()?;
324
+                self.render()?;
325
+            }
255326
         }
256327
 
257328
         self.screen.leave_raw_mode()?;
@@ -268,6 +339,518 @@ impl Editor {
268339
         }
269340
     }
270341
 
342
+    /// Process LSP messages. Returns true if any messages were processed.
343
+    fn process_lsp_messages(&mut self) -> bool {
344
+        use crate::lsp::LspResponse;
345
+
346
+        // Process pending messages from language servers
347
+        self.workspace.lsp.process_messages();
348
+
349
+        let mut had_response = false;
350
+
351
+        // Handle any responses that came in
352
+        while let Some(response) = self.workspace.lsp.poll_response() {
353
+            had_response = true;
354
+            match response {
355
+                LspResponse::Completions(id, items) => {
356
+                    if self.lsp_state.pending_completion == Some(id) {
357
+                        self.lsp_state.completions = items;
358
+                        self.lsp_state.completion_index = 0;
359
+                        self.lsp_state.completion_visible = !self.lsp_state.completions.is_empty();
360
+                        self.lsp_state.pending_completion = None;
361
+                    }
362
+                }
363
+                LspResponse::Hover(id, info) => {
364
+                    if self.lsp_state.pending_hover == Some(id) {
365
+                        self.lsp_state.hover = info;
366
+                        self.lsp_state.hover_visible = self.lsp_state.hover.is_some();
367
+                        self.lsp_state.pending_hover = None;
368
+                        if self.lsp_state.hover.is_none() {
369
+                            self.message = Some("No hover info available".to_string());
370
+                        }
371
+                    }
372
+                }
373
+                LspResponse::Definition(id, locations) => {
374
+                    if self.lsp_state.pending_definition == Some(id) {
375
+                        self.lsp_state.definition_locations = locations.clone();
376
+                        self.lsp_state.pending_definition = None;
377
+                        // Jump to first definition
378
+                        if let Some(loc) = locations.first() {
379
+                            self.goto_location(loc);
380
+                        } else {
381
+                            self.message = Some("No definition found".to_string());
382
+                        }
383
+                    }
384
+                }
385
+                LspResponse::References(id, locations) => {
386
+                    if self.lsp_state.pending_references == Some(id) {
387
+                        self.lsp_state.pending_references = None;
388
+                        if locations.is_empty() {
389
+                            self.message = Some("No references found".to_string());
390
+                        } else if locations.len() == 1 {
391
+                            self.goto_location(&locations[0]);
392
+                        } else {
393
+                            // Multiple references - show count and go to first
394
+                            self.message = Some(format!("Found {} references", locations.len()));
395
+                            self.goto_location(&locations[0]);
396
+                        }
397
+                    }
398
+                }
399
+                LspResponse::Symbols(id, symbols) => {
400
+                    // TODO: Show symbols panel
401
+                    let _ = (id, symbols);
402
+                }
403
+                LspResponse::Formatting(id, edits) => {
404
+                    // Apply formatting edits
405
+                    let _ = (id, edits);
406
+                    // TODO: Apply text edits to buffer
407
+                }
408
+                LspResponse::Rename(_id, workspace_edit) => {
409
+                    // Apply rename edits across all affected files
410
+                    let mut total_edits = 0;
411
+                    let mut files_changed = 0;
412
+
413
+                    for (uri, edits) in &workspace_edit.changes {
414
+                        if let Some(path_str) = crate::lsp::uri_to_path(uri) {
415
+                            // Check if we have this file open
416
+                            let path = std::path::PathBuf::from(&path_str);
417
+                            if let Some(tab_idx) = self.workspace.find_tab_by_path(&path) {
418
+                                // Apply edits to the open buffer (in reverse order to preserve positions)
419
+                                let mut sorted_edits = edits.clone();
420
+                                sorted_edits.sort_by(|a, b| {
421
+                                    // Sort by start position, descending
422
+                                    b.range.start.line.cmp(&a.range.start.line)
423
+                                        .then(b.range.start.character.cmp(&a.range.start.character))
424
+                                });
425
+
426
+                                for edit in sorted_edits {
427
+                                    self.workspace.apply_text_edit(tab_idx, &edit);
428
+                                    total_edits += 1;
429
+                                }
430
+                                files_changed += 1;
431
+                            } else {
432
+                                // File not open - would need to open, edit, and save
433
+                                self.message = Some(format!("Note: {} not open, skipped", path_str));
434
+                            }
435
+                        }
436
+                    }
437
+
438
+                    if total_edits > 0 {
439
+                        self.message = Some(format!("Renamed: {} edits in {} file(s)", total_edits, files_changed));
440
+                    } else {
441
+                        self.message = Some("No rename edits to apply".to_string());
442
+                    }
443
+                }
444
+                LspResponse::CodeActions(id, actions) => {
445
+                    // TODO: Show code actions menu
446
+                    let _ = (id, actions);
447
+                }
448
+                LspResponse::Error(id, message) => {
449
+                    // Clear any pending state for this request
450
+                    if self.lsp_state.pending_completion == Some(id) {
451
+                        self.lsp_state.pending_completion = None;
452
+                    }
453
+                    if self.lsp_state.pending_hover == Some(id) {
454
+                        self.lsp_state.pending_hover = None;
455
+                    }
456
+                    if self.lsp_state.pending_definition == Some(id) {
457
+                        self.lsp_state.pending_definition = None;
458
+                    }
459
+                    if self.lsp_state.pending_references == Some(id) {
460
+                        self.lsp_state.pending_references = None;
461
+                    }
462
+                    // Optionally show error
463
+                    if !message.is_empty() {
464
+                        self.message = Some(format!("LSP: {}", message));
465
+                    }
466
+                }
467
+            }
468
+        }
469
+
470
+        // Update diagnostics for current file
471
+        if let Some(path) = self.filename() {
472
+            let path_str = path.to_string_lossy();
473
+            self.lsp_state.diagnostics = self.workspace.lsp.get_diagnostics(&path_str);
474
+        }
475
+
476
+        // Sync document changes to LSP if buffer has changed
477
+        self.sync_document_to_lsp();
478
+
479
+        had_response
480
+    }
481
+
482
+    /// Sync document changes to LSP server
483
+    fn sync_document_to_lsp(&mut self) {
484
+        let current_path = self.filename();
485
+        let current_hash = self.buffer().content_hash();
486
+
487
+        // Check if we switched files
488
+        let file_changed = current_path != self.lsp_state.last_synced_path;
489
+
490
+        // Check if buffer content changed
491
+        let content_changed = self.lsp_state.last_buffer_hash != Some(current_hash);
492
+
493
+        if file_changed {
494
+            // Close the old document if we had one open
495
+            if let Some(ref old_path) = self.lsp_state.last_synced_path {
496
+                let old_path_str = old_path.to_string_lossy();
497
+                let _ = self.workspace.lsp.close_document(&old_path_str);
498
+            }
499
+
500
+            // Open the new document
501
+            if let Some(ref path) = current_path {
502
+                let tab = self.workspace.active_tab();
503
+                let pane = &tab.panes[tab.active_pane];
504
+                let buffer_entry = &tab.buffers[pane.buffer_idx];
505
+
506
+                let full_path = if buffer_entry.is_orphan {
507
+                    path.clone()
508
+                } else {
509
+                    self.workspace.root.join(path)
510
+                };
511
+                let path_str = full_path.to_string_lossy();
512
+                let content = self.buffer().contents();
513
+                let _ = self.workspace.lsp.open_document(&path_str, &content);
514
+            }
515
+
516
+            self.lsp_state.last_synced_path = current_path;
517
+            self.lsp_state.last_buffer_hash = Some(current_hash);
518
+        } else if content_changed {
519
+            // Content changed - send didChange notification
520
+            if let Some(ref path) = current_path {
521
+                let tab = self.workspace.active_tab();
522
+                let pane = &tab.panes[tab.active_pane];
523
+                let buffer_entry = &tab.buffers[pane.buffer_idx];
524
+
525
+                let full_path = if buffer_entry.is_orphan {
526
+                    path.clone()
527
+                } else {
528
+                    self.workspace.root.join(path)
529
+                };
530
+                let path_str = full_path.to_string_lossy();
531
+                let content = self.buffer().contents();
532
+                let _ = self.workspace.lsp.document_changed(&path_str, &content);
533
+            }
534
+
535
+            self.lsp_state.last_buffer_hash = Some(current_hash);
536
+        }
537
+    }
538
+
539
+    /// Navigate to an LSP location
540
+    fn goto_location(&mut self, location: &Location) {
541
+        use crate::lsp::uri_to_path;
542
+
543
+        if let Some(path) = uri_to_path(&location.uri) {
544
+            let path_buf = PathBuf::from(&path);
545
+            // Open the file if not already open
546
+            if let Err(e) = self.workspace.open_file(&path_buf) {
547
+                self.message = Some(format!("Failed to open {}: {}", path, e));
548
+                return;
549
+            }
550
+
551
+            // Move cursor to the location
552
+            let line = location.range.start.line as usize;
553
+            let col = location.range.start.character as usize;
554
+
555
+            self.cursors_mut().collapse_to_primary();
556
+            self.cursor_mut().line = line.min(self.buffer().line_count().saturating_sub(1));
557
+            self.cursor_mut().col = col.min(self.buffer().line_len(self.cursor().line));
558
+            self.cursor_mut().desired_col = self.cursor().col;
559
+            self.cursor_mut().clear_selection();
560
+            self.scroll_to_cursor();
561
+        }
562
+    }
563
+
564
+    /// Get the full path to the current file
565
+    fn current_file_path(&self) -> Option<PathBuf> {
566
+        let tab = self.workspace.active_tab();
567
+        let pane = &tab.panes[tab.active_pane];
568
+        let buffer_entry = &tab.buffers[pane.buffer_idx];
569
+
570
+        buffer_entry.path.as_ref().map(|p| {
571
+            if buffer_entry.is_orphan {
572
+                p.clone()
573
+            } else {
574
+                self.workspace.root.join(p)
575
+            }
576
+        })
577
+    }
578
+
579
+    /// LSP: Go to definition
580
+    fn lsp_goto_definition(&mut self) {
581
+        if let Some(path) = self.current_file_path() {
582
+            let path_str = path.to_string_lossy().to_string();
583
+            let line = self.cursor().line as u32;
584
+            let col = self.cursor().col as u32;
585
+
586
+            match self.workspace.lsp.request_definition(&path_str, line, col) {
587
+                Ok(id) => {
588
+                    self.lsp_state.pending_definition = Some(id);
589
+                    self.message = Some("Finding definition...".to_string());
590
+                }
591
+                Err(e) => {
592
+                    self.message = Some(format!("LSP error: {}", e));
593
+                }
594
+            }
595
+        } else {
596
+            self.message = Some("No file open".to_string());
597
+        }
598
+    }
599
+
600
+    /// LSP: Find references
601
+    fn lsp_find_references(&mut self) {
602
+        if let Some(path) = self.current_file_path() {
603
+            let path_str = path.to_string_lossy().to_string();
604
+            let line = self.cursor().line as u32;
605
+            let col = self.cursor().col as u32;
606
+
607
+            match self.workspace.lsp.request_references(&path_str, line, col, true) {
608
+                Ok(id) => {
609
+                    self.lsp_state.pending_references = Some(id);
610
+                    self.message = Some("Finding references...".to_string());
611
+                }
612
+                Err(e) => {
613
+                    self.message = Some(format!("LSP error: {}", e));
614
+                }
615
+            }
616
+        } else {
617
+            self.message = Some("No file open".to_string());
618
+        }
619
+    }
620
+
621
+    /// LSP: Show hover information
622
+    fn lsp_hover(&mut self) {
623
+        if let Some(path) = self.current_file_path() {
624
+            let path_str = path.to_string_lossy().to_string();
625
+            let line = self.cursor().line as u32;
626
+            let col = self.cursor().col as u32;
627
+
628
+            match self.workspace.lsp.request_hover(&path_str, line, col) {
629
+                Ok(id) => {
630
+                    self.lsp_state.pending_hover = Some(id);
631
+                    self.message = Some("Loading hover info...".to_string());
632
+                }
633
+                Err(e) => {
634
+                    self.message = Some(format!("LSP error: {}", e));
635
+                }
636
+            }
637
+        } else {
638
+            self.message = Some("No file open".to_string());
639
+        }
640
+    }
641
+
642
+    /// LSP: Trigger completion
643
+    fn lsp_complete(&mut self) {
644
+        if let Some(path) = self.current_file_path() {
645
+            let path_str = path.to_string_lossy().to_string();
646
+            let line = self.cursor().line as u32;
647
+            let col = self.cursor().col as u32;
648
+
649
+            match self.workspace.lsp.request_completions(&path_str, line, col) {
650
+                Ok(id) => {
651
+                    self.lsp_state.pending_completion = Some(id);
652
+                    self.message = Some("Loading completions...".to_string());
653
+                }
654
+                Err(e) => {
655
+                    self.message = Some(format!("LSP error: {}", e));
656
+                }
657
+            }
658
+        } else {
659
+            self.message = Some("No file open".to_string());
660
+        }
661
+    }
662
+
663
+    /// Toggle the LSP server manager panel
664
+    fn toggle_server_manager(&mut self) {
665
+        if self.server_manager.visible {
666
+            self.server_manager.hide();
667
+        } else {
668
+            self.server_manager.show();
669
+        }
670
+    }
671
+
672
+    /// Handle key input when server manager panel is visible
673
+    fn handle_server_manager_key(&mut self, key: Key, mods: Modifiers) -> Result<()> {
674
+        let max_visible = 10; // Should match screen.rs
675
+
676
+        // Alt+M toggles the panel closed
677
+        if key == Key::Char('m') && mods.alt {
678
+            self.server_manager.hide();
679
+            return Ok(());
680
+        }
681
+
682
+        // Handle confirm mode
683
+        if self.server_manager.confirm_mode {
684
+            match key {
685
+                Key::Char('y') | Key::Char('Y') => {
686
+                    // Start install in background thread (non-blocking)
687
+                    self.server_manager.start_install();
688
+                }
689
+                Key::Char('n') | Key::Char('N') | Key::Escape => {
690
+                    self.server_manager.cancel_confirm();
691
+                }
692
+                _ => {}
693
+            }
694
+            return Ok(());
695
+        }
696
+
697
+        // Handle manual info mode
698
+        if self.server_manager.manual_info_mode {
699
+            match key {
700
+                Key::Char('c') | Key::Char('C') => {
701
+                    // Copy install instructions to clipboard
702
+                    if let Some(text) = self.server_manager.get_manual_install_text() {
703
+                        if let Some(ref mut clip) = self.clipboard {
704
+                            if clip.set_text(&text).is_ok() {
705
+                                self.server_manager.mark_copied();
706
+                            } else {
707
+                                self.server_manager.status_message = Some("Failed to copy".to_string());
708
+                            }
709
+                        } else {
710
+                            // Fall back to internal clipboard
711
+                            self.internal_clipboard = text;
712
+                            self.server_manager.mark_copied();
713
+                        }
714
+                    }
715
+                }
716
+                Key::Escape | Key::Char('q') => {
717
+                    self.server_manager.cancel_confirm();
718
+                }
719
+                _ => {}
720
+            }
721
+            return Ok(());
722
+        }
723
+
724
+        // Normal panel navigation
725
+        match key {
726
+            Key::Up | Key::Char('k') => {
727
+                self.server_manager.move_up();
728
+            }
729
+            Key::Down | Key::Char('j') => {
730
+                self.server_manager.move_down(max_visible);
731
+            }
732
+            Key::Enter => {
733
+                self.server_manager.enter_confirm_mode();
734
+            }
735
+            Key::Char('r') | Key::Char('R') => {
736
+                self.server_manager.refresh();
737
+            }
738
+            Key::Escape | Key::Char('q') => {
739
+                self.server_manager.hide();
740
+            }
741
+            _ => {}
742
+        }
743
+
744
+        Ok(())
745
+    }
746
+
747
+    /// LSP: Rename symbol - opens prompt for new name
748
+    fn lsp_rename(&mut self) {
749
+        if let Some(path) = self.current_file_path() {
750
+            let path_str = path.to_string_lossy().to_string();
751
+            let line = self.cursor().line as u32;
752
+            let col = self.cursor().col as u32;
753
+
754
+            // Get the word under cursor to show in prompt
755
+            let buffer = self.buffer();
756
+            let cursor = self.cursor();
757
+            let current_word = if let Some(line_slice) = buffer.line(cursor.line) {
758
+                let line_text: String = line_slice.chars().collect();
759
+                let mut start = cursor.col;
760
+                let mut end = cursor.col;
761
+
762
+                // Find word boundaries
763
+                while start > 0 {
764
+                    let ch = line_text.chars().nth(start - 1).unwrap_or(' ');
765
+                    if ch.is_alphanumeric() || ch == '_' {
766
+                        start -= 1;
767
+                    } else {
768
+                        break;
769
+                    }
770
+                }
771
+                while end < line_text.len() {
772
+                    let ch = line_text.chars().nth(end).unwrap_or(' ');
773
+                    if ch.is_alphanumeric() || ch == '_' {
774
+                        end += 1;
775
+                    } else {
776
+                        break;
777
+                    }
778
+                }
779
+                line_text[start..end].to_string()
780
+            } else {
781
+                String::new()
782
+            };
783
+
784
+            self.prompt = PromptState::TextInput {
785
+                label: "Rename to: ".to_string(),
786
+                buffer: current_word.clone(),
787
+                action: TextInputAction::LspRename { path: path_str, line, col },
788
+            };
789
+            self.message = Some(format!("Rename '{}' to: {}", current_word, current_word));
790
+        } else {
791
+            self.message = Some("No file open".to_string());
792
+        }
793
+    }
794
+
795
+    /// Accept the currently selected completion and insert it
796
+    fn accept_completion(&mut self) {
797
+        if self.lsp_state.completions.is_empty() {
798
+            return;
799
+        }
800
+
801
+        let completion = self.lsp_state.completions[self.lsp_state.completion_index].clone();
802
+
803
+        // Determine the text to insert
804
+        let insert_text = if let Some(ref text_edit) = completion.text_edit {
805
+            // Use text edit if provided (includes range to replace)
806
+            // For now, just use the new text - proper range replacement would be more complex
807
+            text_edit.new_text.clone()
808
+        } else if let Some(ref insert) = completion.insert_text {
809
+            insert.clone()
810
+        } else {
811
+            completion.label.clone()
812
+        };
813
+
814
+        // Find the start of the word being completed (walk back from cursor)
815
+        let buffer = self.buffer();
816
+        let cursor = self.cursor();
817
+        let line_idx = cursor.line;
818
+        let cursor_col = cursor.col;
819
+        let mut word_start = cursor_col;
820
+
821
+        // Walk back to find word start (alphanumeric or underscore)
822
+        if let Some(line_slice) = buffer.line(line_idx) {
823
+            let line_text: String = line_slice.chars().collect();
824
+            while word_start > 0 {
825
+                let prev_char = line_text.chars().nth(word_start - 1).unwrap_or(' ');
826
+                if prev_char.is_alphanumeric() || prev_char == '_' {
827
+                    word_start -= 1;
828
+                } else {
829
+                    break;
830
+                }
831
+            }
832
+        }
833
+
834
+        // Delete the partial word and insert completion
835
+        if word_start < cursor_col {
836
+            // Select from word start to cursor
837
+            let cursor = self.cursor_mut();
838
+            cursor.anchor_line = cursor.line;
839
+            cursor.anchor_col = word_start;
840
+            cursor.selecting = true;
841
+        }
842
+
843
+        // Insert the completion text (this will replace selection if any)
844
+        for ch in insert_text.chars() {
845
+            self.insert_char(ch);
846
+        }
847
+
848
+        // Clear completion state
849
+        self.lsp_state.completion_visible = false;
850
+        self.lsp_state.completions.clear();
851
+        self.lsp_state.completion_index = 0;
852
+    }
853
+
271854
     /// Process a key event, handling ESC as potential Alt prefix
272855
     fn process_key(&mut self, key_event: KeyEvent) -> Result<()> {
273856
         use crossterm::event::KeyCode;
@@ -404,7 +987,11 @@ impl Editor {
404987
             }
405988
             Mouse::ScrollDown { .. } => {
406989
                 // Scroll down 3 lines
407
-                let max_viewport = self.buffer().line_count().saturating_sub(1);
990
+                // Calculate visible rows (accounting for tab bar, gap, and status bar)
991
+                let top_offset = if self.workspace.tabs.len() > 1 { 1 } else { 0 };
992
+                let visible_rows = (self.screen.rows as usize).saturating_sub(2 + top_offset);
993
+                // Max viewport is when the last line is at the bottom of visible area
994
+                let max_viewport = self.buffer().line_count().saturating_sub(visible_rows).max(0);
408995
                 let new_line = (self.viewport_line() + 3).min(max_viewport);
409996
                 self.set_viewport_line(new_line);
410997
             }
@@ -492,29 +1079,93 @@ impl Editor {
4921079
                 top_offset,
4931080
             )
4941081
         } else {
495
-            // Single pane - use simpler render path
1082
+            // Single pane - use simpler render path with syntax highlighting
4961083
             let pane = &tab.panes[tab.active_pane];
4971084
             let buffer_entry = &tab.buffers[pane.buffer_idx];
4981085
             let buffer = &buffer_entry.buffer;
4991086
             let cursors = &pane.cursors;
5001087
             let viewport_line = pane.viewport_line;
1088
+            let viewport_col = pane.viewport_col;
5011089
             let is_modified = buffer_entry.is_modified();
1090
+            let highlighter = &buffer_entry.highlighter;
5021091
 
5031092
             // Find matching bracket for primary cursor
5041093
             let cursor = cursors.primary();
5051094
             let bracket_match = buffer.find_matching_bracket(cursor.line, cursor.col);
5061095
 
507
-            self.screen.render_with_offset(
1096
+            self.screen.render_with_syntax(
5081097
                 buffer,
5091098
                 cursors,
5101099
                 viewport_line,
1100
+                viewport_col,
5111101
                 filename,
5121102
                 self.message.as_deref(),
5131103
                 bracket_match,
5141104
                 fuss_width,
5151105
                 top_offset,
5161106
                 is_modified,
517
-            )
1107
+                highlighter,
1108
+            )?;
1109
+
1110
+            // Render diagnostics markers in gutter
1111
+            if !self.lsp_state.diagnostics.is_empty() {
1112
+                self.screen.render_diagnostics_gutter(
1113
+                    &self.lsp_state.diagnostics,
1114
+                    viewport_line,
1115
+                    fuss_width,
1116
+                    top_offset,
1117
+                )?;
1118
+            }
1119
+
1120
+            // Render completion popup if visible
1121
+            if self.lsp_state.completion_visible && !self.lsp_state.completions.is_empty() {
1122
+                let cursor = cursors.primary();
1123
+                // Calculate cursor screen position
1124
+                let cursor_row = (cursor.line.saturating_sub(viewport_line)) as u16 + top_offset;
1125
+                let line_num_width = self.screen.line_number_width(buffer.line_count()) as u16;
1126
+                let cursor_col = cursor.col as u16 + line_num_width + 1;
1127
+
1128
+                self.screen.render_completion_popup(
1129
+                    &self.lsp_state.completions,
1130
+                    self.lsp_state.completion_index,
1131
+                    cursor_row,
1132
+                    cursor_col,
1133
+                    fuss_width,
1134
+                )?;
1135
+            }
1136
+
1137
+            // Render hover popup if visible
1138
+            if self.lsp_state.hover_visible {
1139
+                if let Some(ref hover) = self.lsp_state.hover {
1140
+                    let cursor = cursors.primary();
1141
+                    let cursor_row = (cursor.line.saturating_sub(viewport_line)) as u16 + top_offset;
1142
+                    let line_num_width = self.screen.line_number_width(buffer.line_count()) as u16;
1143
+                    let cursor_col = cursor.col as u16 + line_num_width + 1;
1144
+
1145
+                    self.screen.render_hover_popup(
1146
+                        hover,
1147
+                        cursor_row,
1148
+                        cursor_col,
1149
+                        fuss_width,
1150
+                    )?;
1151
+                }
1152
+            }
1153
+
1154
+            // Render server manager panel if visible (on top of everything)
1155
+            if self.server_manager.visible {
1156
+                self.screen.render_server_manager_panel(&self.server_manager)?;
1157
+            }
1158
+
1159
+            // After all overlays are rendered, reposition cursor to the correct location
1160
+            // (overlays may have moved the terminal cursor position)
1161
+            let cursor = cursors.primary();
1162
+            let cursor_row = (cursor.line.saturating_sub(viewport_line)) as u16 + top_offset;
1163
+            let line_num_width = self.screen.line_number_width(buffer.line_count()) as u16;
1164
+            // Account for horizontal scroll offset
1165
+            let cursor_screen_col = fuss_width + line_num_width + 1 + (cursor.col.saturating_sub(viewport_col)) as u16;
1166
+            self.screen.show_cursor_at(cursor_screen_col, cursor_row)?;
1167
+
1168
+            Ok(())
5181169
         }
5191170
     }
5201171
 
@@ -524,6 +1175,11 @@ impl Editor {
5241175
             return self.handle_prompt_key(key);
5251176
         }
5261177
 
1178
+        // Handle server manager panel when visible
1179
+        if self.server_manager.visible {
1180
+            return self.handle_server_manager_key(key, mods);
1181
+        }
1182
+
5271183
         // Clear message on any key
5281184
         self.message = None;
5291185
 
@@ -538,6 +1194,58 @@ impl Editor {
5381194
             return self.handle_fuss_key(key, mods);
5391195
         }
5401196
 
1197
+        // Handle completion popup navigation when visible
1198
+        if self.lsp_state.completion_visible {
1199
+            match (&key, &mods) {
1200
+                // Navigate up in completion list
1201
+                (Key::Up, _) => {
1202
+                    if self.lsp_state.completion_index > 0 {
1203
+                        self.lsp_state.completion_index -= 1;
1204
+                    } else {
1205
+                        // Wrap to bottom
1206
+                        self.lsp_state.completion_index = self.lsp_state.completions.len().saturating_sub(1);
1207
+                    }
1208
+                    return Ok(());
1209
+                }
1210
+                // Navigate down in completion list
1211
+                (Key::Down, _) => {
1212
+                    if self.lsp_state.completion_index < self.lsp_state.completions.len().saturating_sub(1) {
1213
+                        self.lsp_state.completion_index += 1;
1214
+                    } else {
1215
+                        // Wrap to top
1216
+                        self.lsp_state.completion_index = 0;
1217
+                    }
1218
+                    return Ok(());
1219
+                }
1220
+                // Select completion with Enter or Tab
1221
+                (Key::Enter, _) | (Key::Tab, _) => {
1222
+                    self.accept_completion();
1223
+                    return Ok(());
1224
+                }
1225
+                // Dismiss completion popup with Escape
1226
+                (Key::Escape, _) => {
1227
+                    self.lsp_state.completion_visible = false;
1228
+                    self.lsp_state.completions.clear();
1229
+                    return Ok(());
1230
+                }
1231
+                // Any other key dismisses popup and continues normally
1232
+                _ => {
1233
+                    self.lsp_state.completion_visible = false;
1234
+                    self.lsp_state.completions.clear();
1235
+                }
1236
+            }
1237
+        }
1238
+
1239
+        // Dismiss hover popup on any key press
1240
+        if self.lsp_state.hover_visible {
1241
+            self.lsp_state.hover_visible = false;
1242
+            self.lsp_state.hover = None;
1243
+            // Let Escape just dismiss the popup without doing anything else
1244
+            if matches!(key, Key::Escape) {
1245
+                return Ok(());
1246
+            }
1247
+        }
1248
+
5411249
         // Break undo group on any non-character key (movement, commands, etc.)
5421250
         // This ensures each "typing session" is its own undo unit
5431251
         let is_typing = matches!(
@@ -717,6 +1425,20 @@ impl Editor {
7171425
             // New tab: Alt+T
7181426
             (Key::Char('t'), Modifiers { alt: true, .. }) => self.workspace.new_tab(),
7191427
 
1428
+            // === LSP operations ===
1429
+            // Go to definition: F12
1430
+            (Key::F(12), Modifiers { shift: false, .. }) => self.lsp_goto_definition(),
1431
+            // Find references: Shift+F12
1432
+            (Key::F(12), Modifiers { shift: true, .. }) => self.lsp_find_references(),
1433
+            // Hover info: F1
1434
+            (Key::F(1), _) => self.lsp_hover(),
1435
+            // Code completion: Ctrl+Space
1436
+            (Key::Char(' '), Modifiers { ctrl: true, .. }) => self.lsp_complete(),
1437
+            // Rename: F2
1438
+            (Key::F(2), _) => self.lsp_rename(),
1439
+            // Server manager: Alt+M
1440
+            (Key::Char('m'), Modifiers { alt: true, .. }) => self.toggle_server_manager(),
1441
+
7201442
             _ => {}
7211443
         }
7221444
 
@@ -1024,8 +1746,13 @@ impl Editor {
10241746
     }
10251747
 
10261748
     fn select_word(&mut self) {
1027
-        // If no selection, select word at cursor
1028
-        // If already have selection, this could expand to next occurrence (future enhancement)
1749
+        // If primary cursor has a selection, find next occurrence and add cursor there
1750
+        if self.cursor().has_selection() {
1751
+            self.select_next_occurrence();
1752
+            return;
1753
+        }
1754
+
1755
+        // No selection - select word at cursor
10291756
         if let Some(line_str) = self.buffer().line_str(self.cursor().line) {
10301757
             let chars: Vec<char> = line_str.chars().collect();
10311758
             let col = self.cursor().col.min(chars.len());
@@ -1063,6 +1790,120 @@ impl Editor {
10631790
         }
10641791
     }
10651792
 
1793
+    /// Find the next occurrence of the selected text and add a cursor there
1794
+    fn select_next_occurrence(&mut self) {
1795
+        // Get the selected text from primary cursor
1796
+        let selected_text = {
1797
+            let cursor = self.cursor();
1798
+            if !cursor.has_selection() {
1799
+                return;
1800
+            }
1801
+            let (start, end) = cursor.selection().ordered();
1802
+            let buffer = self.buffer();
1803
+
1804
+            // Extract selected text
1805
+            let mut text = String::new();
1806
+            for line_idx in start.line..=end.line {
1807
+                if let Some(line) = buffer.line_str(line_idx) {
1808
+                    let line_start = if line_idx == start.line { start.col } else { 0 };
1809
+                    let line_end = if line_idx == end.line { end.col } else { line.len() };
1810
+                    if line_start < line_end && line_end <= line.len() {
1811
+                        text.push_str(&line[line_start..line_end]);
1812
+                    }
1813
+                    if line_idx < end.line {
1814
+                        text.push('\n');
1815
+                    }
1816
+                }
1817
+            }
1818
+            text
1819
+        };
1820
+
1821
+        if selected_text.is_empty() {
1822
+            return;
1823
+        }
1824
+
1825
+        // Find the position to start searching from (after the last cursor with this selection)
1826
+        let search_start = {
1827
+            let cursors = self.cursors();
1828
+            let mut max_pos = (0usize, 0usize);
1829
+            for cursor in cursors.all() {
1830
+                if cursor.has_selection() {
1831
+                    let (_, end) = cursor.selection().ordered();
1832
+                    if (end.line, end.col) > max_pos {
1833
+                        max_pos = (end.line, end.col);
1834
+                    }
1835
+                }
1836
+            }
1837
+            max_pos
1838
+        };
1839
+
1840
+        // Search for next occurrence
1841
+        let buffer = self.buffer();
1842
+        let line_count = buffer.line_count();
1843
+        let search_text = &selected_text;
1844
+
1845
+        // Start searching from the line after the last selection end
1846
+        for line_idx in search_start.0..line_count {
1847
+            if let Some(line) = buffer.line_str(line_idx) {
1848
+                let start_col = if line_idx == search_start.0 { search_start.1 } else { 0 };
1849
+
1850
+                // Search for the text in this line (only works for single-line selections for now)
1851
+                if !search_text.contains('\n') {
1852
+                    if let Some(found_col) = line[start_col..].find(search_text) {
1853
+                        let match_start = start_col + found_col;
1854
+                        let match_end = match_start + search_text.len();
1855
+
1856
+                        // Add a new cursor with selection at this location
1857
+                        self.cursors_mut().add_with_selection(
1858
+                            line_idx,
1859
+                            match_end,
1860
+                            line_idx,
1861
+                            match_start,
1862
+                        );
1863
+                        return;
1864
+                    }
1865
+                }
1866
+            }
1867
+        }
1868
+
1869
+        // Wrap around to beginning if not found
1870
+        for line_idx in 0..=search_start.0 {
1871
+            if let Some(line) = buffer.line_str(line_idx) {
1872
+                let end_col = if line_idx == search_start.0 {
1873
+                    // Don't search past where we started
1874
+                    search_start.1.saturating_sub(search_text.len())
1875
+                } else {
1876
+                    line.len()
1877
+                };
1878
+
1879
+                if !search_text.contains('\n') {
1880
+                    if let Some(found_col) = line[..end_col].find(search_text) {
1881
+                        let match_start = found_col;
1882
+                        let match_end = match_start + search_text.len();
1883
+
1884
+                        // Check if this position already has a cursor
1885
+                        let already_has_cursor = self.cursors().all().iter().any(|c| {
1886
+                            c.line == line_idx && c.col == match_end
1887
+                        });
1888
+
1889
+                        if !already_has_cursor {
1890
+                            self.cursors_mut().add_with_selection(
1891
+                                line_idx,
1892
+                                match_end,
1893
+                                line_idx,
1894
+                                match_start,
1895
+                            );
1896
+                            return;
1897
+                        }
1898
+                    }
1899
+                }
1900
+            }
1901
+        }
1902
+
1903
+        // No more occurrences found
1904
+        self.message = Some("No more occurrences".to_string());
1905
+    }
1906
+
10661907
     // === Bracket/Quote Operations ===
10671908
 
10681909
     fn jump_to_matching_bracket(&mut self) {
@@ -2146,17 +2987,48 @@ impl Editor {
21462987
     // === Viewport ===
21472988
 
21482989
     fn scroll_to_cursor(&mut self) {
2149
-        let visible_rows = self.screen.rows.saturating_sub(1) as usize;
2990
+        // Calculate top offset (tab bar takes 1 row if multiple tabs)
2991
+        let top_offset = if self.workspace.tabs.len() > 1 { 1 } else { 0 };
2992
+        // Vertical scrolling (2 rows reserved: gap + status bar, plus top_offset for tab bar)
2993
+        let visible_rows = (self.screen.rows as usize).saturating_sub(2 + top_offset);
21502994
         let cursor_line = self.cursor().line;
2151
-        let viewport = self.viewport_line();
2995
+        let viewport_line = self.viewport_line();
21522996
 
2153
-        if cursor_line < viewport {
2997
+        if cursor_line < viewport_line {
21542998
             self.set_viewport_line(cursor_line);
21552999
         }
21563000
 
2157
-        if cursor_line >= viewport + visible_rows {
3001
+        if cursor_line >= viewport_line + visible_rows {
21583002
             self.set_viewport_line(cursor_line - visible_rows + 1);
21593003
         }
3004
+
3005
+        // Horizontal scrolling
3006
+        let line_num_width = self.screen.line_number_width(self.buffer().line_count());
3007
+        let fuss_width = if self.workspace.fuss.active {
3008
+            self.workspace.fuss.width(self.screen.cols)
3009
+        } else {
3010
+            0
3011
+        };
3012
+        // Available text columns = screen width - fuss sidebar - line numbers - 1 (separator)
3013
+        let visible_cols = (self.screen.cols as usize)
3014
+            .saturating_sub(fuss_width as usize)
3015
+            .saturating_sub(line_num_width + 1);
3016
+
3017
+        let cursor_col = self.cursor().col;
3018
+        let viewport_col = self.viewport_col();
3019
+
3020
+        // Keep some margin (3 chars) so cursor isn't right at the edge
3021
+        let margin = 3;
3022
+
3023
+        if cursor_col < viewport_col {
3024
+            // Cursor is left of viewport - scroll left
3025
+            self.set_viewport_col(cursor_col.saturating_sub(margin));
3026
+        }
3027
+
3028
+        if cursor_col >= viewport_col + visible_cols.saturating_sub(margin) {
3029
+            // Cursor is right of viewport - scroll right
3030
+            self.set_viewport_col(cursor_col.saturating_sub(visible_cols.saturating_sub(margin + 1)));
3031
+        }
21603032
     }
21613033
 
21623034
     // === File operations ===
@@ -2546,6 +3418,22 @@ impl Editor {
25463418
                 let (_, msg) = self.workspace.fuss.git_tag(buffer);
25473419
                 self.message = Some(msg);
25483420
             }
3421
+            TextInputAction::LspRename { path, line, col } => {
3422
+                if buffer.is_empty() {
3423
+                    self.message = Some("Rename cancelled: empty name".to_string());
3424
+                    return;
3425
+                }
3426
+                match self.workspace.lsp.request_rename(&path, line, col, buffer) {
3427
+                    Ok(_id) => {
3428
+                        self.message = Some(format!("Renaming to '{}'...", buffer));
3429
+                        // Note: The actual rename edits will be applied when we receive
3430
+                        // the response and implement WorkspaceEdit handling
3431
+                    }
3432
+                    Err(e) => {
3433
+                        self.message = Some(format!("Rename failed: {}", e));
3434
+                    }
3435
+                }
3436
+            }
25493437
         }
25503438
     }
25513439
 
src/main.rsmodified
@@ -2,7 +2,9 @@ mod buffer;
22
 mod editor;
33
 mod fuss;
44
 mod input;
5
+mod lsp;
56
 mod render;
7
+mod syntax;
68
 mod util;
79
 mod workspace;
810
 
src/render/screen.rsmodified
1220 lines changed — click to load
@@ -6,14 +6,17 @@ use crossterm::{
66
         KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
77
     },
88
     execute,
9
-    style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor},
9
+    style::{Attribute, Color, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor},
1010
     terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
1111
 };
1212
 use std::io::{stdout, Stdout, Write};
13
+use unicode_width::UnicodeWidthStr;
1314
 
1415
 use crate::buffer::Buffer;
1516
 use crate::editor::{Cursors, Position};
1617
 use crate::fuss::VisibleItem;
18
+use crate::lsp::{CompletionItem, Diagnostic, DiagnosticSeverity, HoverInfo, ServerManagerPanel};
19
+use crate::syntax::{Highlighter, HighlightState, Token};
1720
 
1821
 // Editor color scheme (256-color palette)
1922
 const BG_COLOR: Color = Color::AnsiValue(234);           // Off-black editor background
@@ -123,6 +126,13 @@ impl Screen {
123126
         Ok(())
124127
     }
125128
 
129
+    /// Position and show the hardware cursor at the given screen coordinates
130
+    pub fn show_cursor_at(&mut self, col: u16, row: u16) -> Result<()> {
131
+        execute!(self.stdout, MoveTo(col, row), Show)?;
132
+        self.stdout.flush()?;
133
+        Ok(())
134
+    }
135
+
126136
     #[allow(dead_code)]
127137
     pub fn clear(&mut self) -> Result<()> {
128138
         execute!(self.stdout, Clear(ClearType::All))?;
@@ -528,8 +538,8 @@ impl Screen {
528538
             .map(|(i, c)| (c.line, c.col, i == primary_idx)) // (line, col, is_primary)
529539
             .collect();
530540
 
531
-        // Reserve 1 row for status bar
532
-        let text_rows = self.rows.saturating_sub(1) as usize;
541
+        // Reserve 2 rows: 1 for gap above status bar, 1 for status bar itself
542
+        let text_rows = self.rows.saturating_sub(2) as usize;
533543
 
534544
         // Draw text area
535545
         for row in 0..text_rows {
@@ -597,6 +607,16 @@ impl Screen {
597607
             }
598608
         }
599609
 
610
+        // Render the gap row (empty line between text and status bar)
611
+        let gap_row = text_rows as u16;
612
+        execute!(
613
+            self.stdout,
614
+            MoveTo(0, gap_row),
615
+            SetBackgroundColor(BG_COLOR),
616
+            Clear(ClearType::UntilNewLine),
617
+            ResetColor
618
+        )?;
619
+
600620
         // Status bar
601621
         self.render_status_bar(buffer, cursors, filename, message)?;
602622
 
@@ -622,6 +642,30 @@ impl Screen {
622642
         is_current_line: bool,
623643
         bracket_col: Option<usize>,
624644
         secondary_cursors: &[usize],
645
+    ) -> Result<()> {
646
+        // Call the syntax-aware version with no tokens
647
+        self.render_line_with_syntax(
648
+            line,
649
+            line_idx,
650
+            max_cols,
651
+            selections,
652
+            is_current_line,
653
+            bracket_col,
654
+            secondary_cursors,
655
+            &[],
656
+        )
657
+    }
658
+
659
+    fn render_line_with_syntax(
660
+        &mut self,
661
+        line: &str,
662
+        line_idx: usize,
663
+        max_cols: usize,
664
+        selections: &[(Position, Position)],
665
+        is_current_line: bool,
666
+        bracket_col: Option<usize>,
667
+        secondary_cursors: &[usize],
668
+        tokens: &[Token],
625669
     ) -> Result<()> {
626670
         let chars: Vec<char> = line.chars().take(max_cols).collect();
627671
         let line_bg = if is_current_line { CURRENT_LINE_BG } else { BG_COLOR };
@@ -639,45 +683,63 @@ impl Screen {
639683
             }
640684
         }
641685
 
686
+        // Helper to find token at character position
687
+        let get_token_at = |col: usize| -> Option<&Token> {
688
+            tokens.iter().find(|t| col >= t.start && col < t.end)
689
+        };
690
+
642691
         // Render character by character for precise highlighting
643692
         for (col, ch) in chars.iter().enumerate() {
644693
             let in_selection = sel_ranges.iter().any(|(s, e)| col >= *s && col < *e);
645694
             let is_bracket_match = bracket_col == Some(col);
646695
             let is_secondary_cursor = secondary_cursors.contains(&col);
647696
 
648
-            // Determine background color
697
+            // Determine background color (priority: selection > cursor > bracket > syntax/line)
649698
             let bg = if in_selection {
650699
                 Color::Blue
651700
             } else if is_secondary_cursor {
652
-                Color::Magenta  // Use magenta for better visibility
701
+                Color::Magenta
653702
             } else if is_bracket_match {
654703
                 BRACKET_MATCH_BG
655704
             } else {
656705
                 line_bg
657706
             };
658707
 
659
-            // Determine foreground color
660
-            let fg = if in_selection {
661
-                Color::White
708
+            // Determine foreground color and boldness
709
+            let (fg, bold) = if in_selection {
710
+                (Color::White, false)
662711
             } else if is_secondary_cursor {
663
-                Color::White  // White text on magenta bg
712
+                (Color::White, false)
713
+            } else if let Some(token) = get_token_at(col) {
714
+                (token.token_type.color(), token.token_type.bold())
664715
             } else {
665
-                default_fg
716
+                (default_fg, false)
666717
             };
667718
 
668
-            execute!(
669
-                self.stdout,
670
-                SetBackgroundColor(bg),
671
-                SetForegroundColor(fg),
672
-                Print(ch)
673
-            )?;
719
+            // Apply styling
720
+            if bold {
721
+                execute!(
722
+                    self.stdout,
723
+                    SetBackgroundColor(bg),
724
+                    SetForegroundColor(fg),
725
+                    SetAttribute(Attribute::Bold),
726
+                    Print(ch),
727
+                    SetAttribute(Attribute::NoBold),
728
+                )?;
729
+            } else {
730
+                execute!(
731
+                    self.stdout,
732
+                    SetBackgroundColor(bg),
733
+                    SetForegroundColor(fg),
734
+                    Print(ch)
735
+                )?;
736
+            }
674737
         }
675738
 
676739
         // Reset to line background for rest of line
677740
         execute!(self.stdout, SetBackgroundColor(line_bg), SetForegroundColor(default_fg))?;
678741
 
679742
         // Handle secondary cursors at end of line (past text content)
680
-        // Find the rightmost secondary cursor past text
681743
         let max_cursor_past_text = secondary_cursors.iter()
682744
             .filter(|&&c| c >= chars.len())
683745
             .max()
@@ -685,7 +747,6 @@ impl Screen {
685747
 
686748
         if let Some(max_cursor) = max_cursor_past_text {
687749
             if max_cursor < max_cols {
688
-                // Fill spaces up to and including the cursor positions
689750
                 for col in chars.len()..=max_cursor {
690751
                     if secondary_cursors.contains(&col) {
691752
                         execute!(
@@ -702,7 +763,6 @@ impl Screen {
702763
                         )?;
703764
                     }
704765
                 }
705
-                // Reset for the rest of the line
706766
                 execute!(self.stdout, SetBackgroundColor(line_bg), SetForegroundColor(default_fg))?;
707767
             }
708768
         }
@@ -762,7 +822,7 @@ impl Screen {
762822
         Ok(())
763823
     }
764824
 
765
-    fn line_number_width(&self, line_count: usize) -> usize {
825
+    pub fn line_number_width(&self, line_count: usize) -> usize {
766826
         let digits = if line_count == 0 {
767827
             1
768828
         } else {
@@ -992,6 +1052,7 @@ impl Screen {
9921052
     }
9931053
 
9941054
     /// Render the editor view with horizontal and vertical offsets (for fuss mode and tab bar)
1055
+    #[allow(dead_code)]
9951056
     pub fn render_with_offset(
9961057
         &mut self,
9971058
         buffer: &Buffer,
@@ -1028,8 +1089,8 @@ impl Screen {
10281089
             .map(|(i, c)| (c.line, c.col, i == primary_idx))
10291090
             .collect();
10301091
 
1031
-        // Reserve 1 row for status bar, accounting for top offset
1032
-        let text_rows = self.rows.saturating_sub(1 + top_offset) as usize;
1092
+        // Reserve 2 rows: 1 for gap above status bar, 1 for status bar itself
1093
+        let text_rows = self.rows.saturating_sub(2 + top_offset) as usize;
10331094
 
10341095
         // Draw text area
10351096
         for row in 0..text_rows {
@@ -1091,6 +1152,16 @@ impl Screen {
10911152
             }
10921153
         }
10931154
 
1155
+        // Render the gap row (empty line between text and status bar)
1156
+        let gap_row = text_rows as u16 + top_offset;
1157
+        execute!(
1158
+            self.stdout,
1159
+            MoveTo(left_offset, gap_row),
1160
+            SetBackgroundColor(BG_COLOR),
1161
+            Clear(ClearType::UntilNewLine),
1162
+            ResetColor
1163
+        )?;
1164
+
10941165
         // Status bar
10951166
         self.render_status_bar_with_offset(cursors, filename, message, left_offset, is_modified)?;
10961167
 
@@ -1107,6 +1178,175 @@ impl Screen {
11071178
         Ok(())
11081179
     }
11091180
 
1181
+    /// Render the editor view with syntax highlighting
1182
+    pub fn render_with_syntax(
1183
+        &mut self,
1184
+        buffer: &Buffer,
1185
+        cursors: &Cursors,
1186
+        viewport_line: usize,
1187
+        viewport_col: usize,
1188
+        filename: Option<&str>,
1189
+        message: Option<&str>,
1190
+        bracket_match: Option<(usize, usize)>,
1191
+        left_offset: u16,
1192
+        top_offset: u16,
1193
+        is_modified: bool,
1194
+        highlighter: &Highlighter,
1195
+    ) -> Result<()> {
1196
+        execute!(self.stdout, Hide)?;
1197
+
1198
+        let available_cols = self.cols.saturating_sub(left_offset) as usize;
1199
+        let line_num_width = self.line_number_width(buffer.line_count());
1200
+        let text_cols = available_cols.saturating_sub(line_num_width + 1);
1201
+
1202
+        let primary = cursors.primary();
1203
+
1204
+        // Adjust selections for horizontal scroll
1205
+        let selections: Vec<(Position, Position)> = cursors.all()
1206
+            .iter()
1207
+            .filter_map(|c| c.selection_bounds())
1208
+            .map(|(start, end)| {
1209
+                (
1210
+                    Position { line: start.line, col: start.col.saturating_sub(viewport_col) },
1211
+                    Position { line: end.line, col: end.col.saturating_sub(viewport_col) },
1212
+                )
1213
+            })
1214
+            .collect();
1215
+
1216
+        let primary_idx = cursors.primary_index();
1217
+        // Adjust cursor positions for horizontal scroll
1218
+        let cursor_positions: Vec<(usize, usize, bool)> = cursors.all()
1219
+            .iter()
1220
+            .enumerate()
1221
+            .map(|(i, c)| (c.line, c.col.saturating_sub(viewport_col), i == primary_idx))
1222
+            .collect();
1223
+
1224
+        // Reserve 2 rows: 1 for gap above status bar, 1 for status bar itself
1225
+        let text_rows = self.rows.saturating_sub(2 + top_offset) as usize;
1226
+
1227
+        // Track multiline state across lines
1228
+        let mut highlight_state = HighlightState::default();
1229
+
1230
+        // Pre-tokenize lines from start of buffer to viewport for correct multiline state
1231
+        // (In a production system, we'd cache this state per line)
1232
+        for line_idx in 0..viewport_line {
1233
+            if let Some(line) = buffer.line_str(line_idx) {
1234
+                let _ = highlighter.tokenize_line(&line, &mut highlight_state);
1235
+            }
1236
+        }
1237
+
1238
+        // Draw text area with syntax highlighting
1239
+        for row in 0..text_rows {
1240
+            let line_idx = viewport_line + row;
1241
+            let is_current_line = line_idx == primary.line;
1242
+            execute!(self.stdout, MoveTo(left_offset, (row as u16) + top_offset))?;
1243
+
1244
+            if line_idx < buffer.line_count() {
1245
+                let line_num_fg = if is_current_line {
1246
+                    CURRENT_LINE_NUM_COLOR
1247
+                } else {
1248
+                    LINE_NUM_COLOR
1249
+                };
1250
+                let line_bg = if is_current_line { CURRENT_LINE_BG } else { BG_COLOR };
1251
+
1252
+                execute!(
1253
+                    self.stdout,
1254
+                    SetBackgroundColor(line_bg),
1255
+                    SetForegroundColor(line_num_fg),
1256
+                    Print(format!("{:>width$} ", line_idx + 1, width = line_num_width)),
1257
+                )?;
1258
+
1259
+                if let Some(line) = buffer.line_str(line_idx) {
1260
+                    // Tokenize this line
1261
+                    let tokens = highlighter.tokenize_line(&line, &mut highlight_state);
1262
+
1263
+                    // Apply horizontal scroll to bracket match column
1264
+                    // Only show if the bracket is in the visible area
1265
+                    let bracket_col = bracket_match
1266
+                        .filter(|(bl, bc)| *bl == line_idx && *bc >= viewport_col)
1267
+                        .map(|(_, bc)| bc - viewport_col);
1268
+
1269
+                    let secondary_cursors: Vec<usize> = cursor_positions.iter()
1270
+                        .filter(|(l, _, is_primary)| *l == line_idx && !*is_primary)
1271
+                        .map(|(_, c, _)| *c)
1272
+                        .collect();
1273
+
1274
+                    // Skip characters before viewport_col
1275
+                    let display_line: String = line.chars().skip(viewport_col).collect();
1276
+
1277
+                    // Adjust tokens for horizontal scroll
1278
+                    let adjusted_tokens: Vec<Token> = tokens.iter()
1279
+                        .filter_map(|t| {
1280
+                            let new_start = t.start.saturating_sub(viewport_col);
1281
+                            let new_end = t.end.saturating_sub(viewport_col);
1282
+                            if t.end <= viewport_col {
1283
+                                None // Token is entirely before viewport
1284
+                            } else {
1285
+                                Some(Token {
1286
+                                    start: new_start,
1287
+                                    end: new_end,
1288
+                                    token_type: t.token_type,
1289
+                                })
1290
+                            }
1291
+                        })
1292
+                        .collect();
1293
+
1294
+                    self.render_line_with_syntax(
1295
+                        &display_line,
1296
+                        line_idx,
1297
+                        text_cols,
1298
+                        &selections,
1299
+                        is_current_line,
1300
+                        bracket_col,
1301
+                        &secondary_cursors,
1302
+                        &adjusted_tokens,
1303
+                    )?;
1304
+                }
1305
+
1306
+                execute!(
1307
+                    self.stdout,
1308
+                    SetBackgroundColor(line_bg),
1309
+                    Clear(ClearType::UntilNewLine),
1310
+                    ResetColor
1311
+                )?;
1312
+            } else {
1313
+                execute!(
1314
+                    self.stdout,
1315
+                    SetBackgroundColor(BG_COLOR),
1316
+                    SetForegroundColor(Color::DarkBlue),
1317
+                    Print(format!("{:>width$} ", "~", width = line_num_width)),
1318
+                    Clear(ClearType::UntilNewLine),
1319
+                    ResetColor
1320
+                )?;
1321
+            }
1322
+        }
1323
+
1324
+        // Render the gap row (empty line between text and status bar)
1325
+        let gap_row = text_rows as u16 + top_offset;
1326
+        execute!(
1327
+            self.stdout,
1328
+            MoveTo(left_offset, gap_row),
1329
+            SetBackgroundColor(BG_COLOR),
1330
+            Clear(ClearType::UntilNewLine),
1331
+            ResetColor
1332
+        )?;
1333
+
1334
+        // Status bar
1335
+        self.render_status_bar_with_offset(cursors, filename, message, left_offset, is_modified)?;
1336
+
1337
+        // Position hardware cursor (adjusted for horizontal scroll)
1338
+        let cursor_row = (primary.line.saturating_sub(viewport_line) as u16) + top_offset;
1339
+        let cursor_col = left_offset as usize + line_num_width + 1 + primary.col.saturating_sub(viewport_col);
1340
+        execute!(
1341
+            self.stdout,
1342
+            MoveTo(cursor_col as u16, cursor_row),
1343
+            Show
1344
+        )?;
1345
+
1346
+        self.stdout.flush()?;
1347
+        Ok(())
1348
+    }
1349
+
11101350
     fn render_status_bar_with_offset(
11111351
         &mut self,
11121352
         cursors: &Cursors,
@@ -1377,4 +1617,826 @@ impl Screen {
13771617
         self.stdout.flush()?;
13781618
         Ok(())
13791619
     }
1620
+
1621
+    /// Render a completion popup at the given screen position
1622
+    pub fn render_completion_popup(
1623
+        &mut self,
1624
+        completions: &[CompletionItem],
1625
+        selected_index: usize,
1626
+        cursor_row: u16,
1627
+        cursor_col: u16,
1628
+        left_offset: u16,
1629
+    ) -> Result<()> {
1630
+        if completions.is_empty() {
1631
+            return Ok(());
1632
+        }
1633
+
1634
+        // Popup settings
1635
+        let max_items = 10.min(completions.len());
1636
+        let popup_width = 40;
1637
+        let popup_bg = Color::AnsiValue(237);
1638
+        let selected_bg = Color::AnsiValue(24);
1639
+        let item_fg = Color::AnsiValue(252);
1640
+        let detail_fg = Color::AnsiValue(244);
1641
+
1642
+        // Position popup below cursor, or above if not enough space
1643
+        let popup_row = if cursor_row + (max_items as u16) + 2 < self.rows {
1644
+            cursor_row + 1
1645
+        } else {
1646
+            cursor_row.saturating_sub(max_items as u16 + 1)
1647
+        };
1648
+
1649
+        let popup_col = (cursor_col + left_offset).min(self.cols.saturating_sub(popup_width as u16));
1650
+
1651
+        // Calculate scroll offset to keep selection visible
1652
+        let scroll_offset = if selected_index >= max_items {
1653
+            selected_index - max_items + 1
1654
+        } else {
1655
+            0
1656
+        };
1657
+
1658
+        // Draw border and items
1659
+        for (i, item) in completions.iter().skip(scroll_offset).take(max_items).enumerate() {
1660
+            let row = popup_row + i as u16;
1661
+            let is_selected = i + scroll_offset == selected_index;
1662
+            let bg = if is_selected { selected_bg } else { popup_bg };
1663
+
1664
+            execute!(
1665
+                self.stdout,
1666
+                MoveTo(popup_col, row),
1667
+                SetBackgroundColor(bg),
1668
+                SetForegroundColor(item_fg),
1669
+            )?;
1670
+
1671
+            // Format: [icon] label   detail
1672
+            let icon = item.kind.map(|k| k.icon()).unwrap_or(" ");
1673
+            let label = &item.label;
1674
+            let detail = item.detail.as_deref().unwrap_or("");
1675
+
1676
+            let label_width = popup_width - 4;
1677
+            let truncated_label: String = if label.len() > label_width - 2 {
1678
+                format!("{}...", &label[..label_width - 5])
1679
+            } else {
1680
+                label.clone()
1681
+            };
1682
+
1683
+            write!(self.stdout, " {} ", icon)?;
1684
+            write!(self.stdout, "{:<width$}", truncated_label, width = label_width - detail.len().min(15))?;
1685
+
1686
+            if !detail.is_empty() {
1687
+                execute!(self.stdout, SetForegroundColor(detail_fg))?;
1688
+                let truncated_detail: String = if detail.len() > 12 {
1689
+                    format!("{}...", &detail[..9])
1690
+                } else {
1691
+                    detail.to_string()
1692
+                };
1693
+                write!(self.stdout, "{}", truncated_detail)?;
1694
+            }
1695
+
1696
+            // Clear to popup width
1697
+            execute!(self.stdout, ResetColor)?;
1698
+        }
1699
+
1700
+        // Show scroll indicator if needed
1701
+        if completions.len() > max_items {
1702
+            let indicator_row = popup_row + max_items as u16;
1703
+            execute!(
1704
+                self.stdout,
1705
+                MoveTo(popup_col, indicator_row),
1706
+                SetBackgroundColor(popup_bg),
1707
+                SetForegroundColor(detail_fg),
1708
+                Print(format!(" {}/{} items ", selected_index + 1, completions.len())),
1709
+                ResetColor,
1710
+            )?;
1711
+        }
1712
+
1713
+        Ok(())
1714
+    }
1715
+
1716
+    /// Render diagnostics in the gutter or inline
1717
+    pub fn render_diagnostics_gutter(
1718
+        &mut self,
1719
+        diagnostics: &[Diagnostic],
1720
+        viewport_line: usize,
1721
+        left_offset: u16,
1722
+        top_offset: u16,
1723
+    ) -> Result<()> {
1724
+        // Match text_rows calculation from render functions
1725
+        let text_rows = self.rows.saturating_sub(2 + top_offset) as usize;
1726
+
1727
+        for diagnostic in diagnostics {
1728
+            let line = diagnostic.range.start.line as usize;
1729
+
1730
+            // Only render if in visible viewport
1731
+            if line >= viewport_line && line < viewport_line + text_rows {
1732
+                let row = (line - viewport_line) as u16 + top_offset;
1733
+
1734
+                // Determine color based on severity
1735
+                let color = match diagnostic.severity {
1736
+                    Some(DiagnosticSeverity::Error) => Color::Red,
1737
+                    Some(DiagnosticSeverity::Warning) => Color::Yellow,
1738
+                    Some(DiagnosticSeverity::Information) => Color::Blue,
1739
+                    Some(DiagnosticSeverity::Hint) => Color::Cyan,
1740
+                    None => Color::Yellow,
1741
+                };
1742
+
1743
+                // Draw indicator at the start of the line (before line number)
1744
+                execute!(
1745
+                    self.stdout,
1746
+                    MoveTo(left_offset, row),
1747
+                    SetForegroundColor(color),
1748
+                    Print("●"),
1749
+                    ResetColor,
1750
+                )?;
1751
+            }
1752
+        }
1753
+
1754
+        Ok(())
1755
+    }
1756
+
1757
+    /// Render a hover info popup at the given screen position
1758
+    pub fn render_hover_popup(
1759
+        &mut self,
1760
+        hover: &HoverInfo,
1761
+        cursor_row: u16,
1762
+        cursor_col: u16,
1763
+        left_offset: u16,
1764
+    ) -> Result<()> {
1765
+        let (width, height) = (self.cols, self.rows);
1766
+
1767
+        // Split content into lines
1768
+        let lines: Vec<&str> = hover.contents.lines().collect();
1769
+        if lines.is_empty() {
1770
+            return Ok(());
1771
+        }
1772
+
1773
+        // Calculate popup dimensions
1774
+        let max_popup_width = (width as usize).saturating_sub(left_offset as usize + 4).min(80);
1775
+        let popup_width = lines
1776
+            .iter()
1777
+            .map(|l| l.len().min(max_popup_width))
1778
+            .max()
1779
+            .unwrap_or(20)
1780
+            .max(20);
1781
+        let max_popup_height = (height as usize).saturating_sub(4).min(15);
1782
+        let popup_height = lines.len().min(max_popup_height);
1783
+
1784
+        // Determine position - prefer above cursor, but go below if needed
1785
+        let (popup_row, show_above) = if cursor_row as usize >= popup_height + 2 {
1786
+            (cursor_row.saturating_sub(popup_height as u16 + 1), true)
1787
+        } else {
1788
+            (cursor_row + 1, false)
1789
+        };
1790
+
1791
+        let popup_col = cursor_col.max(left_offset);
1792
+
1793
+        // Ensure popup fits on screen
1794
+        let popup_col = if popup_col as usize + popup_width + 2 > width as usize {
1795
+            (width as usize).saturating_sub(popup_width + 3) as u16
1796
+        } else {
1797
+            popup_col
1798
+        };
1799
+
1800
+        // Draw popup border and content
1801
+        for (i, line) in lines.iter().take(popup_height).enumerate() {
1802
+            let row = popup_row + i as u16;
1803
+
1804
+            // Background and border
1805
+            execute!(
1806
+                self.stdout,
1807
+                MoveTo(popup_col, row),
1808
+                SetBackgroundColor(Color::AnsiValue(238)),
1809
+                SetForegroundColor(Color::White),
1810
+            )?;
1811
+
1812
+            // Truncate line if needed
1813
+            let display_line: String = if line.len() > popup_width {
1814
+                format!(" {}... ", &line[..popup_width.saturating_sub(4)])
1815
+            } else {
1816
+                format!(" {:width$} ", line, width = popup_width)
1817
+            };
1818
+
1819
+            execute!(self.stdout, Print(&display_line), ResetColor)?;
1820
+        }
1821
+
1822
+        // Show indicator if content is truncated
1823
+        if lines.len() > popup_height {
1824
+            let row = popup_row + popup_height as u16;
1825
+            execute!(
1826
+                self.stdout,
1827
+                MoveTo(popup_col, row),
1828
+                SetBackgroundColor(Color::AnsiValue(238)),
1829
+                SetForegroundColor(Color::DarkGrey),
1830
+                Print(format!(" [{} more lines] ", lines.len() - popup_height)),
1831
+                ResetColor
1832
+            )?;
1833
+        }
1834
+
1835
+        // Hide cursor position indicator
1836
+        let _ = show_above; // suppress unused warning
1837
+
1838
+        Ok(())
1839
+    }
1840
+
1841
+    /// Render the LSP server manager panel
1842
+    pub fn render_server_manager_panel(&mut self, panel: &ServerManagerPanel) -> Result<()> {
1843
+        if !panel.visible {
1844
+            return Ok(());
1845
+        }
1846
+
1847
+        let (width, height) = (self.cols, self.rows);
1848
+        let panel_width = 64.min(width as usize - 4);
1849
+        let max_visible = 10.min(height as usize - 8);
1850
+
1851
+        // Center the panel
1852
+        let start_col = ((width as usize).saturating_sub(panel_width)) / 2;
1853
+        let start_row = 2u16;
1854
+
1855
+        // Draw confirm dialog if in confirm mode
1856
+        if panel.confirm_mode {
1857
+            self.render_server_install_confirm(panel, start_col, start_row + 4)?;
1858
+            return Ok(());
1859
+        }
1860
+
1861
+        // Draw manual install info dialog
1862
+        if panel.manual_info_mode {
1863
+            self.render_manual_install_info(panel, start_col, start_row + 4)?;
1864
+            return Ok(());
1865
+        }
1866
+
1867
+        // Top border
1868
+        execute!(
1869
+            self.stdout,
1870
+            MoveTo(start_col as u16, start_row),
1871
+            SetForegroundColor(Color::Cyan),
1872
+            Print("┌"),
1873
+            Print("─".repeat(panel_width - 2)),
1874
+            Print("┐"),
1875
+            ResetColor
1876
+        )?;
1877
+
1878
+        // Header
1879
+        execute!(
1880
+            self.stdout,
1881
+            MoveTo(start_col as u16, start_row + 1),
1882
+            SetForegroundColor(Color::Cyan),
1883
+            Print("│"),
1884
+            SetForegroundColor(Color::Cyan),
1885
+            SetAttribute(Attribute::Bold),
1886
+            Print(" Language Server Manager"),
1887
+            SetAttribute(Attribute::Reset),
1888
+            SetForegroundColor(Color::DarkGrey),
1889
+        )?;
1890
+        let header_len = 25;
1891
+        let padding = panel_width - header_len - 7;
1892
+        execute!(
1893
+            self.stdout,
1894
+            Print(" ".repeat(padding)),
1895
+            Print("Alt+M"),
1896
+            SetForegroundColor(Color::Cyan),
1897
+            Print(" │"),
1898
+            ResetColor
1899
+        )?;
1900
+
1901
+        // Header separator
1902
+        execute!(
1903
+            self.stdout,
1904
+            MoveTo(start_col as u16, start_row + 2),
1905
+            SetForegroundColor(Color::Cyan),
1906
+            Print("├"),
1907
+            Print("─".repeat(panel_width - 2)),
1908
+            Print("┤"),
1909
+            ResetColor
1910
+        )?;
1911
+
1912
+        // Server list
1913
+        let visible_end = (panel.scroll_offset + max_visible).min(panel.servers.len());
1914
+        for (i, idx) in (panel.scroll_offset..visible_end).enumerate() {
1915
+            let server = &panel.servers[idx];
1916
+            let row = start_row + 3 + i as u16;
1917
+            let is_selected = idx == panel.selected_index;
1918
+
1919
+            execute!(
1920
+                self.stdout,
1921
+                MoveTo(start_col as u16, row),
1922
+                SetForegroundColor(Color::Cyan),
1923
+                Print("│"),
1924
+            )?;
1925
+
1926
+            // Highlight selected row
1927
+            if is_selected {
1928
+                execute!(self.stdout, SetAttribute(Attribute::Reverse))?;
1929
+            }
1930
+
1931
+            // Status icon
1932
+            execute!(self.stdout, Print(" "))?;
1933
+            if server.is_installed {
1934
+                execute!(
1935
+                    self.stdout,
1936
+                    SetForegroundColor(Color::Green),
1937
+                    Print("✓"),
1938
+                )?;
1939
+            } else {
1940
+                execute!(
1941
+                    self.stdout,
1942
+                    SetForegroundColor(Color::Red),
1943
+                    Print("✗"),
1944
+                )?;
1945
+            }
1946
+
1947
+            // Server name and language (or "Installing..." if being installed)
1948
+            let is_installing = panel.is_installing(idx);
1949
+            let name_lang = if is_installing {
1950
+                " Installing...".to_string()
1951
+            } else {
1952
+                format!(" {} ({})", server.name, server.language)
1953
+            };
1954
+            let name_len = name_lang.len().min(panel_width - 20);
1955
+            execute!(
1956
+                self.stdout,
1957
+                SetForegroundColor(if is_installing { Color::Yellow } else { Color::White }),
1958
+                Print(&name_lang[..name_len]),
1959
+            )?;
1960
+
1961
+            // Status text
1962
+            let status = if is_installing {
1963
+                ""
1964
+            } else if server.is_installed {
1965
+                "installed"
1966
+            } else if server.install_cmd.starts_with('#') {
1967
+                "manual"
1968
+            } else {
1969
+                "Enter to install"
1970
+            };
1971
+            // Content width is panel_width - 2 (for the two │ borders)
1972
+            // We've printed: 1 space + 1 icon + name_len chars
1973
+            // We need to print: status + 1 trailing space before │
1974
+            let used = 1 + 1 + name_len + status.len() + 1;
1975
+            let content_width = panel_width - 2;
1976
+            let status_padding = content_width.saturating_sub(used);
1977
+            execute!(self.stdout, Print(" ".repeat(status_padding)))?;
1978
+
1979
+            if server.is_installed {
1980
+                execute!(
1981
+                    self.stdout,
1982
+                    SetForegroundColor(Color::DarkGrey),
1983
+                    Print(status),
1984
+                )?;
1985
+            } else {
1986
+                execute!(
1987
+                    self.stdout,
1988
+                    SetForegroundColor(Color::Yellow),
1989
+                    Print(status),
1990
+                )?;
1991
+            }
1992
+
1993
+            if is_selected {
1994
+                execute!(self.stdout, SetAttribute(Attribute::Reset))?;
1995
+            }
1996
+
1997
+            execute!(
1998
+                self.stdout,
1999
+                Print(" "),
2000
+                SetForegroundColor(Color::Cyan),
2001
+                Print("│"),
2002
+                ResetColor
2003
+            )?;
2004
+        }
2005
+
2006
+        // Fill remaining rows
2007
+        for i in (visible_end - panel.scroll_offset)..max_visible {
2008
+            let row = start_row + 3 + i as u16;
2009
+            execute!(
2010
+                self.stdout,
2011
+                MoveTo(start_col as u16, row),
2012
+                SetForegroundColor(Color::Cyan),
2013
+                Print("│"),
2014
+                Print(" ".repeat(panel_width - 2)),
2015
+                Print("│"),
2016
+                ResetColor
2017
+            )?;
2018
+        }
2019
+
2020
+        // Footer separator
2021
+        let footer_row = start_row + 3 + max_visible as u16;
2022
+        execute!(
2023
+            self.stdout,
2024
+            MoveTo(start_col as u16, footer_row),
2025
+            SetForegroundColor(Color::Cyan),
2026
+            Print("├"),
2027
+            Print("─".repeat(panel_width - 2)),
2028
+            Print("┤"),
2029
+            ResetColor
2030
+        )?;
2031
+
2032
+        // Status or help
2033
+        execute!(
2034
+            self.stdout,
2035
+            MoveTo(start_col as u16, footer_row + 1),
2036
+            SetForegroundColor(Color::Cyan),
2037
+            Print("│"),
2038
+        )?;
2039
+
2040
+        if let Some(ref msg) = panel.status_message {
2041
+            let content_width = panel_width - 2;
2042
+            let msg_width = msg.width();
2043
+            // Truncate if needed (simple truncation, could be smarter)
2044
+            let msg_display = if msg_width > content_width - 2 {
2045
+                // Find a safe truncation point
2046
+                let mut truncated = String::new();
2047
+                let mut w = 0;
2048
+                for c in msg.chars() {
2049
+                    let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
2050
+                    if w + cw > content_width - 5 {
2051
+                        break;
2052
+                    }
2053
+                    truncated.push(c);
2054
+                    w += cw;
2055
+                }
2056
+                truncated.push_str("...");
2057
+                truncated
2058
+            } else {
2059
+                msg.clone()
2060
+            };
2061
+            let display_width = msg_display.width();
2062
+            execute!(
2063
+                self.stdout,
2064
+                SetForegroundColor(Color::Yellow),
2065
+                Print(format!(" {}", msg_display)),
2066
+            )?;
2067
+            // We printed 1 space + msg_display, need to fill to content_width
2068
+            let pad = content_width.saturating_sub(1 + display_width);
2069
+            execute!(self.stdout, Print(" ".repeat(pad)))?;
2070
+        } else {
2071
+            let help_text = " ↑↓ Navigate  Enter Install  r Refresh  Esc Close ";
2072
+            let help_width = help_text.width();
2073
+            execute!(
2074
+                self.stdout,
2075
+                SetForegroundColor(Color::DarkGrey),
2076
+                Print(help_text),
2077
+            )?;
2078
+            // Content width is panel_width - 2 (for borders)
2079
+            let content_width = panel_width - 2;
2080
+            let pad = content_width.saturating_sub(help_width);
2081
+            execute!(self.stdout, Print(" ".repeat(pad)))?;
2082
+        }
2083
+
2084
+        execute!(
2085
+            self.stdout,
2086
+            SetForegroundColor(Color::Cyan),
2087
+            Print("│"),
2088
+            ResetColor
2089
+        )?;
2090
+
2091
+        // Bottom border
2092
+        execute!(
2093
+            self.stdout,
2094
+            MoveTo(start_col as u16, footer_row + 2),
2095
+            SetForegroundColor(Color::Cyan),
2096
+            Print("└"),
2097
+            Print("─".repeat(panel_width - 2)),
2098
+            Print("┘"),
2099
+            ResetColor
2100
+        )?;
2101
+
2102
+        Ok(())
2103
+    }
2104
+
2105
+    /// Render the install confirmation dialog
2106
+    fn render_server_install_confirm(
2107
+        &mut self,
2108
+        panel: &ServerManagerPanel,
2109
+        start_col: usize,
2110
+        start_row: u16,
2111
+    ) -> Result<()> {
2112
+        let panel_width = 60;
2113
+
2114
+        let server = match panel.confirm_server() {
2115
+            Some(s) => s,
2116
+            None => return Ok(()),
2117
+        };
2118
+
2119
+        // Top border
2120
+        execute!(
2121
+            self.stdout,
2122
+            MoveTo(start_col as u16, start_row),
2123
+            SetForegroundColor(Color::Cyan),
2124
+            Print("┌"),
2125
+            Print("─".repeat(panel_width - 2)),
2126
+            Print("┐"),
2127
+            ResetColor
2128
+        )?;
2129
+
2130
+        // Title
2131
+        let title = format!(" Install {}? ", server.name);
2132
+        execute!(
2133
+            self.stdout,
2134
+            MoveTo(start_col as u16, start_row + 1),
2135
+            SetForegroundColor(Color::Cyan),
2136
+            Print("│"),
2137
+            SetAttribute(Attribute::Bold),
2138
+            Print(&title),
2139
+            SetAttribute(Attribute::Reset),
2140
+        )?;
2141
+        let pad = panel_width - 2 - title.len();
2142
+        execute!(
2143
+            self.stdout,
2144
+            Print(" ".repeat(pad)),
2145
+            SetForegroundColor(Color::Cyan),
2146
+            Print("│"),
2147
+            ResetColor
2148
+        )?;
2149
+
2150
+        // Blank line
2151
+        execute!(
2152
+            self.stdout,
2153
+            MoveTo(start_col as u16, start_row + 2),
2154
+            SetForegroundColor(Color::Cyan),
2155
+            Print("│"),
2156
+            Print(" ".repeat(panel_width - 2)),
2157
+            Print("│"),
2158
+            ResetColor
2159
+        )?;
2160
+
2161
+        // Command
2162
+        let cmd_display = if server.install_cmd.len() > panel_width - 14 {
2163
+            format!("{}...", &server.install_cmd[..panel_width - 17])
2164
+        } else {
2165
+            server.install_cmd.to_string()
2166
+        };
2167
+        execute!(
2168
+            self.stdout,
2169
+            MoveTo(start_col as u16, start_row + 3),
2170
+            SetForegroundColor(Color::Cyan),
2171
+            Print("│"),
2172
+            SetForegroundColor(Color::White),
2173
+            Print(" Command: "),
2174
+            SetForegroundColor(Color::Yellow),
2175
+            Print(&cmd_display),
2176
+        )?;
2177
+        let pad = panel_width - 12 - cmd_display.len();
2178
+        execute!(
2179
+            self.stdout,
2180
+            Print(" ".repeat(pad)),
2181
+            SetForegroundColor(Color::Cyan),
2182
+            Print("│"),
2183
+            ResetColor
2184
+        )?;
2185
+
2186
+        // Blank line
2187
+        execute!(
2188
+            self.stdout,
2189
+            MoveTo(start_col as u16, start_row + 4),
2190
+            SetForegroundColor(Color::Cyan),
2191
+            Print("│"),
2192
+            Print(" ".repeat(panel_width - 2)),
2193
+            Print("│"),
2194
+            ResetColor
2195
+        )?;
2196
+
2197
+        // Buttons
2198
+        execute!(
2199
+            self.stdout,
2200
+            MoveTo(start_col as u16, start_row + 5),
2201
+            SetForegroundColor(Color::Cyan),
2202
+            Print("│"),
2203
+        )?;
2204
+        let button_text = "[Y]es    [N]o";
2205
+        let button_pad = (panel_width - 2 - button_text.len()) / 2;
2206
+        execute!(
2207
+            self.stdout,
2208
+            Print(" ".repeat(button_pad)),
2209
+            Print("["),
2210
+            SetForegroundColor(Color::Green),
2211
+            Print("Y"),
2212
+            SetForegroundColor(Color::White),
2213
+            Print("]es    ["),
2214
+            SetForegroundColor(Color::Red),
2215
+            Print("N"),
2216
+            SetForegroundColor(Color::White),
2217
+            Print("]o"),
2218
+            Print(" ".repeat(panel_width - 2 - button_pad - button_text.len())),
2219
+            SetForegroundColor(Color::Cyan),
2220
+            Print("│"),
2221
+            ResetColor
2222
+        )?;
2223
+
2224
+        // Bottom border
2225
+        execute!(
2226
+            self.stdout,
2227
+            MoveTo(start_col as u16, start_row + 6),
2228
+            SetForegroundColor(Color::Cyan),
2229
+            Print("└"),
2230
+            Print("─".repeat(panel_width - 2)),
2231
+            Print("┘"),
2232
+            ResetColor
2233
+        )?;
2234
+
2235
+        Ok(())
2236
+    }
2237
+
2238
+    /// Render the manual install info dialog
2239
+    fn render_manual_install_info(
2240
+        &mut self,
2241
+        panel: &ServerManagerPanel,
2242
+        start_col: usize,
2243
+        start_row: u16,
2244
+    ) -> Result<()> {
2245
+        let panel_width = 60;
2246
+
2247
+        let server = match panel.manual_info_server() {
2248
+            Some(s) => s,
2249
+            None => return Ok(()),
2250
+        };
2251
+
2252
+        // Parse the install instructions (remove leading #)
2253
+        let instructions = server.install_cmd.trim_start_matches('#').trim();
2254
+
2255
+        // Top border
2256
+        execute!(
2257
+            self.stdout,
2258
+            MoveTo(start_col as u16, start_row),
2259
+            SetForegroundColor(Color::Cyan),
2260
+            Print("┌"),
2261
+            Print("─".repeat(panel_width - 2)),
2262
+            Print("┐"),
2263
+            ResetColor
2264
+        )?;
2265
+
2266
+        // Title
2267
+        let title = format!(" {} - Manual Installation ", server.name);
2268
+        execute!(
2269
+            self.stdout,
2270
+            MoveTo(start_col as u16, start_row + 1),
2271
+            SetForegroundColor(Color::Cyan),
2272
+            Print("│"),
2273
+            SetAttribute(Attribute::Bold),
2274
+            SetForegroundColor(Color::Yellow),
2275
+            Print(&title),
2276
+            SetAttribute(Attribute::Reset),
2277
+        )?;
2278
+        let pad = panel_width - 2 - title.len();
2279
+        execute!(
2280
+            self.stdout,
2281
+            Print(" ".repeat(pad)),
2282
+            SetForegroundColor(Color::Cyan),
2283
+            Print("│"),
2284
+            ResetColor
2285
+        )?;
2286
+
2287
+        // Separator
2288
+        execute!(
2289
+            self.stdout,
2290
+            MoveTo(start_col as u16, start_row + 2),
2291
+            SetForegroundColor(Color::Cyan),
2292
+            Print("├"),
2293
+            Print("─".repeat(panel_width - 2)),
2294
+            Print("┤"),
2295
+            ResetColor
2296
+        )?;
2297
+
2298
+        // Language
2299
+        execute!(
2300
+            self.stdout,
2301
+            MoveTo(start_col as u16, start_row + 3),
2302
+            SetForegroundColor(Color::Cyan),
2303
+            Print("│"),
2304
+            SetForegroundColor(Color::White),
2305
+            Print(" Language: "),
2306
+            SetForegroundColor(Color::Green),
2307
+            Print(server.language),
2308
+        )?;
2309
+        let lang_pad = panel_width - 13 - server.language.len();
2310
+        execute!(
2311
+            self.stdout,
2312
+            Print(" ".repeat(lang_pad)),
2313
+            SetForegroundColor(Color::Cyan),
2314
+            Print("│"),
2315
+            ResetColor
2316
+        )?;
2317
+
2318
+        // Blank line
2319
+        execute!(
2320
+            self.stdout,
2321
+            MoveTo(start_col as u16, start_row + 4),
2322
+            SetForegroundColor(Color::Cyan),
2323
+            Print("│"),
2324
+            Print(" ".repeat(panel_width - 2)),
2325
+            Print("│"),
2326
+            ResetColor
2327
+        )?;
2328
+
2329
+        // Instructions label
2330
+        execute!(
2331
+            self.stdout,
2332
+            MoveTo(start_col as u16, start_row + 5),
2333
+            SetForegroundColor(Color::Cyan),
2334
+            Print("│"),
2335
+            SetForegroundColor(Color::White),
2336
+            Print(" Installation:"),
2337
+        )?;
2338
+        execute!(
2339
+            self.stdout,
2340
+            Print(" ".repeat(panel_width - 16)),
2341
+            SetForegroundColor(Color::Cyan),
2342
+            Print("│"),
2343
+            ResetColor
2344
+        )?;
2345
+
2346
+        // Instructions text (may be multi-line, show up to 3 lines)
2347
+        let instr_lines: Vec<&str> = instructions.lines().collect();
2348
+        for (i, line) in instr_lines.iter().take(3).enumerate() {
2349
+            let row = start_row + 6 + i as u16;
2350
+            let display_line = if line.len() > panel_width - 6 {
2351
+                format!("{}...", &line[..panel_width - 9])
2352
+            } else {
2353
+                line.to_string()
2354
+            };
2355
+            execute!(
2356
+                self.stdout,
2357
+                MoveTo(start_col as u16, row),
2358
+                SetForegroundColor(Color::Cyan),
2359
+                Print("│"),
2360
+                SetForegroundColor(Color::Yellow),
2361
+                Print(format!("   {}", display_line)),
2362
+            )?;
2363
+            let line_pad = panel_width - 5 - display_line.len();
2364
+            execute!(
2365
+                self.stdout,
2366
+                Print(" ".repeat(line_pad)),
2367
+                SetForegroundColor(Color::Cyan),
2368
+                Print("│"),
2369
+                ResetColor
2370
+            )?;
2371
+        }
2372
+
2373
+        // Fill remaining instruction lines if less than 3
2374
+        for i in instr_lines.len()..3 {
2375
+            let row = start_row + 6 + i as u16;
2376
+            execute!(
2377
+                self.stdout,
2378
+                MoveTo(start_col as u16, row),
2379
+                SetForegroundColor(Color::Cyan),
2380
+                Print("│"),
2381
+                Print(" ".repeat(panel_width - 2)),
2382
+                Print("│"),
2383
+                ResetColor
2384
+            )?;
2385
+        }
2386
+
2387
+        // Blank line
2388
+        execute!(
2389
+            self.stdout,
2390
+            MoveTo(start_col as u16, start_row + 9),
2391
+            SetForegroundColor(Color::Cyan),
2392
+            Print("│"),
2393
+            Print(" ".repeat(panel_width - 2)),
2394
+            Print("│"),
2395
+            ResetColor
2396
+        )?;
2397
+
2398
+        // Status or help line
2399
+        execute!(
2400
+            self.stdout,
2401
+            MoveTo(start_col as u16, start_row + 10),
2402
+            SetForegroundColor(Color::Cyan),
2403
+            Print("│"),
2404
+        )?;
2405
+
2406
+        if panel.copied_to_clipboard {
2407
+            execute!(
2408
+                self.stdout,
2409
+                SetForegroundColor(Color::Green),
2410
+                Print(" ✓ Copied to clipboard!"),
2411
+            )?;
2412
+            execute!(self.stdout, Print(" ".repeat(panel_width - 26)))?;
2413
+        } else {
2414
+            execute!(
2415
+                self.stdout,
2416
+                SetForegroundColor(Color::DarkGrey),
2417
+                Print(" [C] Copy to clipboard  [Esc] Close"),
2418
+            )?;
2419
+            execute!(self.stdout, Print(" ".repeat(panel_width - 38)))?;
2420
+        }
2421
+
2422
+        execute!(
2423
+            self.stdout,
2424
+            SetForegroundColor(Color::Cyan),
2425
+            Print("│"),
2426
+            ResetColor
2427
+        )?;
2428
+
2429
+        // Bottom border
2430
+        execute!(
2431
+            self.stdout,
2432
+            MoveTo(start_col as u16, start_row + 11),
2433
+            SetForegroundColor(Color::Cyan),
2434
+            Print("└"),
2435
+            Print("─".repeat(panel_width - 2)),
2436
+            Print("┘"),
2437
+            ResetColor
2438
+        )?;
2439
+
2440
+        Ok(())
2441
+    }
13802442
 }
src/workspace/state.rsmodified
@@ -11,6 +11,8 @@ use std::path::{Path, PathBuf};
1111
 use crate::buffer::Buffer;
1212
 use crate::editor::{Cursors, History};
1313
 use crate::fuss::FussMode;
14
+use crate::lsp::LspClient;
15
+use crate::syntax::Highlighter;
1416
 
1517
 /// Normalized pane bounds (0.0 to 1.0)
1618
 /// Converted to screen coordinates at render time
@@ -43,6 +45,8 @@ pub struct BufferEntry {
4345
     pub buffer: Buffer,
4446
     /// Undo/redo history for this buffer
4547
     pub history: History,
48
+    /// Syntax highlighter for this buffer
49
+    pub highlighter: Highlighter,
4650
     /// File is outside workspace directory
4751
     pub is_orphan: bool,
4852
     /// Hash of buffer content at last save (None for new unsaved buffers)
@@ -60,6 +64,7 @@ impl BufferEntry {
6064
             path: None,
6165
             buffer,
6266
             history: History::new(),
67
+            highlighter: Highlighter::new(),
6368
             is_orphan: false,
6469
             saved_hash,
6570
             saved_len,
@@ -72,10 +77,18 @@ impl BufferEntry {
7277
         let buffer = Buffer::from_str(content);
7378
         let saved_hash = Some(buffer.content_hash());
7479
         let saved_len = Some(buffer.len_chars());
80
+
81
+        // Detect language from display name for syntax highlighting
82
+        let mut highlighter = Highlighter::new();
83
+        if let Some(name) = display_name {
84
+            highlighter.detect_language(name);
85
+        }
86
+
7587
         Self {
7688
             path: display_name.map(PathBuf::from),
7789
             buffer,
7890
             history: History::new(),
91
+            highlighter,
7992
             is_orphan: true, // Mark as orphan so path isn't prefixed with workspace root
8093
             saved_hash,
8194
             saved_len,
@@ -97,10 +110,17 @@ impl BufferEntry {
97110
                 .to_path_buf()
98111
         };
99112
 
113
+        // Detect language for syntax highlighting
114
+        let mut highlighter = Highlighter::new();
115
+        if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
116
+            highlighter.detect_language(filename);
117
+        }
118
+
100119
         Ok(Self {
101120
             path: Some(stored_path),
102121
             buffer,
103122
             history: History::new(),
123
+            highlighter,
104124
             is_orphan,
105125
             saved_hash,
106126
             saved_len,
@@ -530,6 +550,8 @@ pub struct Workspace {
530550
     pub fuss: FussMode,
531551
     /// Workspace configuration
532552
     pub config: WorkspaceConfig,
553
+    /// LSP client for language server support
554
+    pub lsp: LspClient,
533555
 }
534556
 
535557
 impl Workspace {
@@ -537,12 +559,15 @@ impl Workspace {
537559
     pub fn new(root: PathBuf) -> Self {
538560
         let mut fuss = FussMode::new();
539561
         fuss.init(&root);
562
+        let root_str = root.to_string_lossy().to_string();
563
+        let lsp = LspClient::new(&root_str);
540564
         Self {
541565
             root,
542566
             tabs: vec![Tab::new()],
543567
             active_tab: 0,
544568
             fuss,
545569
             config: WorkspaceConfig::default(),
570
+            lsp,
546571
         }
547572
     }
548573
 
@@ -664,6 +689,19 @@ impl Workspace {
664689
 
665690
         // Open new tab
666691
         let tab = Tab::from_file(path, &self.root)?;
692
+
693
+        // Notify LSP server of newly opened file
694
+        if let Some(file_path) = tab.path() {
695
+            let full_path = if tab.is_orphan() {
696
+                file_path.clone()
697
+            } else {
698
+                self.root.join(file_path)
699
+            };
700
+            let path_str = full_path.to_string_lossy();
701
+            let content = tab.buffers[0].buffer.contents();
702
+            let _ = self.lsp.open_document(&path_str, &content);
703
+        }
704
+
667705
         self.tabs.push(tab);
668706
         self.active_tab = self.tabs.len() - 1;
669707
         Ok(())
@@ -981,4 +1019,58 @@ impl Workspace {
9811019
     pub fn is_git_repo(&self) -> bool {
9821020
         self.root.join(".git").exists()
9831021
     }
1022
+
1023
+    /// Find a tab by file path, returns tab index if found
1024
+    pub fn find_tab_by_path(&self, path: &std::path::Path) -> Option<usize> {
1025
+        for (tab_idx, tab) in self.tabs.iter().enumerate() {
1026
+            for buffer_entry in &tab.buffers {
1027
+                if let Some(buf_path) = &buffer_entry.path {
1028
+                    // Get full path for comparison
1029
+                    let full_path = if buffer_entry.is_orphan {
1030
+                        buf_path.clone()
1031
+                    } else {
1032
+                        self.root.join(buf_path)
1033
+                    };
1034
+                    if full_path == path {
1035
+                        return Some(tab_idx);
1036
+                    }
1037
+                }
1038
+            }
1039
+        }
1040
+        None
1041
+    }
1042
+
1043
+    /// Apply a text edit to a buffer in a specific tab
1044
+    pub fn apply_text_edit(&mut self, tab_idx: usize, edit: &crate::lsp::TextEdit) {
1045
+        if tab_idx >= self.tabs.len() {
1046
+            return;
1047
+        }
1048
+
1049
+        let tab = &mut self.tabs[tab_idx];
1050
+        if tab.buffers.is_empty() {
1051
+            return;
1052
+        }
1053
+
1054
+        let buffer = &mut tab.buffers[0].buffer;
1055
+
1056
+        // Convert LSP range to buffer char indices
1057
+        let start_line = edit.range.start.line as usize;
1058
+        let start_col = edit.range.start.character as usize;
1059
+        let end_line = edit.range.end.line as usize;
1060
+        let end_col = edit.range.end.character as usize;
1061
+
1062
+        let start_char = buffer.line_col_to_char(start_line, start_col);
1063
+        let end_char = buffer.line_col_to_char(end_line, end_col);
1064
+
1065
+        // Delete the old text first (if range is non-empty)
1066
+        if start_char < end_char {
1067
+            buffer.delete(start_char, end_char);
1068
+        }
1069
+
1070
+        // Insert the new text at start position
1071
+        if !edit.new_text.is_empty() {
1072
+            buffer.insert(start_char, &edit.new_text);
1073
+        }
1074
+        // Buffer automatically tracks modifications via content hash
1075
+    }
9841076
 }