1use 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
13const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
15
16#[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 pub global_hotkey_name: Option<&'a str>,
27 pub approval: Option<&'a PendingApproval>,
28 pub approval_count: Option<usize>,
29 pub spinner_frame: usize,
31}
32
33pub 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
40pub struct Display {
45 line_count: u16,
46}
47
48impl Display {
49 pub fn new() -> Self {
50 Display { line_count: 0 }
51 }
52
53 pub fn update(&mut self, state: RecordingState, meta: &DisplayMeta) {
62 let mut stdout = io::stdout();
63
64 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 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 if i + 1 < lines.len() {
85 let _ = stdout.queue(crossterm::style::Print("\r\n"));
86 }
87 }
88 let _ = stdout.flush();
89 }
90
91 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 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 }
120
121 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
238pub 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 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 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 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}