syncable_cli/agent/ui/
layout.rs

1//! Terminal layout with ANSI scrolling regions
2//!
3//! Provides a split terminal layout:
4//! - Scrollable content area (top) - for tool output, thinking, responses
5//! - Fixed status line - for progress indicator
6//! - Fixed input line - always visible prompt
7//!
8//! Uses ANSI escape codes for scroll regions, compatible with most terminals.
9
10use crossterm::{cursor::MoveTo, execute, terminal};
11use std::io::{self, Write};
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
14
15use super::colors::ansi;
16
17/// Number of lines reserved at bottom (status + focus + input + mode indicator)
18const RESERVED_LINES: u16 = 4;
19
20/// ANSI escape codes for scroll region control
21pub mod escape {
22    /// Set scroll region from line `top` to line `bottom` (1-indexed)
23    pub fn set_scroll_region(top: u16, bottom: u16) -> String {
24        format!("\x1b[{};{}r", top, bottom)
25    }
26
27    /// Reset scroll region to full screen
28    pub const RESET_SCROLL_REGION: &str = "\x1b[r";
29
30    /// Save cursor position
31    pub const SAVE_CURSOR: &str = "\x1b[s";
32
33    /// Restore cursor position
34    pub const RESTORE_CURSOR: &str = "\x1b[u";
35
36    /// Move cursor to line (1-indexed), column 1
37    pub fn move_to_line(line: u16) -> String {
38        format!("\x1b[{};1H", line)
39    }
40}
41
42/// Shared state for terminal layout
43#[derive(Debug)]
44pub struct LayoutState {
45    /// Whether layout is active
46    pub active: AtomicBool,
47    /// Terminal height when layout was set up
48    pub term_height: AtomicU16,
49    /// Terminal width
50    pub term_width: AtomicU16,
51}
52
53impl Default for LayoutState {
54    fn default() -> Self {
55        let (width, height) = terminal::size().unwrap_or((80, 24));
56        Self {
57            active: AtomicBool::new(false),
58            term_height: AtomicU16::new(height),
59            term_width: AtomicU16::new(width),
60        }
61    }
62}
63
64impl LayoutState {
65    pub fn new() -> Arc<Self> {
66        Arc::new(Self::default())
67    }
68
69    pub fn is_active(&self) -> bool {
70        self.active.load(Ordering::SeqCst)
71    }
72
73    pub fn height(&self) -> u16 {
74        self.term_height.load(Ordering::SeqCst)
75    }
76
77    pub fn width(&self) -> u16 {
78        self.term_width.load(Ordering::SeqCst)
79    }
80
81    /// Get the line number for status (1-indexed)
82    pub fn status_line(&self) -> u16 {
83        self.height().saturating_sub(3)
84    }
85
86    /// Get the line number for focus/detail (1-indexed)
87    pub fn focus_line(&self) -> u16 {
88        self.height().saturating_sub(2)
89    }
90
91    /// Get the line number for input (1-indexed)
92    pub fn input_line(&self) -> u16 {
93        self.height().saturating_sub(1)
94    }
95
96    /// Get the line number for mode indicator (1-indexed)
97    pub fn mode_line(&self) -> u16 {
98        self.height()
99    }
100}
101
102/// Terminal layout manager with scroll regions
103pub struct TerminalLayout {
104    state: Arc<LayoutState>,
105}
106
107impl TerminalLayout {
108    /// Create a new layout manager
109    pub fn new() -> Self {
110        Self {
111            state: LayoutState::new(),
112        }
113    }
114
115    /// Get shared state for external access
116    pub fn state(&self) -> Arc<LayoutState> {
117        self.state.clone()
118    }
119
120    /// Initialize the layout - sets up scroll region and fixed lines
121    pub fn init(&self) -> io::Result<()> {
122        let mut stdout = io::stdout();
123
124        // Get current terminal size
125        let (width, height) = terminal::size()?;
126        self.state.term_width.store(width, Ordering::SeqCst);
127        self.state.term_height.store(height, Ordering::SeqCst);
128
129        // Calculate scroll region (leave RESERVED_LINES at bottom)
130        let scroll_bottom = height.saturating_sub(RESERVED_LINES);
131
132        // Move to bottom and create space for reserved lines
133        execute!(stdout, MoveTo(0, height - 1))?;
134        for _ in 0..RESERVED_LINES {
135            println!();
136        }
137
138        // Set scroll region (top to scroll_bottom)
139        print!("{}", escape::set_scroll_region(1, scroll_bottom));
140
141        // Move cursor to top of scroll region
142        execute!(stdout, MoveTo(0, 0))?;
143
144        // Draw initial fixed lines (status, focus, input, mode)
145        self.draw_status_line("")?;
146        self.draw_focus_line(None)?;
147        self.draw_input_line(false)?;
148        self.draw_mode_line(false)?;
149
150        // Move back to scroll region
151        execute!(stdout, MoveTo(0, 0))?;
152
153        self.state.active.store(true, Ordering::SeqCst);
154        stdout.flush()?;
155
156        Ok(())
157    }
158
159    /// Update the status line (progress indicator area)
160    pub fn update_status(&self, content: &str) -> io::Result<()> {
161        if !self.state.is_active() {
162            return Ok(());
163        }
164
165        let mut stdout = io::stdout();
166        let status_line = self.state.status_line();
167
168        // Save cursor, move to status line, clear and print, restore
169        print!("{}", escape::SAVE_CURSOR);
170        print!("{}", escape::move_to_line(status_line));
171        print!("{}", ansi::CLEAR_LINE);
172        print!("{}", content);
173        print!("{}", escape::RESTORE_CURSOR);
174        stdout.flush()?;
175
176        Ok(())
177    }
178
179    /// Draw the status line with optional content
180    fn draw_status_line(&self, content: &str) -> io::Result<()> {
181        let mut stdout = io::stdout();
182        let status_line = self.state.status_line();
183
184        print!("{}", escape::move_to_line(status_line));
185        print!("{}", ansi::CLEAR_LINE);
186        if !content.is_empty() {
187            print!("{}", content);
188        }
189        stdout.flush()?;
190
191        Ok(())
192    }
193
194    /// Draw the focus/detail line
195    fn draw_focus_line(&self, content: Option<&str>) -> io::Result<()> {
196        let mut stdout = io::stdout();
197        let focus_line = self.state.focus_line();
198
199        print!("{}", escape::move_to_line(focus_line));
200        print!("{}", ansi::CLEAR_LINE);
201        if let Some(text) = content {
202            print!(
203                "{}└{} {}{}{}",
204                ansi::DIM,
205                ansi::RESET,
206                ansi::GRAY,
207                text,
208                ansi::RESET
209            );
210        }
211        stdout.flush()?;
212
213        Ok(())
214    }
215
216    /// Draw the input line
217    fn draw_input_line(&self, _has_text: bool) -> io::Result<()> {
218        let mut stdout = io::stdout();
219        let input_line = self.state.input_line();
220
221        print!("{}", escape::move_to_line(input_line));
222        print!("{}", ansi::CLEAR_LINE);
223        // Input prompt will be drawn by input handler
224        stdout.flush()?;
225
226        Ok(())
227    }
228
229    /// Draw the mode indicator line
230    fn draw_mode_line(&self, plan_mode: bool) -> io::Result<()> {
231        let mut stdout = io::stdout();
232        let mode_line = self.state.mode_line();
233
234        print!("{}", escape::move_to_line(mode_line));
235        print!("{}", ansi::CLEAR_LINE);
236
237        if plan_mode {
238            print!(
239                "{}⏸ plan mode on (shift+tab to switch){}",
240                ansi::DIM,
241                ansi::RESET
242            );
243        } else {
244            print!(
245                "{}▶ standard mode (shift+tab to switch){}",
246                ansi::DIM,
247                ansi::RESET
248            );
249        }
250        stdout.flush()?;
251
252        Ok(())
253    }
254
255    /// Update the mode indicator
256    pub fn update_mode(&self, plan_mode: bool) -> io::Result<()> {
257        if !self.state.is_active() {
258            return Ok(());
259        }
260
261        let mut stdout = io::stdout();
262
263        print!("{}", escape::SAVE_CURSOR);
264        self.draw_mode_line(plan_mode)?;
265        print!("{}", escape::RESTORE_CURSOR);
266        stdout.flush()?;
267
268        Ok(())
269    }
270
271    /// Position cursor at the input line for user input
272    pub fn position_for_input(&self) -> io::Result<()> {
273        if !self.state.is_active() {
274            return Ok(());
275        }
276
277        let mut stdout = io::stdout();
278        let input_line = self.state.input_line();
279
280        print!("{}", escape::move_to_line(input_line));
281        print!("{}", ansi::CLEAR_LINE);
282        stdout.flush()?;
283
284        Ok(())
285    }
286
287    /// Return cursor to scroll region (for output)
288    pub fn position_for_output(&self) -> io::Result<()> {
289        if !self.state.is_active() {
290            return Ok(());
291        }
292
293        // Restore saved cursor position (in scroll region)
294        print!("{}", escape::RESTORE_CURSOR);
295        io::stdout().flush()?;
296
297        Ok(())
298    }
299
300    /// Clean up - reset scroll region and restore terminal
301    pub fn cleanup(&self) -> io::Result<()> {
302        if !self.state.is_active() {
303            return Ok(());
304        }
305
306        let mut stdout = io::stdout();
307
308        // Reset scroll region
309        print!("{}", escape::RESET_SCROLL_REGION);
310
311        // Clear the fixed lines
312        let height = self.state.height();
313        for line in (height - RESERVED_LINES + 1)..=height {
314            print!("{}", escape::move_to_line(line));
315            print!("{}", ansi::CLEAR_LINE);
316        }
317
318        // Move to bottom
319        execute!(stdout, MoveTo(0, height - 1))?;
320        print!("{}", ansi::SHOW_CURSOR);
321
322        self.state.active.store(false, Ordering::SeqCst);
323        stdout.flush()?;
324
325        Ok(())
326    }
327
328    /// Handle terminal resize
329    pub fn handle_resize(&self) -> io::Result<()> {
330        if !self.state.is_active() {
331            return Ok(());
332        }
333
334        // Get new size
335        let (width, height) = terminal::size()?;
336        self.state.term_width.store(width, Ordering::SeqCst);
337        self.state.term_height.store(height, Ordering::SeqCst);
338
339        // Recalculate and set new scroll region
340        let scroll_bottom = height.saturating_sub(RESERVED_LINES);
341        print!("{}", escape::set_scroll_region(1, scroll_bottom));
342
343        // Redraw fixed lines
344        self.draw_status_line("")?;
345        self.draw_focus_line(None)?;
346        self.draw_input_line(false)?;
347        self.draw_mode_line(false)?;
348
349        io::stdout().flush()?;
350        Ok(())
351    }
352}
353
354impl Default for TerminalLayout {
355    fn default() -> Self {
356        Self::new()
357    }
358}
359
360impl Drop for TerminalLayout {
361    fn drop(&mut self) {
362        let _ = self.cleanup();
363    }
364}
365
366/// Print content to the scroll region (normal output area)
367/// This ensures output goes to the right place when layout is active
368pub fn print_to_scroll_region(content: &str) {
369    // Just print normally - the scroll region handles it
370    print!("{}", content);
371    let _ = io::stdout().flush();
372}
373
374/// Println to the scroll region
375pub fn println_to_scroll_region(content: &str) {
376    println!("{}", content);
377    let _ = io::stdout().flush();
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn test_layout_state_defaults() {
386        let state = LayoutState::default();
387        assert!(!state.is_active());
388        assert!(state.height() > 0);
389        assert!(state.width() > 0);
390    }
391
392    #[test]
393    fn test_scroll_region_escape() {
394        assert_eq!(escape::set_scroll_region(1, 20), "\x1b[1;20r");
395        assert_eq!(escape::move_to_line(5), "\x1b[5;1H");
396    }
397}