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(®ion).unwrap();
237 let decoded: InteractiveRegion = serde_json::from_value(encoded).unwrap();
238
239 assert_eq!(decoded, region);
240 }
241}