Skip to main content

opencode_voice/ui/
display.rs

1//! ANSI terminal display renderer for the voice mode UI.
2
3use crossterm::{
4    cursor,
5    terminal::{Clear, ClearType},
6    QueueableCommand,
7};
8use std::io::{self, Write};
9
10use crate::approval::types::PendingApproval;
11use crate::state::RecordingState;
12
13/// Optional metadata for the display renderer.
14#[derive(Default)]
15pub struct DisplayMeta<'a> {
16    pub duration: Option<f64>,
17    pub level: Option<f32>,
18    pub transcript: Option<&'a str>,
19    pub error: Option<&'a str>,
20    pub toggle_key: Option<&'a str>,
21    /// When a global hotkey is active, this carries the hotkey name
22    /// (e.g. "right_option") so the status line shows the actual key.
23    pub global_hotkey_name: Option<&'a str>,
24    pub approval: Option<&'a PendingApproval>,
25    pub approval_count: Option<usize>,
26}
27
28/// Renders an ASCII level bar like `[||||    ]`.
29pub fn render_level(level: f32, width: usize) -> String {
30    let filled = ((level * width as f32).round() as usize).min(width);
31    let empty = width - filled;
32    format!("[{}{}]", "|".repeat(filled), " ".repeat(empty))
33}
34
35/// In-place terminal renderer.
36///
37/// Uses absolute cursor positioning (`origin_row`) so that interleaved
38/// stderr output (e.g. from `eprintln!`) cannot corrupt the display area.
39pub struct Display {
40    line_count: u16,
41}
42
43impl Display {
44    pub fn new() -> Self {
45        Display { line_count: 0 }
46    }
47
48    /// Erases previously rendered lines and renders the new state **in place**.
49    ///
50    /// Uses only relative cursor movement (`MoveUp`) so it works with or
51    /// without raw mode and is immune to `cursor::position()` hangs.
52    ///
53    /// The cursor is left at the end of the last rendered line (no trailing
54    /// newline) so that the next `update` can move back up exactly
55    /// `line_count - 1` lines to reach the first rendered line.
56    pub fn update(&mut self, state: RecordingState, meta: &DisplayMeta) {
57        let mut stdout = io::stdout();
58
59        // Move cursor back to the start of the first line we rendered last
60        // time.  After the previous render the cursor sits at the end of the
61        // last content line (no trailing \n), so we need to go up
62        // (line_count - 1) lines, then to column 0.
63        if self.line_count > 1 {
64            let _ = stdout.queue(cursor::MoveUp(self.line_count - 1));
65        }
66        if self.line_count > 0 {
67            let _ = stdout.queue(cursor::MoveToColumn(0));
68            let _ = stdout.queue(Clear(ClearType::FromCursorDown));
69        }
70
71        // Render new state
72        let lines = self.render_state(state, meta);
73        self.line_count = lines.len() as u16;
74
75        for (i, line) in lines.iter().enumerate() {
76            let _ = stdout.queue(crossterm::style::Print(line));
77            // Newline between lines, but NOT after the last one — keeps the
78            // cursor on the content so the next update can overwrite cleanly.
79            if i + 1 < lines.len() {
80                let _ = stdout.queue(crossterm::style::Print("\r\n"));
81            }
82        }
83        let _ = stdout.flush();
84    }
85
86    /// Erases all rendered lines and resets.
87    pub fn clear(&mut self) {
88        let mut stdout = io::stdout();
89        if self.line_count > 1 {
90            let _ = stdout.queue(cursor::MoveUp(self.line_count - 1));
91        }
92        if self.line_count > 0 {
93            let _ = stdout.queue(cursor::MoveToColumn(0));
94            let _ = stdout.queue(Clear(ClearType::FromCursorDown));
95        }
96        self.line_count = 0;
97        let _ = stdout.flush();
98    }
99
100    /// Prints a log message **above** the display area.
101    ///
102    /// Clears the current display, writes `msg` to stdout on its own line,
103    /// then resets `line_count` so the next `update()` renders cleanly below.
104    /// All output goes through stdout to avoid cursor-tracking issues with
105    /// stderr interleaving.
106    pub fn log(&mut self, msg: &str) {
107        self.clear();
108        let mut stdout = io::stdout();
109        let _ = stdout.queue(crossterm::style::Print(msg));
110        let _ = stdout.queue(crossterm::style::Print("\r\n"));
111        let _ = stdout.flush();
112        // line_count is already 0 from clear(), so the next update()
113        // will render starting at the current cursor position.
114    }
115
116    /// Prints the welcome banner. NOT tracked in line_count.
117    pub fn show_welcome(
118        &self,
119        toggle_key: &str,
120        global_hotkey: bool,
121        global_hotkey_name: &str,
122        push_to_talk: bool,
123    ) {
124        let mode = if push_to_talk {
125            if global_hotkey {
126                format!("Hold [{}] to record (global hotkey)", global_hotkey_name)
127            } else {
128                format!("Hold [{}] to record", toggle_key)
129            }
130        } else {
131            format!("Press [{}] to toggle recording", toggle_key)
132        };
133        println!("\x1b[1;36m━━━ OpenCode Voice Mode ━━━\x1b[0m");
134        println!("  {}", mode);
135        println!("  Press [q] or Ctrl+C to quit");
136        println!();
137    }
138
139    fn render_state(&self, state: RecordingState, meta: &DisplayMeta) -> Vec<String> {
140        match state {
141            RecordingState::Idle => {
142                let key_hint = meta
143                    .global_hotkey_name
144                    .or(meta.toggle_key)
145                    .map(|k| format!(" [{}]", k))
146                    .unwrap_or_default();
147                if let Some(transcript) = meta.transcript {
148                    let preview: String = transcript.chars().take(60).collect();
149                    let ellipsis = if transcript.len() > 60 { "..." } else { "" };
150                    vec![
151                        format!("\x1b[32m● Ready{}\x1b[0m", key_hint),
152                        format!("  Sent: {}{}", preview, ellipsis),
153                    ]
154                } else {
155                    vec![format!(
156                        "\x1b[32m● Ready{} — Press to speak\x1b[0m",
157                        key_hint
158                    )]
159                }
160            }
161            RecordingState::Recording => {
162                let duration = meta.duration.unwrap_or(0.0);
163                let level_bar = meta
164                    .level
165                    .map(|l| format!(" {}", render_level(l, 8)))
166                    .unwrap_or_default();
167                vec![format!(
168                    "\x1b[31m● REC{} {:.1}s\x1b[0m",
169                    level_bar, duration
170                )]
171            }
172            RecordingState::Transcribing => {
173                vec!["\x1b[33m◌ Transcribing...\x1b[0m".to_string()]
174            }
175            RecordingState::Injecting => {
176                vec!["\x1b[36m→ Sending to OpenCode...\x1b[0m".to_string()]
177            }
178            RecordingState::ApprovalPending => {
179                let count = meta.approval_count.unwrap_or(0);
180                let count_str = if count > 1 {
181                    format!(" (+{} more)", count - 1)
182                } else {
183                    String::new()
184                };
185
186                if let Some(approval) = meta.approval {
187                    match approval {
188                        PendingApproval::Permission(req) => {
189                            let detail = format_permission_detail(&req.permission, &req.metadata);
190                            vec![
191                                format!(
192                                    "\x1b[35m⚠ Approval needed{}: {} — {}\x1b[0m",
193                                    count_str, req.permission, detail
194                                ),
195                                "  Say: allow/always/reject".to_string(),
196                            ]
197                        }
198                        PendingApproval::Question(req) => {
199                            let mut lines = Vec::new();
200                            if let Some(q) = req.questions.first() {
201                                lines.push(format!("\x1b[35m? {}{}\x1b[0m", q.question, count_str));
202                                for (i, opt) in q.options.iter().take(5).enumerate() {
203                                    lines.push(format!("  {}. {}", i + 1, opt.label));
204                                }
205                                lines.push("  Say the option name or number".to_string());
206                            } else {
207                                lines.push(format!(
208                                    "\x1b[35m? Question pending{}\x1b[0m",
209                                    count_str
210                                ));
211                            }
212                            lines
213                        }
214                    }
215                } else {
216                    vec![format!("\x1b[35m⚠ Approval needed{}\x1b[0m", count_str)]
217                }
218            }
219            RecordingState::Error => {
220                let msg = meta.error.unwrap_or("An error occurred");
221                vec![
222                    format!("\x1b[31m✗ Error: {}\x1b[0m", msg),
223                    "  Recovering...".to_string(),
224                ]
225            }
226        }
227    }
228}
229
230impl Default for Display {
231    fn default() -> Self {
232        Self::new()
233    }
234}
235
236/// Formats a human-readable detail for a permission type and metadata.
237pub fn format_permission_detail(permission: &str, metadata: &serde_json::Value) -> String {
238    match permission {
239        "bash" => {
240            if let Some(cmd) = metadata.get("command").and_then(|v| v.as_str()) {
241                return format!("`{}`", cmd.chars().take(60).collect::<String>());
242            }
243        }
244        "edit" | "write" | "read" => {
245            if let Some(path) = metadata.get("path").and_then(|v| v.as_str()) {
246                return path.to_string();
247            }
248        }
249        _ => {}
250    }
251    // Fallback: first string value in metadata
252    if let Some(obj) = metadata.as_object() {
253        for v in obj.values() {
254            if let Some(s) = v.as_str() {
255                return s.chars().take(60).collect();
256            }
257        }
258    }
259    String::new()
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_render_level_empty() {
268        assert_eq!(render_level(0.0, 8), "[        ]");
269    }
270
271    #[test]
272    fn test_render_level_full() {
273        assert_eq!(render_level(1.0, 8), "[||||||||]");
274    }
275
276    #[test]
277    fn test_render_level_half() {
278        // 0.5 * 8 = 4.0 → 4 filled, 4 empty
279        assert_eq!(render_level(0.5, 8), "[||||    ]");
280    }
281
282    #[test]
283    fn test_render_level_clamps_above_one() {
284        assert_eq!(render_level(2.0, 8), "[||||||||]");
285    }
286
287    #[test]
288    fn test_render_level_width_zero() {
289        assert_eq!(render_level(0.5, 0), "[]");
290    }
291
292    #[test]
293    fn test_format_permission_detail_bash() {
294        let meta = serde_json::json!({ "command": "ls -la" });
295        assert_eq!(format_permission_detail("bash", &meta), "`ls -la`");
296    }
297
298    #[test]
299    fn test_format_permission_detail_edit() {
300        let meta = serde_json::json!({ "path": "/tmp/foo.txt" });
301        assert_eq!(format_permission_detail("edit", &meta), "/tmp/foo.txt");
302    }
303
304    #[test]
305    fn test_format_permission_detail_write() {
306        let meta = serde_json::json!({ "path": "/tmp/bar.txt" });
307        assert_eq!(format_permission_detail("write", &meta), "/tmp/bar.txt");
308    }
309
310    #[test]
311    fn test_format_permission_detail_read() {
312        let meta = serde_json::json!({ "path": "/etc/hosts" });
313        assert_eq!(format_permission_detail("read", &meta), "/etc/hosts");
314    }
315
316    #[test]
317    fn test_format_permission_detail_unknown_fallback() {
318        let meta = serde_json::json!({ "target": "some-value" });
319        assert_eq!(format_permission_detail("unknown", &meta), "some-value");
320    }
321
322    #[test]
323    fn test_format_permission_detail_empty_metadata() {
324        let meta = serde_json::json!({});
325        assert_eq!(format_permission_detail("bash", &meta), "");
326    }
327
328    #[test]
329    fn test_render_state_idle_no_transcript() {
330        let display = Display::new();
331        let meta = DisplayMeta {
332            toggle_key: Some("space"),
333            ..Default::default()
334        };
335        let lines = display.render_state(RecordingState::Idle, &meta);
336        assert_eq!(lines.len(), 1);
337        assert!(lines[0].contains("Ready"));
338        assert!(lines[0].contains("[space]"));
339        assert!(lines[0].contains("Press to speak"));
340    }
341
342    #[test]
343    fn test_render_state_idle_with_transcript() {
344        let display = Display::new();
345        let meta = DisplayMeta {
346            transcript: Some("hello world"),
347            ..Default::default()
348        };
349        let lines = display.render_state(RecordingState::Idle, &meta);
350        assert_eq!(lines.len(), 2);
351        assert!(lines[0].contains("Ready"));
352        assert!(lines[1].contains("Sent: hello world"));
353    }
354
355    #[test]
356    fn test_render_state_idle_transcript_truncated() {
357        let display = Display::new();
358        let long_text = "a".repeat(80);
359        let meta = DisplayMeta {
360            transcript: Some(&long_text),
361            ..Default::default()
362        };
363        let lines = display.render_state(RecordingState::Idle, &meta);
364        assert_eq!(lines.len(), 2);
365        assert!(lines[1].contains("..."));
366    }
367
368    #[test]
369    fn test_render_state_recording() {
370        let display = Display::new();
371        let meta = DisplayMeta {
372            duration: Some(2.5),
373            level: Some(0.5),
374            ..Default::default()
375        };
376        let lines = display.render_state(RecordingState::Recording, &meta);
377        assert_eq!(lines.len(), 1);
378        assert!(lines[0].contains("REC"));
379        assert!(lines[0].contains("2.5s"));
380        assert!(lines[0].contains("[||||    ]"));
381    }
382
383    #[test]
384    fn test_render_state_recording_no_level() {
385        let display = Display::new();
386        let meta = DisplayMeta {
387            duration: Some(1.0),
388            ..Default::default()
389        };
390        let lines = display.render_state(RecordingState::Recording, &meta);
391        assert_eq!(lines.len(), 1);
392        assert!(lines[0].contains("REC"));
393        assert!(lines[0].contains("1.0s"));
394    }
395
396    #[test]
397    fn test_render_state_transcribing() {
398        let display = Display::new();
399        let meta = DisplayMeta::default();
400        let lines = display.render_state(RecordingState::Transcribing, &meta);
401        assert_eq!(lines.len(), 1);
402        assert!(lines[0].contains("Transcribing"));
403    }
404
405    #[test]
406    fn test_render_state_injecting() {
407        let display = Display::new();
408        let meta = DisplayMeta::default();
409        let lines = display.render_state(RecordingState::Injecting, &meta);
410        assert_eq!(lines.len(), 1);
411        assert!(lines[0].contains("Sending to OpenCode"));
412    }
413
414    #[test]
415    fn test_render_state_error() {
416        let display = Display::new();
417        let meta = DisplayMeta {
418            error: Some("connection failed"),
419            ..Default::default()
420        };
421        let lines = display.render_state(RecordingState::Error, &meta);
422        assert_eq!(lines.len(), 2);
423        assert!(lines[0].contains("Error: connection failed"));
424        assert!(lines[1].contains("Recovering"));
425    }
426
427    #[test]
428    fn test_render_state_error_default_message() {
429        let display = Display::new();
430        let meta = DisplayMeta::default();
431        let lines = display.render_state(RecordingState::Error, &meta);
432        assert_eq!(lines.len(), 2);
433        assert!(lines[0].contains("An error occurred"));
434    }
435
436    #[test]
437    fn test_render_state_approval_pending_no_approval() {
438        let display = Display::new();
439        let meta = DisplayMeta {
440            approval_count: Some(1),
441            ..Default::default()
442        };
443        let lines = display.render_state(RecordingState::ApprovalPending, &meta);
444        assert_eq!(lines.len(), 1);
445        assert!(lines[0].contains("Approval needed"));
446    }
447
448    #[test]
449    fn test_render_state_approval_pending_permission() {
450        use crate::approval::types::PermissionRequest;
451
452        let display = Display::new();
453        let req = PermissionRequest {
454            id: "req-1".to_string(),
455            permission: "bash".to_string(),
456            metadata: serde_json::json!({ "command": "rm -rf /tmp/test" }),
457        };
458        let approval = PendingApproval::Permission(req);
459        let meta = DisplayMeta {
460            approval: Some(&approval),
461            approval_count: Some(1),
462            ..Default::default()
463        };
464        let lines = display.render_state(RecordingState::ApprovalPending, &meta);
465        assert_eq!(lines.len(), 2);
466        assert!(lines[0].contains("Approval needed"));
467        assert!(lines[0].contains("bash"));
468        assert!(lines[0].contains("`rm -rf /tmp/test`"));
469        assert!(lines[1].contains("allow/always/reject"));
470    }
471
472    #[test]
473    fn test_render_state_approval_pending_multiple_count() {
474        use crate::approval::types::PermissionRequest;
475
476        let display = Display::new();
477        let req = PermissionRequest {
478            id: "req-1".to_string(),
479            permission: "edit".to_string(),
480            metadata: serde_json::json!({ "path": "/tmp/file.txt" }),
481        };
482        let approval = PendingApproval::Permission(req);
483        let meta = DisplayMeta {
484            approval: Some(&approval),
485            approval_count: Some(3),
486            ..Default::default()
487        };
488        let lines = display.render_state(RecordingState::ApprovalPending, &meta);
489        assert!(lines[0].contains("+2 more"));
490    }
491
492    #[test]
493    fn test_render_state_approval_pending_question() {
494        use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
495
496        let display = Display::new();
497        let req = QuestionRequest {
498            id: "q-1".to_string(),
499            questions: vec![QuestionInfo {
500                question: "Which approach?".to_string(),
501                options: vec![
502                    QuestionOption {
503                        label: "Option A".to_string(),
504                    },
505                    QuestionOption {
506                        label: "Option B".to_string(),
507                    },
508                ],
509                custom: true,
510            }],
511        };
512        let approval = PendingApproval::Question(req);
513        let meta = DisplayMeta {
514            approval: Some(&approval),
515            approval_count: Some(1),
516            ..Default::default()
517        };
518        let lines = display.render_state(RecordingState::ApprovalPending, &meta);
519        assert!(lines[0].contains("Which approach?"));
520        assert!(lines[1].contains("1. Option A"));
521        assert!(lines[2].contains("2. Option B"));
522        assert!(lines
523            .last()
524            .unwrap()
525            .contains("Say the option name or number"));
526    }
527
528    #[test]
529    fn test_render_state_approval_pending_question_empty() {
530        use crate::approval::types::QuestionRequest;
531
532        let display = Display::new();
533        let req = QuestionRequest {
534            id: "q-1".to_string(),
535            questions: vec![],
536        };
537        let approval = PendingApproval::Question(req);
538        let meta = DisplayMeta {
539            approval: Some(&approval),
540            approval_count: Some(1),
541            ..Default::default()
542        };
543        let lines = display.render_state(RecordingState::ApprovalPending, &meta);
544        assert_eq!(lines.len(), 1);
545        assert!(lines[0].contains("Question pending"));
546    }
547
548    #[test]
549    fn test_display_new_initial_state() {
550        let display = Display::new();
551        assert_eq!(display.line_count, 0);
552    }
553
554    #[test]
555    fn test_display_default() {
556        let display = Display::default();
557        assert_eq!(display.line_count, 0);
558    }
559
560    #[test]
561    fn test_all_states_produce_output() {
562        let display = Display::new();
563        let meta = DisplayMeta::default();
564
565        let states = [
566            RecordingState::Idle,
567            RecordingState::Recording,
568            RecordingState::Transcribing,
569            RecordingState::Injecting,
570            RecordingState::ApprovalPending,
571            RecordingState::Error,
572        ];
573
574        for state in states {
575            let lines = display.render_state(state, &meta);
576            assert!(!lines.is_empty(), "State {:?} produced no output", state);
577        }
578    }
579
580    #[test]
581    fn test_all_states_produce_distinct_output() {
582        let display = Display::new();
583        let meta = DisplayMeta::default();
584
585        let outputs: Vec<String> = [
586            RecordingState::Idle,
587            RecordingState::Recording,
588            RecordingState::Transcribing,
589            RecordingState::Injecting,
590            RecordingState::ApprovalPending,
591            RecordingState::Error,
592        ]
593        .iter()
594        .map(|&s| display.render_state(s, &meta).join("|"))
595        .collect();
596
597        // Each state should produce unique output
598        for i in 0..outputs.len() {
599            for j in (i + 1)..outputs.len() {
600                assert_ne!(
601                    outputs[i], outputs[j],
602                    "States {} and {} produce identical output",
603                    i, j
604                );
605            }
606        }
607    }
608}