Skip to main content

scarab_protocol/
zones.rs

1//! Semantic zones for deep shell integration
2//!
3//! This module defines semantic zones that represent different regions of the
4//! terminal output based on OSC 133 shell integration markers:
5//! - Prompt zones: The shell prompt area
6//! - Input zones: User command input
7//! - Output zones: Command output
8//!
9//! These zones enable:
10//! - Zone-aware text selection (e.g., select only command output)
11//! - Command duration tracking
12//! - Exit code display
13//! - Output extraction for copy/paste
14
15extern crate alloc;
16use alloc::string::String;
17use alloc::vec::Vec;
18
19/// Type of semantic zone in the terminal
20#[derive(Debug, Clone, Copy, PartialEq, Eq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
21#[archive(check_bytes)]
22pub enum ZoneType {
23    /// Shell prompt area (between OSC 133;A and 133;B)
24    Prompt,
25    /// User command input (between OSC 133;B and 133;C)
26    Input,
27    /// Command output (between OSC 133;C and 133;D)
28    Output,
29}
30
31/// A semantic zone representing a region between shell integration markers
32///
33/// Zones are created by correlating OSC 133 markers:
34/// - Prompt: From A marker to B marker
35/// - Input: From B marker to C marker
36/// - Output: From C marker to D marker
37#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
38#[archive(check_bytes)]
39pub struct SemanticZone {
40    /// Unique identifier for this zone
41    pub id: u64,
42    /// Type of zone
43    pub zone_type: ZoneType,
44    /// Starting line number (absolute, including scrollback)
45    pub start_row: u32,
46    /// Ending line number (absolute, including scrollback)
47    /// For incomplete zones (e.g., still outputting), this may equal start_row
48    pub end_row: u32,
49    /// Command text extracted from the input zone (if available)
50    pub command: Option<String>,
51    /// Exit code from OSC 133;D marker (only for completed output zones)
52    pub exit_code: Option<i32>,
53    /// Timestamp when the zone started (microseconds since UNIX epoch)
54    pub started_at: u64,
55    /// Duration in microseconds (only for completed zones)
56    pub duration_micros: Option<u64>,
57    /// Whether this zone is complete (has an end marker)
58    pub is_complete: bool,
59}
60
61impl SemanticZone {
62    /// Create a new prompt zone
63    pub fn new_prompt(id: u64, start_row: u32, timestamp: u64) -> Self {
64        Self {
65            id,
66            zone_type: ZoneType::Prompt,
67            start_row,
68            end_row: start_row,
69            command: None,
70            exit_code: None,
71            started_at: timestamp,
72            duration_micros: None,
73            is_complete: false,
74        }
75    }
76
77    /// Create a new input zone
78    pub fn new_input(id: u64, start_row: u32, timestamp: u64) -> Self {
79        Self {
80            id,
81            zone_type: ZoneType::Input,
82            start_row,
83            end_row: start_row,
84            command: None,
85            exit_code: None,
86            started_at: timestamp,
87            duration_micros: None,
88            is_complete: false,
89        }
90    }
91
92    /// Create a new output zone
93    pub fn new_output(id: u64, start_row: u32, timestamp: u64) -> Self {
94        Self {
95            id,
96            zone_type: ZoneType::Output,
97            start_row,
98            end_row: start_row,
99            command: None,
100            exit_code: None,
101            started_at: timestamp,
102            duration_micros: None,
103            is_complete: false,
104        }
105    }
106
107    /// Mark this zone as complete with an ending row and timestamp
108    pub fn complete(&mut self, end_row: u32, end_timestamp: u64) {
109        self.end_row = end_row;
110        self.is_complete = true;
111        if end_timestamp >= self.started_at {
112            self.duration_micros = Some(end_timestamp - self.started_at);
113        }
114    }
115
116    /// Set the command text for this zone (typically for input zones)
117    pub fn set_command(&mut self, command: String) {
118        self.command = Some(command);
119    }
120
121    /// Set the exit code (only for output zones)
122    pub fn set_exit_code(&mut self, exit_code: i32) {
123        self.exit_code = Some(exit_code);
124    }
125
126    /// Check if this zone contains the given line number
127    pub fn contains_line(&self, line: u32) -> bool {
128        line >= self.start_row && line <= self.end_row
129    }
130
131    /// Get the number of lines in this zone
132    pub fn line_count(&self) -> u32 {
133        if self.end_row >= self.start_row {
134            self.end_row - self.start_row + 1
135        } else {
136            1
137        }
138    }
139
140    /// Check if this zone represents a successful command (exit code 0)
141    pub fn is_success(&self) -> bool {
142        self.exit_code == Some(0)
143    }
144
145    /// Check if this zone represents a failed command (non-zero exit code)
146    pub fn is_failure(&self) -> bool {
147        matches!(self.exit_code, Some(code) if code != 0)
148    }
149
150    /// Get duration in milliseconds for display
151    pub fn duration_millis(&self) -> Option<u64> {
152        self.duration_micros.map(|micros| micros / 1000)
153    }
154
155    /// Get duration in seconds for display
156    pub fn duration_secs(&self) -> Option<f64> {
157        self.duration_micros
158            .map(|micros| micros as f64 / 1_000_000.0)
159    }
160}
161
162/// A command block represents a complete prompt-input-output sequence
163///
164/// This is a higher-level abstraction that groups related zones together
165/// for easier reasoning about commands and their results.
166#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
167#[archive(check_bytes)]
168pub struct CommandBlock {
169    /// Unique identifier
170    pub id: u64,
171    /// Prompt zone
172    pub prompt_zone: Option<SemanticZone>,
173    /// Input zone
174    pub input_zone: Option<SemanticZone>,
175    /// Output zone
176    pub output_zone: Option<SemanticZone>,
177    /// Starting line of the entire block
178    pub start_row: u32,
179    /// Ending line of the entire block
180    pub end_row: u32,
181    /// Timestamp when the command started
182    pub started_at: u64,
183    /// Total duration from prompt to command completion
184    pub duration_micros: Option<u64>,
185}
186
187impl CommandBlock {
188    /// Create a new command block starting with a prompt zone
189    pub fn new(id: u64, prompt_zone: SemanticZone) -> Self {
190        Self {
191            id,
192            start_row: prompt_zone.start_row,
193            end_row: prompt_zone.end_row,
194            started_at: prompt_zone.started_at,
195            prompt_zone: Some(prompt_zone),
196            input_zone: None,
197            output_zone: None,
198            duration_micros: None,
199        }
200    }
201
202    /// Add an input zone to this command block
203    pub fn add_input_zone(&mut self, zone: SemanticZone) {
204        self.end_row = zone.end_row.max(self.end_row);
205        self.input_zone = Some(zone);
206    }
207
208    /// Add an output zone to this command block
209    pub fn add_output_zone(&mut self, zone: SemanticZone) {
210        self.end_row = zone.end_row.max(self.end_row);
211
212        // Calculate total duration if output zone is complete
213        if zone.is_complete {
214            let end_timestamp = zone.started_at + zone.duration_micros.unwrap_or(0);
215            if end_timestamp >= self.started_at {
216                self.duration_micros = Some(end_timestamp - self.started_at);
217            }
218        }
219
220        self.output_zone = Some(zone);
221    }
222
223    /// Get the command text from the input zone
224    pub fn command_text(&self) -> Option<&str> {
225        self.input_zone.as_ref()?.command.as_deref()
226    }
227
228    /// Get the exit code from the output zone
229    pub fn exit_code(&self) -> Option<i32> {
230        self.output_zone.as_ref()?.exit_code
231    }
232
233    /// Check if this command block is complete (has all zones)
234    pub fn is_complete(&self) -> bool {
235        self.output_zone.as_ref().map_or(false, |z| z.is_complete)
236    }
237
238    /// Check if this command was successful
239    pub fn is_success(&self) -> bool {
240        self.exit_code() == Some(0)
241    }
242
243    /// Check if this command failed
244    pub fn is_failure(&self) -> bool {
245        matches!(self.exit_code(), Some(code) if code != 0)
246    }
247
248    /// Get the output zone bounds for text extraction
249    pub fn output_bounds(&self) -> Option<(u32, u32)> {
250        self.output_zone.as_ref().map(|z| (z.start_row, z.end_row))
251    }
252
253    /// Check if this block contains the given line
254    pub fn contains_line(&self, line: u32) -> bool {
255        line >= self.start_row && line <= self.end_row
256    }
257
258    /// Get duration in seconds for display
259    pub fn duration_secs(&self) -> Option<f64> {
260        self.duration_micros
261            .map(|micros| micros as f64 / 1_000_000.0)
262    }
263}
264
265/// Zone tracking state for managing semantic zones
266///
267/// This is not directly serialized for IPC, but used internally by the daemon
268/// to track zones and generate SemanticZone messages for the client.
269#[derive(Debug, Clone)]
270pub struct ZoneTracker {
271    /// Next zone ID to assign
272    next_zone_id: u64,
273    /// Current incomplete zones being tracked
274    current_zones: Vec<SemanticZone>,
275    /// Completed command blocks (limited to recent history)
276    command_blocks: Vec<CommandBlock>,
277    /// Maximum command blocks to retain
278    max_blocks: usize,
279    /// Current command block being built
280    current_block: Option<CommandBlock>,
281}
282
283impl ZoneTracker {
284    /// Create a new zone tracker
285    pub fn new(max_blocks: usize) -> Self {
286        Self {
287            next_zone_id: 1,
288            current_zones: Vec::new(),
289            command_blocks: Vec::new(),
290            max_blocks,
291            current_block: None,
292        }
293    }
294
295    /// Allocate a new zone ID
296    fn next_id(&mut self) -> u64 {
297        let id = self.next_zone_id;
298        self.next_zone_id = self.next_zone_id.wrapping_add(1);
299        id
300    }
301
302    /// Handle OSC 133;A - Prompt start
303    pub fn mark_prompt_start(&mut self, line: u32, timestamp: u64) {
304        let id = self.next_id();
305        let zone = SemanticZone::new_prompt(id, line, timestamp);
306
307        // Start a new command block
308        self.current_block = Some(CommandBlock::new(id, zone.clone()));
309        self.current_zones.push(zone);
310    }
311
312    /// Handle OSC 133;B - Command/input start
313    pub fn mark_command_start(&mut self, line: u32, timestamp: u64) {
314        // Complete the previous prompt zone if any
315        if let Some(zone) = self
316            .current_zones
317            .iter_mut()
318            .rev()
319            .find(|z| z.zone_type == ZoneType::Prompt && !z.is_complete)
320        {
321            zone.complete(line.saturating_sub(1), timestamp);
322        }
323
324        // Create new input zone
325        let id = self.next_id();
326        let zone = SemanticZone::new_input(id, line, timestamp);
327
328        // Add to current block
329        if let Some(ref mut block) = self.current_block {
330            block.add_input_zone(zone.clone());
331        }
332
333        self.current_zones.push(zone);
334    }
335
336    /// Handle OSC 133;C - Command executed, output begins
337    pub fn mark_command_executed(&mut self, line: u32, timestamp: u64) {
338        // Complete the previous input zone if any
339        if let Some(zone) = self
340            .current_zones
341            .iter_mut()
342            .rev()
343            .find(|z| z.zone_type == ZoneType::Input && !z.is_complete)
344        {
345            zone.complete(line.saturating_sub(1), timestamp);
346        }
347
348        // Create new output zone
349        let id = self.next_id();
350        let zone = SemanticZone::new_output(id, line, timestamp);
351
352        // Add to current block
353        if let Some(ref mut block) = self.current_block {
354            block.add_output_zone(zone.clone());
355        }
356
357        self.current_zones.push(zone);
358    }
359
360    /// Handle OSC 133;D - Command finished
361    pub fn mark_command_finished(&mut self, line: u32, exit_code: i32, timestamp: u64) {
362        // Complete the previous output zone if any
363        if let Some(zone) = self
364            .current_zones
365            .iter_mut()
366            .rev()
367            .find(|z| z.zone_type == ZoneType::Output && !z.is_complete)
368        {
369            zone.complete(line, timestamp);
370            zone.set_exit_code(exit_code);
371
372            // Update the current block's output zone
373            if let Some(ref mut block) = self.current_block {
374                block.add_output_zone(zone.clone());
375
376                // Move completed block to history
377                self.command_blocks.push(block.clone());
378
379                // Trim old blocks
380                if self.command_blocks.len() > self.max_blocks {
381                    self.command_blocks.remove(0);
382                }
383            }
384        }
385
386        // Clear current block
387        self.current_block = None;
388    }
389
390    /// Set command text for the most recent input zone
391    pub fn set_command_text(&mut self, command: String) {
392        if let Some(zone) = self
393            .current_zones
394            .iter_mut()
395            .rev()
396            .find(|z| z.zone_type == ZoneType::Input)
397        {
398            zone.set_command(command.clone());
399        }
400
401        // Also update the current block
402        if let Some(ref mut block) = self.current_block {
403            if let Some(ref mut input_zone) = block.input_zone {
404                input_zone.set_command(command);
405            }
406        }
407    }
408
409    /// Get all tracked zones
410    pub fn zones(&self) -> &[SemanticZone] {
411        &self.current_zones
412    }
413
414    /// Get all completed command blocks
415    pub fn command_blocks(&self) -> &[CommandBlock] {
416        &self.command_blocks
417    }
418
419    /// Get the current incomplete command block
420    pub fn current_block(&self) -> Option<&CommandBlock> {
421        self.current_block.as_ref()
422    }
423
424    /// Find the command block containing the given line
425    pub fn find_block_at_line(&self, line: u32) -> Option<&CommandBlock> {
426        self.command_blocks
427            .iter()
428            .rev()
429            .find(|block| block.contains_line(line))
430    }
431
432    /// Find the zone containing the given line
433    pub fn find_zone_at_line(&self, line: u32) -> Option<&SemanticZone> {
434        self.current_zones
435            .iter()
436            .rev()
437            .find(|zone| zone.contains_line(line))
438    }
439
440    /// Get the last output zone for "copy last output" functionality
441    pub fn last_output_zone(&self) -> Option<&SemanticZone> {
442        self.command_blocks
443            .iter()
444            .rev()
445            .find_map(|block| block.output_zone.as_ref())
446    }
447
448    /// Clear all zones (useful for reset operations)
449    pub fn clear(&mut self) {
450        self.current_zones.clear();
451        self.command_blocks.clear();
452        self.current_block = None;
453    }
454
455    /// Update zone line numbers after scrolling
456    ///
457    /// When the terminal scrolls, line numbers in scrollback increase.
458    /// This method adjusts zone line numbers accordingly.
459    pub fn adjust_for_scroll(&mut self, lines_scrolled: i32) {
460        if lines_scrolled == 0 {
461            return;
462        }
463
464        let adjust = |row: u32, delta: i32| -> u32 {
465            if delta < 0 {
466                row.saturating_sub(delta.abs() as u32)
467            } else {
468                row.saturating_add(delta as u32)
469            }
470        };
471
472        // Adjust current zones
473        for zone in &mut self.current_zones {
474            zone.start_row = adjust(zone.start_row, lines_scrolled);
475            zone.end_row = adjust(zone.end_row, lines_scrolled);
476        }
477
478        // Adjust command blocks
479        for block in &mut self.command_blocks {
480            block.start_row = adjust(block.start_row, lines_scrolled);
481            block.end_row = adjust(block.end_row, lines_scrolled);
482
483            if let Some(ref mut zone) = block.prompt_zone {
484                zone.start_row = adjust(zone.start_row, lines_scrolled);
485                zone.end_row = adjust(zone.end_row, lines_scrolled);
486            }
487            if let Some(ref mut zone) = block.input_zone {
488                zone.start_row = adjust(zone.start_row, lines_scrolled);
489                zone.end_row = adjust(zone.end_row, lines_scrolled);
490            }
491            if let Some(ref mut zone) = block.output_zone {
492                zone.start_row = adjust(zone.start_row, lines_scrolled);
493                zone.end_row = adjust(zone.end_row, lines_scrolled);
494            }
495        }
496
497        // Adjust current block
498        if let Some(ref mut block) = self.current_block {
499            block.start_row = adjust(block.start_row, lines_scrolled);
500            block.end_row = adjust(block.end_row, lines_scrolled);
501
502            if let Some(ref mut zone) = block.prompt_zone {
503                zone.start_row = adjust(zone.start_row, lines_scrolled);
504                zone.end_row = adjust(zone.end_row, lines_scrolled);
505            }
506            if let Some(ref mut zone) = block.input_zone {
507                zone.start_row = adjust(zone.start_row, lines_scrolled);
508                zone.end_row = adjust(zone.end_row, lines_scrolled);
509            }
510            if let Some(ref mut zone) = block.output_zone {
511                zone.start_row = adjust(zone.start_row, lines_scrolled);
512                zone.end_row = adjust(zone.end_row, lines_scrolled);
513            }
514        }
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use alloc::string::ToString;
522
523    #[test]
524    fn test_semantic_zone_creation() {
525        let zone = SemanticZone::new_prompt(1, 10, 1000);
526        assert_eq!(zone.id, 1);
527        assert_eq!(zone.zone_type, ZoneType::Prompt);
528        assert_eq!(zone.start_row, 10);
529        assert!(!zone.is_complete);
530    }
531
532    #[test]
533    fn test_zone_completion() {
534        let mut zone = SemanticZone::new_input(1, 10, 1000);
535        zone.complete(15, 2000);
536
537        assert!(zone.is_complete);
538        assert_eq!(zone.end_row, 15);
539        assert_eq!(zone.duration_micros, Some(1000));
540        assert_eq!(zone.line_count(), 6);
541    }
542
543    #[test]
544    fn test_zone_contains_line() {
545        let mut zone = SemanticZone::new_output(1, 10, 1000);
546        zone.complete(20, 2000);
547
548        assert!(!zone.contains_line(9));
549        assert!(zone.contains_line(10));
550        assert!(zone.contains_line(15));
551        assert!(zone.contains_line(20));
552        assert!(!zone.contains_line(21));
553    }
554
555    #[test]
556    fn test_zone_tracker_prompt_flow() {
557        let mut tracker = ZoneTracker::new(100);
558
559        // OSC 133;A - Prompt start
560        tracker.mark_prompt_start(0, 1000);
561        assert_eq!(tracker.zones().len(), 1);
562        assert!(tracker.current_block().is_some());
563
564        // OSC 133;B - Command start
565        tracker.mark_command_start(1, 2000);
566        assert_eq!(tracker.zones().len(), 2);
567
568        // Check prompt zone was completed
569        let prompt_zone = tracker
570            .zones()
571            .iter()
572            .find(|z| z.zone_type == ZoneType::Prompt)
573            .unwrap();
574        assert!(prompt_zone.is_complete);
575        assert_eq!(prompt_zone.end_row, 0);
576
577        // OSC 133;C - Command executed
578        tracker.mark_command_executed(2, 3000);
579        assert_eq!(tracker.zones().len(), 3);
580
581        // Check input zone was completed
582        let input_zone = tracker
583            .zones()
584            .iter()
585            .find(|z| z.zone_type == ZoneType::Input)
586            .unwrap();
587        assert!(input_zone.is_complete);
588        assert_eq!(input_zone.end_row, 1);
589
590        // OSC 133;D - Command finished
591        tracker.mark_command_finished(10, 0, 4000);
592
593        // Check output zone was completed
594        let output_zone = tracker
595            .zones()
596            .iter()
597            .find(|z| z.zone_type == ZoneType::Output)
598            .unwrap();
599        assert!(output_zone.is_complete);
600        assert_eq!(output_zone.end_row, 10);
601        assert_eq!(output_zone.exit_code, Some(0));
602
603        // Check command block was created
604        assert_eq!(tracker.command_blocks().len(), 1);
605        let block = &tracker.command_blocks()[0];
606        assert!(block.is_complete());
607        assert!(block.is_success());
608        assert_eq!(block.duration_secs(), Some(0.003));
609    }
610
611    #[test]
612    fn test_command_block_with_failure() {
613        let mut tracker = ZoneTracker::new(100);
614
615        tracker.mark_prompt_start(0, 1000);
616        tracker.mark_command_start(1, 2000);
617        tracker.set_command_text("false".to_string());
618        tracker.mark_command_executed(2, 3000);
619        tracker.mark_command_finished(3, 1, 4000);
620
621        let block = &tracker.command_blocks()[0];
622        assert!(block.is_failure());
623        assert_eq!(block.exit_code(), Some(1));
624        assert_eq!(block.command_text(), Some("false"));
625    }
626
627    #[test]
628    fn test_zone_tracker_max_blocks() {
629        let mut tracker = ZoneTracker::new(3);
630
631        // Create 5 command blocks
632        for i in 0..5u64 {
633            let base_line = (i * 10) as u32;
634            let base_time = i * 10000;
635
636            tracker.mark_prompt_start(base_line, base_time);
637            tracker.mark_command_start(base_line + 1, base_time + 1000);
638            tracker.mark_command_executed(base_line + 2, base_time + 2000);
639            tracker.mark_command_finished(base_line + 3, 0, base_time + 3000);
640        }
641
642        // Should only keep the last 3 blocks
643        assert_eq!(tracker.command_blocks().len(), 3);
644
645        // First block should be the 3rd one created (0-indexed 2nd)
646        assert_eq!(tracker.command_blocks()[0].start_row, 20);
647    }
648
649    #[test]
650    fn test_find_zone_at_line() {
651        let mut tracker = ZoneTracker::new(100);
652
653        tracker.mark_prompt_start(10, 1000);
654        tracker.mark_command_start(11, 2000);
655        tracker.mark_command_executed(12, 3000);
656        tracker.mark_command_finished(20, 0, 4000);
657
658        // Test finding zones
659        let zone = tracker.find_zone_at_line(15).unwrap();
660        assert_eq!(zone.zone_type, ZoneType::Output);
661
662        let block = tracker.find_block_at_line(15).unwrap();
663        assert_eq!(block.start_row, 10);
664    }
665
666    #[test]
667    fn test_last_output_zone() {
668        let mut tracker = ZoneTracker::new(100);
669
670        // Create two command blocks
671        tracker.mark_prompt_start(0, 1000);
672        tracker.mark_command_start(1, 2000);
673        tracker.mark_command_executed(2, 3000);
674        tracker.mark_command_finished(10, 0, 4000);
675
676        tracker.mark_prompt_start(11, 5000);
677        tracker.mark_command_start(12, 6000);
678        tracker.mark_command_executed(13, 7000);
679        tracker.mark_command_finished(20, 0, 8000);
680
681        // Should return the most recent output zone
682        let last_output = tracker.last_output_zone().unwrap();
683        assert_eq!(last_output.start_row, 13);
684        assert_eq!(last_output.end_row, 20);
685    }
686
687    #[test]
688    fn test_adjust_for_scroll() {
689        let mut tracker = ZoneTracker::new(100);
690
691        tracker.mark_prompt_start(10, 1000);
692        tracker.mark_command_start(11, 2000);
693        tracker.mark_command_executed(12, 3000);
694
695        // Scroll up by 5 lines (line numbers increase in scrollback)
696        tracker.adjust_for_scroll(5);
697
698        // Check that all zones were adjusted
699        let prompt = tracker
700            .zones()
701            .iter()
702            .find(|z| z.zone_type == ZoneType::Prompt)
703            .unwrap();
704        assert_eq!(prompt.start_row, 15);
705
706        let input = tracker
707            .zones()
708            .iter()
709            .find(|z| z.zone_type == ZoneType::Input)
710            .unwrap();
711        assert_eq!(input.start_row, 16);
712
713        let output = tracker
714            .zones()
715            .iter()
716            .find(|z| z.zone_type == ZoneType::Output)
717            .unwrap();
718        assert_eq!(output.start_row, 17);
719    }
720}