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
13#[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 pub global_hotkey_name: Option<&'a str>,
24 pub approval: Option<&'a PendingApproval>,
25 pub approval_count: Option<usize>,
26}
27
28pub 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
35pub struct Display {
40 line_count: u16,
41}
42
43impl Display {
44 pub fn new() -> Self {
45 Display { line_count: 0 }
46 }
47
48 pub fn update(&mut self, state: RecordingState, meta: &DisplayMeta) {
57 let mut stdout = io::stdout();
58
59 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 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 if i + 1 < lines.len() {
80 let _ = stdout.queue(crossterm::style::Print("\r\n"));
81 }
82 }
83 let _ = stdout.flush();
84 }
85
86 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 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 }
115
116 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
236pub 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 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 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 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}