Skip to main content

roder_api/
interactive.rs

1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5use crate::events::{ThreadId, TurnId};
6use crate::extension::ExtensionId;
7
8pub type RegionId = String;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct InteractiveRegion {
12    pub id: RegionId,
13    pub rect: RegionRect,
14    pub z: i16,
15    pub kind: RegionKind,
16    pub hover_cursor: HoverCursor,
17    pub keyboard_binding: Option<KeyChord>,
18}
19
20#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
21pub struct RegionRect {
22    pub x: u16,
23    pub y: u16,
24    pub width: u16,
25    pub height: u16,
26}
27
28impl RegionRect {
29    pub fn contains(self, x: u16, y: u16) -> bool {
30        x >= self.x
31            && y >= self.y
32            && x < self.x.saturating_add(self.width)
33            && y < self.y.saturating_add(self.height)
34    }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub enum RegionKind {
39    TranscriptMessage {
40        thread_id: ThreadId,
41        turn_id: TurnId,
42        message_idx: usize,
43    },
44    ToolCallBlock {
45        call_id: String,
46        expanded: bool,
47    },
48    FileReference {
49        path: PathBuf,
50        line: Option<u32>,
51    },
52    Url(String),
53    AttachmentThumbnail {
54        attachment_id: String,
55    },
56    StatusSegment {
57        segment_id: String,
58    },
59    PaletteItem {
60        source_id: String,
61        item_id: String,
62    },
63    DiffHunk {
64        call_id: String,
65        file_path: PathBuf,
66        hunk_idx: usize,
67    },
68    PolicyApprovalButton {
69        decision_id: String,
70        vote: ApprovalVote,
71    },
72    Composer,
73    Custom {
74        extension_id: ExtensionId,
75        payload: serde_json::Value,
76    },
77}
78
79impl RegionKind {
80    pub fn kind_name(&self) -> &'static str {
81        match self {
82            Self::TranscriptMessage { .. } => "TranscriptMessage",
83            Self::ToolCallBlock { .. } => "ToolCallBlock",
84            Self::FileReference { .. } => "FileReference",
85            Self::Url(_) => "Url",
86            Self::AttachmentThumbnail { .. } => "AttachmentThumbnail",
87            Self::StatusSegment { .. } => "StatusSegment",
88            Self::PaletteItem { .. } => "PaletteItem",
89            Self::DiffHunk { .. } => "DiffHunk",
90            Self::PolicyApprovalButton { .. } => "PolicyApprovalButton",
91            Self::Composer => "Composer",
92            Self::Custom { .. } => "Custom",
93        }
94    }
95}
96
97#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
98pub enum ApprovalVote {
99    Approve,
100    Deny,
101}
102
103#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
104pub enum HoverCursor {
105    Default,
106    Pointer,
107    Text,
108    Grab,
109    Crosshair,
110    NotAllowed,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct KeyChord {
115    pub key: String,
116    #[serde(default)]
117    pub modifiers: InteractiveModifiers,
118}
119
120#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
121pub struct InteractiveModifiers {
122    pub shift: bool,
123    pub control: bool,
124    pub alt: bool,
125    pub super_key: bool,
126}
127
128#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
129pub enum InteractiveMouseButton {
130    Left,
131    Right,
132    Middle,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136pub enum InteractiveEvent {
137    HoverEnter {
138        region: RegionId,
139    },
140    HoverLeave {
141        region: RegionId,
142    },
143    Click {
144        region: RegionId,
145        modifiers: InteractiveModifiers,
146        button: InteractiveMouseButton,
147    },
148    DoubleClick {
149        region: RegionId,
150        modifiers: InteractiveModifiers,
151    },
152    RightClick {
153        region: RegionId,
154        modifiers: InteractiveModifiers,
155    },
156    DragStart {
157        region: RegionId,
158        anchor: (u16, u16),
159    },
160    DragUpdate {
161        region: RegionId,
162        cursor: (u16, u16),
163    },
164    DragEnd {
165        region: RegionId,
166        cursor: (u16, u16),
167    },
168    Scroll {
169        region: Option<RegionId>,
170        delta_lines: i16,
171        modifiers: InteractiveModifiers,
172    },
173}
174
175#[async_trait::async_trait]
176pub trait InteractiveRegionHandler: Send + Sync + 'static {
177    fn id(&self) -> String;
178
179    fn kinds(&self) -> &[&'static str];
180
181    async fn handle(
182        &self,
183        event: InteractiveEvent,
184        region: &InteractiveRegion,
185    ) -> anyhow::Result<HandlerOutcome>;
186}
187
188#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
189pub enum HandlerOutcome {
190    Consumed,
191    Passthrough,
192    InvalidateRender,
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn region_rect_contains_inside_edges_only() {
201        let rect = RegionRect {
202            x: 2,
203            y: 3,
204            width: 4,
205            height: 2,
206        };
207
208        assert!(rect.contains(2, 3));
209        assert!(rect.contains(5, 4));
210        assert!(!rect.contains(6, 4));
211        assert!(!rect.contains(5, 5));
212    }
213
214    #[test]
215    fn interactive_region_round_trips_json() {
216        let region = InteractiveRegion {
217            id: "region-1".to_string(),
218            rect: RegionRect {
219                x: 0,
220                y: 1,
221                width: 10,
222                height: 2,
223            },
224            z: 3,
225            kind: RegionKind::ToolCallBlock {
226                call_id: "call-1".to_string(),
227                expanded: false,
228            },
229            hover_cursor: HoverCursor::Pointer,
230            keyboard_binding: Some(KeyChord {
231                key: "enter".to_string(),
232                modifiers: InteractiveModifiers::default(),
233            }),
234        };
235
236        let encoded = serde_json::to_value(&region).unwrap();
237        let decoded: InteractiveRegion = serde_json::from_value(encoded).unwrap();
238
239        assert_eq!(decoded, region);
240    }
241}