@@ -483,6 +483,33 @@ struct BracketMatchCache { |
| 483 | 483 | valid: bool, |
| 484 | 484 | } |
| 485 | 485 | |
| 486 | +/// Source of ghost text suggestion |
| 487 | +#[derive(Debug, Default, Clone, Copy, PartialEq)] |
| 488 | +enum GhostTextSource { |
| 489 | + #[default] |
| 490 | + None, |
| 491 | + Lsp, |
| 492 | + CurrentBuffer, |
| 493 | + OtherBuffer, |
| 494 | +} |
| 495 | + |
| 496 | +/// Ghost text inline autocomplete state |
| 497 | +#[derive(Debug, Default)] |
| 498 | +struct GhostTextState { |
| 499 | + /// The ghost text suggestion (suffix to show/insert after cursor) |
| 500 | + suggestion: Option<String>, |
| 501 | + /// Position where suggestion applies (line, col) |
| 502 | + position: Option<(usize, usize)>, |
| 503 | + /// The prefix that was used to generate this suggestion |
| 504 | + prefix: String, |
| 505 | + /// Source of the current suggestion |
| 506 | + source: GhostTextSource, |
| 507 | + /// Cached word list from current buffer (content_hash, words) |
| 508 | + buffer_words_cache: Option<(u64, Vec<String>)>, |
| 509 | + /// Cached word list from all open buffers |
| 510 | + all_buffer_words_cache: Vec<String>, |
| 511 | +} |
| 512 | + |
| 486 | 513 | /// Main editor state |
| 487 | 514 | pub struct Editor { |
| 488 | 515 | /// The workspace (owns tabs, panes, fuss mode, and config) |
@@ -511,6 +538,8 @@ pub struct Editor { |
| 511 | 538 | search_state: SearchState, |
| 512 | 539 | /// Cached bracket match for rendering |
| 513 | 540 | bracket_cache: BracketMatchCache, |
| 541 | + /// Ghost text inline autocomplete state |
| 542 | + ghost_text: GhostTextState, |
| 514 | 543 | /// Yank stack (kill ring) - separate from system clipboard |
| 515 | 544 | yank_stack: Vec<String>, |
| 516 | 545 | /// Current index in yank stack when cycling with Alt+Y |
@@ -575,6 +604,7 @@ impl Editor { |
| 575 | 604 | server_manager: ServerManagerPanel::new(), |
| 576 | 605 | search_state: SearchState::default(), |
| 577 | 606 | bracket_cache: BracketMatchCache::default(), |
| 607 | + ghost_text: GhostTextState::default(), |
| 578 | 608 | yank_stack: Vec::with_capacity(32), |
| 579 | 609 | yank_index: None, |
| 580 | 610 | last_yank_len: 0, |
@@ -1448,6 +1478,152 @@ impl Editor { |
| 1448 | 1478 | self.lsp_state.completion_index = 0; |
| 1449 | 1479 | } |
| 1450 | 1480 | |
| 1481 | + // === Ghost Text (Inline Autocomplete) === |
| 1482 | + |
| 1483 | + /// Update ghost text suggestion based on current cursor position |
| 1484 | + fn update_ghost_text(&mut self) { |
| 1485 | + // Don't show ghost text if completion popup is visible |
| 1486 | + if self.lsp_state.completion_visible { |
| 1487 | + self.ghost_text.suggestion = None; |
| 1488 | + return; |
| 1489 | + } |
| 1490 | + |
| 1491 | + let cursor = self.cursor(); |
| 1492 | + let line_idx = cursor.line; |
| 1493 | + let col = cursor.col; |
| 1494 | + |
| 1495 | + // Get current line and extract prefix (word before cursor) |
| 1496 | + let prefix = match self.buffer().line_str(line_idx) { |
| 1497 | + Some(line) => { |
| 1498 | + let before_cursor: String = line.chars().take(col).collect(); |
| 1499 | + // Walk back to find word start |
| 1500 | + before_cursor |
| 1501 | + .chars() |
| 1502 | + .rev() |
| 1503 | + .take_while(|c| c.is_alphanumeric() || *c == '_') |
| 1504 | + .collect::<String>() |
| 1505 | + .chars() |
| 1506 | + .rev() |
| 1507 | + .collect::<String>() |
| 1508 | + } |
| 1509 | + None => return, |
| 1510 | + }; |
| 1511 | + |
| 1512 | + // Don't suggest for very short prefixes |
| 1513 | + if prefix.len() < 2 { |
| 1514 | + self.ghost_text.suggestion = None; |
| 1515 | + return; |
| 1516 | + } |
| 1517 | + |
| 1518 | + // Try to find best suggestion from all sources |
| 1519 | + if let Some(full_word) = self.find_best_suggestion(&prefix) { |
| 1520 | + // Ghost text is the suffix (part after prefix) |
| 1521 | + if full_word.len() > prefix.len() { |
| 1522 | + let suffix = full_word[prefix.len()..].to_string(); |
| 1523 | + self.ghost_text.suggestion = Some(suffix); |
| 1524 | + self.ghost_text.position = Some((line_idx, col)); |
| 1525 | + self.ghost_text.prefix = prefix; |
| 1526 | + return; |
| 1527 | + } |
| 1528 | + } |
| 1529 | + |
| 1530 | + self.ghost_text.suggestion = None; |
| 1531 | + } |
| 1532 | + |
| 1533 | + /// Find best matching suggestion from all sources (LSP, current buffer, other buffers) |
| 1534 | + fn find_best_suggestion(&mut self, prefix: &str) -> Option<String> { |
| 1535 | + let prefix_lower = prefix.to_lowercase(); |
| 1536 | + |
| 1537 | + // Priority 1: LSP completions (if we have recent ones) |
| 1538 | + for item in &self.lsp_state.completions_original { |
| 1539 | + let text = item.insert_text.as_ref().unwrap_or(&item.label); |
| 1540 | + if text.to_lowercase().starts_with(&prefix_lower) && text.len() > prefix.len() { |
| 1541 | + self.ghost_text.source = GhostTextSource::Lsp; |
| 1542 | + return Some(text.clone()); |
| 1543 | + } |
| 1544 | + } |
| 1545 | + |
| 1546 | + // Priority 2: Current buffer words |
| 1547 | + let buffer_hash = self.buffer_mut().content_hash(); |
| 1548 | + let needs_refresh = self |
| 1549 | + .ghost_text |
| 1550 | + .buffer_words_cache |
| 1551 | + .as_ref() |
| 1552 | + .map(|(h, _)| *h != buffer_hash) |
| 1553 | + .unwrap_or(true); |
| 1554 | + |
| 1555 | + if needs_refresh { |
| 1556 | + let words = self.buffer().extract_words(); |
| 1557 | + self.ghost_text.buffer_words_cache = Some((buffer_hash, words)); |
| 1558 | + } |
| 1559 | + |
| 1560 | + if let Some((_, ref words)) = self.ghost_text.buffer_words_cache { |
| 1561 | + for word in words { |
| 1562 | + if word.to_lowercase().starts_with(&prefix_lower) |
| 1563 | + && word != prefix |
| 1564 | + && word.len() > prefix.len() |
| 1565 | + { |
| 1566 | + self.ghost_text.source = GhostTextSource::CurrentBuffer; |
| 1567 | + return Some(word.clone()); |
| 1568 | + } |
| 1569 | + } |
| 1570 | + } |
| 1571 | + |
| 1572 | + // Priority 3: Words from other buffers |
| 1573 | + for word in &self.ghost_text.all_buffer_words_cache { |
| 1574 | + if word.to_lowercase().starts_with(&prefix_lower) |
| 1575 | + && word != prefix |
| 1576 | + && word.len() > prefix.len() |
| 1577 | + { |
| 1578 | + self.ghost_text.source = GhostTextSource::OtherBuffer; |
| 1579 | + return Some(word.clone()); |
| 1580 | + } |
| 1581 | + } |
| 1582 | + |
| 1583 | + None |
| 1584 | + } |
| 1585 | + |
| 1586 | + /// Collect words from all open buffers in workspace |
| 1587 | + fn collect_all_buffer_words(&self) -> Vec<String> { |
| 1588 | + let mut words = std::collections::HashSet::new(); |
| 1589 | + for tab in &self.workspace.tabs { |
| 1590 | + for buffer_entry in &tab.buffers { |
| 1591 | + for word in buffer_entry.buffer.extract_words() { |
| 1592 | + words.insert(word); |
| 1593 | + } |
| 1594 | + } |
| 1595 | + } |
| 1596 | + words.into_iter().collect() |
| 1597 | + } |
| 1598 | + |
| 1599 | + /// Accept the current ghost text suggestion |
| 1600 | + fn accept_ghost_text(&mut self) { |
| 1601 | + if let Some(suffix) = self.ghost_text.suggestion.take() { |
| 1602 | + self.insert_text(&suffix); |
| 1603 | + self.ghost_text.position = None; |
| 1604 | + self.ghost_text.prefix.clear(); |
| 1605 | + } |
| 1606 | + } |
| 1607 | + |
| 1608 | + /// Dismiss ghost text (clear suggestion) |
| 1609 | + fn dismiss_ghost_text(&mut self) { |
| 1610 | + self.ghost_text.suggestion = None; |
| 1611 | + self.ghost_text.position = None; |
| 1612 | + self.ghost_text.prefix.clear(); |
| 1613 | + } |
| 1614 | + |
| 1615 | + /// Check if ghost text should be dismissed due to cursor movement |
| 1616 | + fn validate_ghost_text_position(&mut self) { |
| 1617 | + if let Some((line, col)) = self.ghost_text.position { |
| 1618 | + let cursor = self.cursor(); |
| 1619 | + // Dismiss if cursor moved to different line or before the prefix start |
| 1620 | + let prefix_start = col.saturating_sub(self.ghost_text.prefix.len()); |
| 1621 | + if cursor.line != line || cursor.col < prefix_start || cursor.col > col { |
| 1622 | + self.dismiss_ghost_text(); |
| 1623 | + } |
| 1624 | + } |
| 1625 | + } |
| 1626 | + |
| 1451 | 1627 | /// Process a key event, handling ESC as potential Alt prefix |
| 1452 | 1628 | fn process_key(&mut self, key_event: KeyEvent) -> Result<()> { |
| 1453 | 1629 | use crossterm::event::{KeyCode, KeyModifiers}; |
@@ -1900,6 +2076,7 @@ impl Editor { |
| 1900 | 2076 | top_offset, |
| 1901 | 2077 | is_modified, |
| 1902 | 2078 | &mut buffer_entry.highlighter, |
| 2079 | + self.ghost_text.suggestion.as_deref(), |
| 1903 | 2080 | )?; |
| 1904 | 2081 | } |
| 1905 | 2082 | |
@@ -2272,6 +2449,7 @@ impl Editor { |
| 2272 | 2449 | } else { |
| 2273 | 2450 | self.cursors_mut().primary_mut().clear_selection(); |
| 2274 | 2451 | } |
| 2452 | + self.dismiss_ghost_text(); |
| 2275 | 2453 | } |
| 2276 | 2454 | |
| 2277 | 2455 | // === Undo/Redo === |
@@ -2318,20 +2496,44 @@ impl Editor { |
| 2318 | 2496 | (Key::Char('f'), Modifiers { alt: true, .. }) => self.move_word_right(false), |
| 2319 | 2497 | |
| 2320 | 2498 | // === Movement with selection === |
| 2321 | | - (Key::Up, Modifiers { shift, .. }) => self.move_up(*shift), |
| 2322 | | - (Key::Down, Modifiers { shift, .. }) => self.move_down(*shift), |
| 2323 | | - (Key::Left, Modifiers { shift, .. }) => self.move_left(*shift), |
| 2324 | | - (Key::Right, Modifiers { shift, .. }) => self.move_right(*shift), |
| 2499 | + (Key::Up, Modifiers { shift, .. }) => { |
| 2500 | + self.move_up(*shift); |
| 2501 | + self.validate_ghost_text_position(); |
| 2502 | + } |
| 2503 | + (Key::Down, Modifiers { shift, .. }) => { |
| 2504 | + self.move_down(*shift); |
| 2505 | + self.validate_ghost_text_position(); |
| 2506 | + } |
| 2507 | + (Key::Left, Modifiers { shift, .. }) => { |
| 2508 | + self.move_left(*shift); |
| 2509 | + self.validate_ghost_text_position(); |
| 2510 | + } |
| 2511 | + (Key::Right, Modifiers { shift, .. }) => { |
| 2512 | + self.move_right(*shift); |
| 2513 | + self.validate_ghost_text_position(); |
| 2514 | + } |
| 2325 | 2515 | |
| 2326 | 2516 | // Home/End |
| 2327 | | - (Key::Home, Modifiers { shift, .. }) => self.move_home(*shift), |
| 2328 | | - (Key::End, Modifiers { shift, .. }) => self.move_end(*shift), |
| 2517 | + (Key::Home, Modifiers { shift, .. }) => { |
| 2518 | + self.move_home(*shift); |
| 2519 | + self.validate_ghost_text_position(); |
| 2520 | + } |
| 2521 | + (Key::End, Modifiers { shift, .. }) => { |
| 2522 | + self.move_end(*shift); |
| 2523 | + self.validate_ghost_text_position(); |
| 2524 | + } |
| 2329 | 2525 | (Key::Char('a'), Modifiers { ctrl: true, shift, .. }) => self.smart_home(*shift), |
| 2330 | 2526 | (Key::Char('e'), Modifiers { ctrl: true, shift, .. }) => self.move_end(*shift), |
| 2331 | 2527 | |
| 2332 | 2528 | // Page movement |
| 2333 | | - (Key::PageUp, Modifiers { shift, .. }) => self.page_up(*shift), |
| 2334 | | - (Key::PageDown, Modifiers { shift, .. }) => self.page_down(*shift), |
| 2529 | + (Key::PageUp, Modifiers { shift, .. }) => { |
| 2530 | + self.page_up(*shift); |
| 2531 | + self.validate_ghost_text_position(); |
| 2532 | + } |
| 2533 | + (Key::PageDown, Modifiers { shift, .. }) => { |
| 2534 | + self.page_down(*shift); |
| 2535 | + self.validate_ghost_text_position(); |
| 2536 | + } |
| 2335 | 2537 | |
| 2336 | 2538 | // Join lines: Ctrl+J |
| 2337 | 2539 | (Key::Char('j'), Modifiers { ctrl: true, .. }) => self.join_lines(), |
@@ -2374,13 +2576,30 @@ impl Editor { |
| 2374 | 2576 | (Key::Char(c), Modifiers { ctrl: false, alt: false, .. }) => { |
| 2375 | 2577 | self.insert_char(*c); |
| 2376 | 2578 | } |
| 2377 | | - (Key::Enter, _) => self.insert_newline(), |
| 2378 | | - (Key::Backspace, Modifiers { alt: true, .. }) => self.delete_word_backward(), |
| 2579 | + (Key::Enter, _) => { |
| 2580 | + self.insert_newline(); |
| 2581 | + self.dismiss_ghost_text(); |
| 2582 | + } |
| 2583 | + (Key::Backspace, Modifiers { alt: true, .. }) => { |
| 2584 | + self.delete_word_backward(); |
| 2585 | + self.update_ghost_text(); |
| 2586 | + } |
| 2379 | 2587 | (Key::Backspace, _) | (Key::Char('h'), Modifiers { ctrl: true, .. }) => { |
| 2380 | 2588 | self.delete_backward(); |
| 2589 | + self.update_ghost_text(); |
| 2590 | + } |
| 2591 | + (Key::Delete, _) => { |
| 2592 | + self.delete_forward(); |
| 2593 | + self.dismiss_ghost_text(); |
| 2594 | + } |
| 2595 | + (Key::Tab, _) => { |
| 2596 | + // Accept ghost text if visible and no selection |
| 2597 | + if self.ghost_text.suggestion.is_some() && !self.cursor().has_selection() { |
| 2598 | + self.accept_ghost_text(); |
| 2599 | + } else { |
| 2600 | + self.insert_tab(); |
| 2601 | + } |
| 2381 | 2602 | } |
| 2382 | | - (Key::Delete, _) => self.delete_forward(), |
| 2383 | | - (Key::Tab, _) => self.insert_tab(), |
| 2384 | 2603 | (Key::BackTab, _) => self.dedent(), |
| 2385 | 2604 | |
| 2386 | 2605 | // Delete word backward: Ctrl+W |
@@ -3282,6 +3501,7 @@ impl Editor { |
| 3282 | 3501 | // For multi-cursor, use simple insert (skip auto-pair complexity for now) |
| 3283 | 3502 | if self.cursors().len() > 1 { |
| 3284 | 3503 | self.insert_text_multi(&c.to_string()); |
| 3504 | + self.dismiss_ghost_text(); |
| 3285 | 3505 | return; |
| 3286 | 3506 | } |
| 3287 | 3507 | |
@@ -3292,6 +3512,7 @@ impl Editor { |
| 3292 | 3512 | if c == next_char && (c == ')' || c == ']' || c == '}' || c == '"' || c == '\'' || c == '`') { |
| 3293 | 3513 | self.cursor_mut().col += 1; |
| 3294 | 3514 | self.cursor_mut().desired_col = self.cursor().col; |
| 3515 | + self.dismiss_ghost_text(); |
| 3295 | 3516 | return; |
| 3296 | 3517 | } |
| 3297 | 3518 | } |
@@ -3334,11 +3555,19 @@ impl Editor { |
| 3334 | 3555 | |
| 3335 | 3556 | let cursor_after = self.cursor_pos(); |
| 3336 | 3557 | self.history_mut().record_insert(idx, pair_str, cursor_before, cursor_after); |
| 3558 | + self.dismiss_ghost_text(); |
| 3337 | 3559 | return; |
| 3338 | 3560 | } |
| 3339 | 3561 | } |
| 3340 | 3562 | |
| 3341 | 3563 | self.insert_text(&c.to_string()); |
| 3564 | + |
| 3565 | + // Update ghost text after alphanumeric input |
| 3566 | + if c.is_alphanumeric() || c == '_' { |
| 3567 | + self.update_ghost_text(); |
| 3568 | + } else { |
| 3569 | + self.dismiss_ghost_text(); |
| 3570 | + } |
| 3342 | 3571 | } |
| 3343 | 3572 | |
| 3344 | 3573 | /// Get character at cursor position (if any) |