Skip to main content

ragrig_gpui/editor/
host.rs

1//! The editor contract used by the roundtrip.
2//!
3//! Hosts implement [`EditorSurface`] for their concrete editor component and
4//! [`RoundtripHost`] for the state that owns it. [`Roundtrip`] turns those
5//! implementations into the document/selection contexts the agent sees and
6//! routes guarded replacements back to the editor.
7
8use std::ops::Range;
9use std::path::{Path, PathBuf};
10use std::rc::Rc;
11
12use gpui_kit::{App, SharedString, Window};
13
14use super::{
15    bibtex::bibtex_entry_offset,
16    protocol::{EditorBuffer, RoundtripContext, char_range_to_bytes},
17};
18
19/// An owned snapshot of one editor document.
20///
21/// The content is a [`SharedString`], so taking a snapshot for a prompt does
22/// not copy the buffer.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct EditorSnapshot {
25    /// Stable identity of the document inside the host.
26    pub id: u64,
27    /// Display name (`draft.md`, `Untitled-1`).
28    pub name: SharedString,
29    /// File backing the document, if any.
30    pub path: Option<PathBuf>,
31    /// Caret position (line, column, 0-based).
32    pub cursor: (usize, usize),
33    /// The document source.
34    pub content: SharedString,
35}
36
37impl EditorSnapshot {
38    /// The borrowed view the protocol's containers are built from.
39    #[must_use]
40    pub fn buffer(&self) -> EditorBuffer<'_> {
41        EditorBuffer {
42            name: self.name.as_ref(),
43            path: self.path.as_deref(),
44            cursor: self.cursor,
45            content: self.content.as_ref(),
46        }
47    }
48}
49
50/// A marked passage in one editor document.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct SelectionSnapshot {
53    /// The document the passage belongs to.
54    pub document: EditorSnapshot,
55    /// The passage, char-indexed and document-relative.
56    pub range: Range<usize>,
57}
58
59/// The minimal editor surface the roundtrip needs.
60///
61/// Implement this for the host's document/editor adapter. The concrete editor
62/// component (zorite, gpuikit, …) stays behind this trait, so the agent never
63/// names a backend.
64pub trait EditorSurface {
65    /// Stable identity of the document inside the host.
66    fn id(&self) -> u64;
67    /// Display name shown in tabs and context containers.
68    fn name(&self) -> SharedString;
69    /// File backing the document, if any.
70    fn path(&self) -> Option<&Path>;
71    /// The current source, as a cheap shared handle.
72    fn text(&self, cx: &App) -> SharedString;
73    /// Caret position (line, column, 0-based).
74    fn cursor(&self, cx: &App) -> (usize, usize);
75    /// The marked passage as a char range, or `None` when nothing is marked.
76    fn selection(&self, window: &mut Window, cx: &mut App) -> Option<Range<usize>>;
77    /// Replace a byte range as one undo step.
78    fn replace(&self, range: Range<usize>, text: &str, window: &mut Window, cx: &mut App);
79    /// Focus this editor.
80    fn focus(&self, window: &mut Window, cx: &mut App);
81
82    /// Place the caret at `offset` (char-indexed) and scroll it into view.
83    ///
84    /// Used by the Library to reveal a reference in the bibliography. A
85    /// backend that already scrolls on a caret change may leave the scrolling
86    /// to its editor; a host-owned scroll view should also follow the caret.
87    fn reveal(&self, offset: usize, window: &mut Window, cx: &mut App);
88
89    /// The caret as a char index, whether or not a selection is marked.
90    ///
91    /// The library uses this to insert a citation at the caret. Hosts whose
92    /// editor exposes a native caret can override the default, which computes
93    /// it from the snapshot's line and column.
94    fn caret(&self, cx: &App) -> usize {
95        let snapshot = self.snapshot(cx);
96        char_index_for_line_column(&snapshot.content, snapshot.cursor)
97    }
98
99    /// Insert `text` at the selection, or at the caret when nothing is marked.
100    ///
101    /// The default is backend-independent: it converts the char-indexed
102    /// selection to bytes and calls [`Self::replace`], so the insertion is one
103    /// undo step in every editor that implements the contract. This is the
104    /// Library → Editor half of the roundtrip.
105    fn insert_text(&self, text: &str, window: &mut Window, cx: &mut App) {
106        if text.is_empty() {
107            return;
108        }
109        let content = self.text(cx);
110        let range = self.selection(window, cx).unwrap_or_else(|| {
111            let caret = self.caret(cx);
112            caret..caret
113        });
114        self.replace(char_range_to_bytes(&content, range), text, window, cx);
115    }
116
117    /// An owned snapshot suitable for prompt building.
118    fn snapshot(&self, cx: &App) -> EditorSnapshot {
119        EditorSnapshot {
120            id: self.id(),
121            name: self.name(),
122            path: self.path().map(Path::to_path_buf),
123            cursor: self.cursor(cx),
124            content: self.text(cx),
125        }
126    }
127}
128
129/// The char index of a 0-based `(line, column)` in `content`.
130///
131/// The column counts chars, matching [`EditorSnapshot::cursor`]. A position
132/// past the end clamps to the last char.
133#[must_use]
134pub fn char_index_for_line_column(content: &str, cursor: (usize, usize)) -> usize {
135    let mut line = 0usize;
136    let mut column = 0usize;
137    for (byte, character) in content.char_indices() {
138        if line == cursor.0 && column == cursor.1 {
139            return content[..byte].chars().count();
140        }
141        if character == '\n' {
142            line += 1;
143            column = 0;
144        } else {
145            column += 1;
146        }
147    }
148    content.chars().count()
149}
150
151/// The host side of the roundtrip: the active editor and its write-back.
152pub trait RoundtripHost {
153    /// The active document, if any.
154    fn active_document(&self, cx: &App) -> Option<EditorSnapshot>;
155    /// The active editor's marked passage, if any.
156    fn selection(&self, window: &mut Window, cx: &mut App) -> Option<SelectionSnapshot>;
157    /// Insert text at the active selection or caret.
158    ///
159    /// This is the Library → Editor direction: the host owns the editor, so it
160    /// performs the insertion through its [`EditorSurface`] implementation.
161    fn insert(&self, _text: &str, _window: &mut Window, _cx: &mut App) -> bool {
162        false
163    }
164    /// The bibliography file this host edits, if one is configured.
165    ///
166    /// The default [`Self::edit_reference`] reads this path from disk. Hosts
167    /// that keep the bibliography open override `edit_reference` to use the
168    /// in-memory buffer instead.
169    fn bibliography(&self, _cx: &App) -> Option<PathBuf> {
170        None
171    }
172
173    /// Open `path` (or focus it when already open) and reveal char `offset`.
174    ///
175    /// Returns `false` when the host cannot open the file. This is the file
176    /// side of the Library → Editor roundtrip; the host owns tabs and paths.
177    fn open_at(&self, _path: &Path, _offset: usize, _window: &mut Window, _cx: &mut App) -> bool {
178        false
179    }
180
181    /// Open the bibliography at the entry for `key`.
182    ///
183    /// The default implementation locates the key in the file on disk and
184    /// calls [`Self::open_at`]. A host with an open, possibly unsaved
185    /// bibliography overrides this to use its buffer.
186    fn edit_reference(&self, key: &str, window: &mut Window, cx: &mut App) -> Result<(), String> {
187        let Some(path) = self.bibliography(cx) else {
188            return Err("No bibliography configured".to_owned());
189        };
190        let text = std::fs::read_to_string(&path)
191            .map_err(|error| format!("cannot read '{}' for @{key}: {error}", path.display()))?;
192        let Some(offset) = bibtex_entry_offset(&text, key) else {
193            return Err(format!("Entry @{key} is not in {}", path.display()));
194        };
195        if self.open_at(&path, offset, window, cx) {
196            Ok(())
197        } else {
198            Err(format!("'{}' could not be opened", path.display()))
199        }
200    }
201
202    /// Write a replacement back, guarded by the selection digest.
203    fn apply_replacement(
204        &self,
205        target: ReplacementTarget,
206        text: &str,
207        window: &mut Window,
208        cx: &mut App,
209    ) -> ReplacementOutcome;
210}
211
212/// Where a guarded replacement should land.
213#[derive(Clone, Debug, PartialEq, Eq)]
214pub struct ReplacementTarget {
215    /// The document identity captured with the selection.
216    pub document_id: u64,
217    /// The char range captured with the selection.
218    pub range: Range<usize>,
219    /// The digest of the marked passage at capture time.
220    pub digest: String,
221}
222
223/// The result of trying to write an agent replacement into an editor.
224#[derive(Clone, Debug, PartialEq, Eq)]
225pub enum ReplacementOutcome {
226    /// The passage still matched its digest and was replaced.
227    Applied {
228        /// A transcript-ready summary.
229        message: String,
230    },
231    /// The passage changed (or the document vanished); offer the text instead.
232    Offered {
233        /// The replacement text.
234        text: String,
235        /// Why it was not applied.
236        reason: String,
237    },
238    /// There was nothing to write.
239    Empty,
240}
241
242/// A captured selection together with the document it belongs to.
243#[derive(Clone, Debug, PartialEq, Eq)]
244pub struct SelectionContext {
245    /// The document identity, used as the write-back target.
246    pub document_id: u64,
247    /// The protocol container for the passage.
248    pub context: RoundtripContext,
249}
250
251/// The roundtrip façade the agent holds.
252///
253/// The host implements [`RoundtripHost`] once; this type builds document and
254/// selection contexts and applies replacements without knowing the editor.
255#[derive(Clone)]
256pub struct Roundtrip {
257    host: Rc<dyn RoundtripHost>,
258}
259
260impl Roundtrip {
261    /// Wrap a host implementation.
262    pub fn new(host: Rc<dyn RoundtripHost>) -> Self {
263        Self { host }
264    }
265
266    /// The active document, if any.
267    #[must_use]
268    pub fn active_document(&self, cx: &App) -> Option<EditorSnapshot> {
269        self.host.active_document(cx)
270    }
271
272    /// The active document as a prompt container.
273    #[must_use]
274    pub fn document_context(&self, cx: &App, limit: usize) -> Option<RoundtripContext> {
275        let document = self.host.active_document(cx)?;
276        RoundtripContext::document_with_limit(document.buffer(), limit)
277    }
278
279    /// The active selection as a prompt container.
280    #[must_use]
281    pub fn selection_context(
282        &self,
283        window: &mut Window,
284        cx: &mut App,
285        limit: usize,
286    ) -> Option<SelectionContext> {
287        let selection = self.host.selection(window, cx)?;
288        let context = RoundtripContext::selection_with_limit(
289            selection.document.buffer(),
290            selection.range,
291            limit,
292        )?;
293        Some(SelectionContext {
294            document_id: selection.document.id,
295            context,
296        })
297    }
298
299    /// Apply a guarded replacement through the host.
300    pub fn apply(
301        &self,
302        target: ReplacementTarget,
303        text: &str,
304        window: &mut Window,
305        cx: &mut App,
306    ) -> ReplacementOutcome {
307        self.host.apply_replacement(target, text, window, cx)
308    }
309
310    /// Insert text at the active selection or caret.
311    ///
312    /// Returns `false` when no document is open.
313    pub fn insert(&self, text: &str, window: &mut Window, cx: &mut App) -> bool {
314        self.host.insert(text, window, cx)
315    }
316
317    /// Open the host's bibliography at the entry for `key`.
318    pub fn edit_reference(
319        &self,
320        key: &str,
321        window: &mut Window,
322        cx: &mut App,
323    ) -> Result<(), String> {
324        self.host.edit_reference(key, window, cx)
325    }
326
327    /// Open `path` (or focus it) and reveal char `offset`.
328    pub fn open_at(&self, path: &Path, offset: usize, window: &mut Window, cx: &mut App) -> bool {
329        self.host.open_at(path, offset, window, cx)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use std::cell::RefCell;
337
338    struct MockEditor {
339        content: RefCell<String>,
340        caret: RefCell<usize>,
341        selection: RefCell<Option<Range<usize>>>,
342    }
343
344    impl MockEditor {
345        fn new(content: &str, caret: usize) -> Self {
346            Self {
347                content: RefCell::new(content.to_owned()),
348                caret: RefCell::new(caret),
349                selection: RefCell::new(None),
350            }
351        }
352
353        fn selected(mut self, range: Range<usize>) -> Self {
354            self.selection = RefCell::new(Some(range));
355            self
356        }
357
358        fn value(&self) -> String {
359            self.content.borrow().clone()
360        }
361    }
362
363    impl EditorSurface for MockEditor {
364        fn id(&self) -> u64 {
365            1
366        }
367
368        fn name(&self) -> SharedString {
369            "mock.md".into()
370        }
371
372        fn path(&self) -> Option<&Path> {
373            None
374        }
375
376        fn text(&self, _: &App) -> SharedString {
377            self.content.borrow().clone().into()
378        }
379
380        fn cursor(&self, _: &App) -> (usize, usize) {
381            let content = self.content.borrow();
382            let caret = (*self.caret.borrow()).min(content.chars().count());
383            let before: String = content.chars().take(caret).collect();
384            (
385                before.matches('\n').count(),
386                before.rsplit('\n').next().unwrap_or("").chars().count(),
387            )
388        }
389
390        fn selection(&self, _: &mut Window, _: &mut App) -> Option<Range<usize>> {
391            self.selection.borrow().clone()
392        }
393
394        fn replace(&self, range: Range<usize>, text: &str, _: &mut Window, _: &mut App) {
395            let content = self.content.borrow().clone();
396            let mut next = content[..range.start].to_owned();
397            next.push_str(text);
398            next.push_str(&content[range.end..]);
399            *self.caret.borrow_mut() =
400                content[..range.start].chars().count() + text.chars().count();
401            *self.content.borrow_mut() = next;
402            *self.selection.borrow_mut() = None;
403        }
404
405        fn focus(&self, _: &mut Window, _: &mut App) {}
406
407        fn reveal(&self, offset: usize, window: &mut Window, cx: &mut App) {
408            *self.caret.borrow_mut() = offset;
409            *self.selection.borrow_mut() = None;
410            self.focus(window, cx);
411        }
412    }
413
414    struct MockHost {
415        bibliography: Option<PathBuf>,
416        opened: RefCell<Vec<(PathBuf, usize)>>,
417        can_open: bool,
418    }
419
420    impl MockHost {
421        fn with_bibliography(path: PathBuf) -> Self {
422            Self {
423                bibliography: Some(path),
424                opened: RefCell::new(Vec::new()),
425                can_open: true,
426            }
427        }
428    }
429
430    impl RoundtripHost for MockHost {
431        fn active_document(&self, _: &App) -> Option<EditorSnapshot> {
432            None
433        }
434
435        fn selection(&self, _: &mut Window, _: &mut App) -> Option<SelectionSnapshot> {
436            None
437        }
438
439        fn bibliography(&self, _: &App) -> Option<PathBuf> {
440            self.bibliography.clone()
441        }
442
443        fn open_at(&self, path: &Path, offset: usize, _: &mut Window, _: &mut App) -> bool {
444            self.opened.borrow_mut().push((path.to_path_buf(), offset));
445            self.can_open
446        }
447
448        fn apply_replacement(
449            &self,
450            _: ReplacementTarget,
451            _: &str,
452            _: &mut Window,
453            _: &mut App,
454        ) -> ReplacementOutcome {
455            ReplacementOutcome::Empty
456        }
457    }
458
459    fn temp_bibliography(label: &str) -> (PathBuf, PathBuf) {
460        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
461        let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
462        let dir = std::env::temp_dir().join(format!(
463            "ragrig-gpui-host-{label}-{}-{counter}",
464            std::process::id()
465        ));
466        std::fs::create_dir_all(&dir).expect("temp dir");
467        let path = dir.join("references.bib");
468        std::fs::write(
469            &path,
470            "@article{Kano2025,\n  title = {Gaze},\n}\n\n@book{Mila2023,\n  title = {Sclera},\n}\n",
471        )
472        .expect("bib");
473        (dir, path)
474    }
475
476    struct Root;
477
478    impl gpui_kit::Render for Root {
479        fn render(
480            &mut self,
481            _: &mut Window,
482            _: &mut gpui_kit::Context<Self>,
483        ) -> impl gpui_kit::IntoElement {
484            gpui_kit::div()
485        }
486    }
487
488    #[gpui_kit::test]
489    fn the_default_insert_lands_at_the_caret(cx: &mut gpui_kit::TestAppContext) {
490        let (_root, cx) = cx.add_window_view(|_, _| Root);
491        let editor = MockEditor::new("hello world", 5);
492        cx.update(|window, cx| editor.insert_text(" brave", window, cx));
493        assert_eq!(editor.value(), "hello brave world");
494    }
495
496    #[gpui_kit::test]
497    fn the_default_insert_replaces_the_selection(cx: &mut gpui_kit::TestAppContext) {
498        let (_root, cx) = cx.add_window_view(|_, _| Root);
499        let editor = MockEditor::new("hello world", 0).selected(0..5);
500        cx.update(|window, cx| editor.insert_text("hi", window, cx));
501        assert_eq!(editor.value(), "hi world");
502    }
503
504    #[gpui_kit::test]
505    fn the_default_edit_reference_opens_the_entry(cx: &mut gpui_kit::TestAppContext) {
506        let (dir, path) = temp_bibliography("edit");
507        let host = MockHost::with_bibliography(path.clone());
508        let (_root, cx) = cx.add_window_view(|_, _| Root);
509
510        let result = cx.update(|window, cx| {
511            host.edit_reference("Mila2023", window, cx)
512                .expect("entry is opened");
513            host.opened.borrow().clone()
514        });
515        let text = std::fs::read_to_string(&path).expect("bib");
516        assert_eq!(
517            result,
518            vec![(
519                path,
520                bibtex_entry_offset(&text, "Mila2023").expect("offset")
521            )]
522        );
523
524        let _ = std::fs::remove_dir_all(dir);
525    }
526
527    #[gpui_kit::test]
528    fn the_default_edit_reference_reports_a_missing_key(cx: &mut gpui_kit::TestAppContext) {
529        let (dir, path) = temp_bibliography("missing-key");
530        let host = MockHost::with_bibliography(path);
531        let (_root, cx) = cx.add_window_view(|_, _| Root);
532
533        let error = cx.update(|window, cx| {
534            host.edit_reference("Nope", window, cx)
535                .expect_err("missing key")
536        });
537        assert!(error.contains("@Nope"), "unexpected error: {error}");
538        assert!(host.opened.borrow().is_empty());
539
540        let _ = std::fs::remove_dir_all(dir);
541    }
542
543    #[gpui_kit::test]
544    fn the_default_edit_reference_requires_a_bibliography(cx: &mut gpui_kit::TestAppContext) {
545        let host = MockHost {
546            bibliography: None,
547            opened: RefCell::new(Vec::new()),
548            can_open: true,
549        };
550        let (_root, cx) = cx.add_window_view(|_, _| Root);
551
552        let error = cx.update(|window, cx| {
553            host.edit_reference("Kano2025", window, cx)
554                .expect_err("no bibliography")
555        });
556        assert_eq!(error, "No bibliography configured");
557        assert!(host.opened.borrow().is_empty());
558    }
559
560    #[test]
561    fn line_columns_convert_to_char_indices() {
562        assert_eq!(char_index_for_line_column("abc", (0, 0)), 0);
563        assert_eq!(char_index_for_line_column("abc", (0, 3)), 3);
564        assert_eq!(char_index_for_line_column("ab\ncd", (1, 1)), 4);
565        assert_eq!(char_index_for_line_column("ab\ncd", (1, 99)), 5);
566        assert_eq!(char_index_for_line_column("ab\ncd", (9, 0)), 5);
567    }
568}