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