mod app;
mod logger;
mod platform;
mod ui;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Result;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::prelude::*;
use app::{App, AppMode, RecordingStatus};
#[cfg(target_os = "macos")]
fn reconnect_tty_from_args() {
let args: Vec<String> = std::env::args().collect();
let tty_idx = match args.iter().position(|a| a == "--tty") {
Some(idx) => idx,
None => return,
};
let tty_path = match args.get(tty_idx + 1) {
Some(p) => p.clone(),
None => return,
};
let c_path = match std::ffi::CString::new(tty_path) {
Ok(p) => p,
Err(_) => return,
};
unsafe {
extern "C" {
fn open(path: *const std::ffi::c_char, oflag: i32) -> i32;
fn dup2(oldfd: i32, newfd: i32) -> i32;
fn close(fd: i32) -> i32;
}
const O_RDWR: i32 = 2;
let fd = open(c_path.as_ptr(), O_RDWR);
if fd < 0 {
return;
}
dup2(fd, 0);
if fd > 2 {
close(fd);
}
}
}
#[cfg(target_os = "macos")]
fn init_macos_app_event_handler() {
use std::ffi::CString;
#[link(name = "AppKit", kind = "framework")]
extern "C" {
fn NSApplicationLoad() -> bool;
}
#[link(name = "objc", kind = "dylib")]
extern "C" {
fn objc_getClass(name: *const std::ffi::c_char) -> *mut std::ffi::c_void;
fn sel_registerName(name: *const std::ffi::c_char) -> *mut std::ffi::c_void;
fn objc_msgSend(
obj: *mut std::ffi::c_void,
sel: *mut std::ffi::c_void,
...
) -> *mut std::ffi::c_void;
}
std::thread::spawn(|| {
unsafe {
NSApplicationLoad();
let cls_name = CString::new("NSApplication").unwrap();
let cls = objc_getClass(cls_name.as_ptr());
if cls.is_null() {
return;
}
let sel_shared = CString::new("sharedApplication").unwrap();
let app = objc_msgSend(cls, sel_registerName(sel_shared.as_ptr()));
if app.is_null() {
return;
}
let sel_finish = CString::new("finishLaunching").unwrap();
objc_msgSend(app, sel_registerName(sel_finish.as_ptr()));
}
});
std::thread::sleep(Duration::from_millis(50));
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
if let Some(pos) = args.iter().position(|a| a == "--recover") {
let dir = args
.get(pos + 1)
.ok_or_else(|| anyhow::anyhow!("--recover requires a path"))?;
voice_bird_cli::session::recover::recover(std::path::Path::new(dir))?;
println!("Recovered transcripts in {}", dir);
return Ok(());
}
let debug_snapshot_path: Option<PathBuf> = args
.iter()
.position(|a| a == "--debug-state-snapshot")
.and_then(|pos| args.get(pos + 1))
.map(PathBuf::from);
#[cfg(target_os = "macos")]
reconnect_tty_from_args();
#[cfg(target_os = "macos")]
init_macos_app_event_handler();
let log_path = match logger::init() {
Ok(path) => Some(path),
Err(e) => {
eprintln!("Warning: failed to initialize logger: {}", e);
None
}
};
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "<unknown>".into());
log::info!(
"Voice Bird v{} starting — ui=inline-devices-panel exe={} built_at={}",
env!("CARGO_PKG_VERSION"),
exe,
env!("VOICE_BIRD_BUILD_TS"),
);
#[cfg(not(windows))]
whisper_rs::install_whisper_log_trampoline();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
app.log_path = log_path;
app.refresh_inventory();
if let Some(saved) = app.config.input_device.clone() {
let saved_kind = app.config.input_device_kind;
let by_name_and_kind = saved_kind.and_then(|k| {
app.devices
.iter()
.position(|d| d.name == saved && d.kind == k)
});
let i = by_name_and_kind.or_else(|| app.devices.iter().position(|d| d.name == saved));
if let Some(i) = i {
app.selected_device_index = i;
}
}
if let Some(last_id) = app.config.last_app_id.clone() {
app.selected_app_index = app.apps.iter().position(|a| a.id == last_id);
}
log::info!(
"Found {} devices, {} apps",
app.devices.len(),
app.apps.len()
);
for (i, d) in app.devices.iter().enumerate() {
log::info!(" device[{}] = {:?} kind={:?}", i, d.name, d.kind);
}
log::info!(
"config.input_device = {:?}, cursor selected_device_index = {}, selected_app_index = {:?}",
app.config.input_device,
app.selected_device_index,
app.selected_app_index,
);
if let Some(p) = &debug_snapshot_path {
log::info!("debug state snapshots → {}", p.display());
let _ = std::fs::write(p, "");
write_state_snapshot(&app, "<startup>", p);
}
let result = run_app(&mut terminal, &mut app, debug_snapshot_path.as_deref());
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(e) = result {
log::error!("Application error: {}", e);
eprintln!("Error: {}", e);
}
log::info!("Voice Bird CLI exiting");
Ok(())
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
debug_snapshot_path: Option<&Path>,
) -> Result<()> {
let mut consecutive_poll_errors: u32 = 0;
let mut last_periodic_snapshot = std::time::Instant::now();
loop {
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let stdin_fd = std::io::stdin().as_raw_fd();
if unsafe { libc::isatty(stdin_fd) } == 0 {
log::warn!("stdin is no longer a TTY, exiting gracefully");
stop_all_sessions(app);
return Ok(());
}
}
app.update_duration();
app.check_error();
app.check_engine_error();
app.poll_picker_download();
terminal.draw(|f| ui::render(f, app))?;
match event::poll(Duration::from_millis(50)) {
Ok(true) => {
consecutive_poll_errors = 0;
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
match app.mode {
AppMode::Normal => handle_normal_mode(app, key.code),
AppMode::ModelPicker => handle_picker_mode(app, key.code),
AppMode::Help => handle_help_mode(app, key.code),
AppMode::ApiKeyModal => handle_api_key_modal(app, key.code),
AppMode::PathModal => handle_path_modal(app, key.code),
}
if let Some(path) = debug_snapshot_path {
write_state_snapshot(app, &format!("{:?}", key.code), path);
}
}
Event::Mouse(mouse) if app.mode == AppMode::Normal => match mouse.kind {
MouseEventKind::ScrollUp => app.scroll_transcript_up(3),
MouseEventKind::ScrollDown => app.scroll_transcript_down(3),
_ => {}
},
_ => {}
}
}
Ok(false) => {
consecutive_poll_errors = 0;
}
Err(e) => {
consecutive_poll_errors += 1;
log::warn!("event::poll error ({}/5): {}", consecutive_poll_errors, e);
if consecutive_poll_errors >= 5 {
log::error!("Too many consecutive poll errors, exiting");
stop_all_sessions(app);
return Ok(());
}
}
}
if app.should_quit {
stop_all_sessions(app);
break;
}
if let Some(path) = debug_snapshot_path {
if last_periodic_snapshot.elapsed() >= Duration::from_secs(1) {
write_state_snapshot(app, "<periodic>", path);
last_periodic_snapshot = std::time::Instant::now();
}
}
}
Ok(())
}
fn handle_normal_mode(app: &mut App, key: KeyCode) {
match key {
KeyCode::PageUp => {
app.scroll_transcript_up(10);
return;
}
KeyCode::PageDown => {
app.scroll_transcript_down(10);
return;
}
KeyCode::Home => {
app.scroll_transcript_home();
return;
}
KeyCode::End => {
app.scroll_transcript_end();
return;
}
_ => {}
}
match key {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('?') => app.toggle_help(),
KeyCode::Tab => {
app.focus_next();
log::info!(
"keys: Tab → focused_section = {} (sections active = {})",
app.focused_section,
app.active_section_count()
);
}
KeyCode::BackTab => {
app.focus_prev();
log::info!(
"keys: BackTab → focused_section = {} (sections active = {})",
app.focused_section,
app.active_section_count()
);
}
KeyCode::Up | KeyCode::Char('k') => app.select_previous(),
KeyCode::Down | KeyCode::Char('j') => app.select_next(),
KeyCode::Left | KeyCode::Char('h') => {
app.picker_focus = crate::app::PickerFocus::Devices;
log::info!("keys: Left → picker_focus = Devices");
}
KeyCode::Right => {
app.picker_focus = crate::app::PickerFocus::Apps;
log::info!("keys: Right → picker_focus = Apps");
}
KeyCode::Char(' ') => {
if app.picker_focus == crate::app::PickerFocus::Apps {
app.clear_app_selection();
log::info!("keys: Space → cleared app selection");
}
}
KeyCode::Enter => {
let Some(slot) = app.next_free_slot() else {
log::info!("keys: Enter → refused (all 3 sections full)");
app.banner = Some("All 3 sections are recording — stop one first ([s])".into());
return;
};
use voice_bird_cli::config::AudioSessionKind;
use voice_bird_cli::session::layout::SessionSource;
let Some(dev) = app.devices.get(app.selected_device_index).cloned() else {
log::warn!(
"keys: Enter → refused (no device at idx {})",
app.selected_device_index
);
app.banner = Some("No audio device selected — press [r] to refresh".into());
return;
};
let app_pick = app.focused_app().cloned();
log::info!(
"keys: Enter → slot={} focused_section_before={} dev=({:?}, {:?}) app={:?}",
slot,
app.focused_section,
dev.name,
dev.kind,
app_pick.as_ref().map(|a| (a.name.clone(), a.id.clone())),
);
if matches!(dev.kind, AudioSessionKind::Input) && app_pick.is_some() {
app.banner = Some(
"Mic + per-app capture isn't supported — pick an output device or [Space] to clear the app".into(),
);
return;
}
let name_changed = app.config.input_device.as_deref() != Some(dev.name.as_str());
let kind_changed = app.config.input_device_kind != Some(dev.kind);
let app_id_changed =
app.config.last_app_id.as_deref() != app_pick.as_ref().map(|a| a.id.as_str());
if name_changed || kind_changed || app_id_changed {
app.config.input_device = Some(dev.name.clone());
app.config.input_device_kind = Some(dev.kind);
app.config.last_app_id = app_pick.as_ref().map(|a| a.id.clone());
if let Err(e) = app.config.save() {
log::error!("config save: {e}");
}
}
let source = match (dev.kind, app_pick) {
(AudioSessionKind::Input, _) => SessionSource::Microphone,
(AudioSessionKind::Output, None) => SessionSource::System,
(AudioSessionKind::Output, Some(a)) => SessionSource::App {
id: a.id,
name: a.name,
device_name: dev.name.clone(),
},
(AudioSessionKind::App, _) => {
app.banner = Some("Unexpected device kind — press [r] to refresh".into());
return;
}
};
let settings = app.effective_settings_for(&source);
app.focused_section = slot;
log::info!(
"keys: Enter → resolved source={:?}; calling start_section[{}]",
source,
slot
);
match app.start_section(slot, source, settings) {
Ok(()) => {
log::info!(
"keys: Enter → start_section[{}] OK; sections active = {}",
slot,
app.active_section_count()
);
}
Err(msg) => {
log::warn!("keys: Enter → start_section[{}] FAILED: {}", slot, msg);
app.banner = Some(msg.clone());
app.status = RecordingStatus::Error(msg);
}
}
}
KeyCode::Char('r') => {
let before_d = app.devices.len();
let before_a = app.apps.len();
app.refresh_inventory();
log::info!(
"keys: r refresh_inventory: devices {} → {}, apps {} → {}",
before_d,
app.devices.len(),
before_a,
app.apps.len()
);
}
KeyCode::Char('s') => {
let slot = app.focused_section;
if app.sections[slot].is_some() {
log::info!("keys: s → stop_section[{}]", slot);
app.stop_section(slot);
} else {
log::info!("keys: s → no-op (slot {} empty)", slot);
}
}
KeyCode::Char('x') => {
let slot = app.focused_section;
let had_text =
app.focused_committed().lock().len() > 0 || app.transcript_saved[slot].is_some();
app.clear_slot_transcript(slot);
if had_text {
log::info!("keys: x → cleared transcript for slot {slot}");
}
}
KeyCode::Char('S') => {
app.stop_all_sections();
}
#[cfg(not(windows))]
KeyCode::Char('m') => {
let catalog = voice_bird_cli::transcription::models::Catalog::builtin();
let current = app.display_model();
let current_idx = catalog
.all()
.iter()
.position(|m| m.id == current.as_str())
.unwrap_or(0);
app.picker = Some(crate::app::PickerState {
index: current_idx,
downloading: None,
});
app.mode = AppMode::ModelPicker;
}
#[cfg(windows)]
KeyCode::Char('m') => {
app.banner = Some("Windows is cloud-only — no local models".into());
}
#[cfg(windows)]
KeyCode::Char('c') => {
app.open_api_key_modal();
}
#[cfg(not(windows))]
KeyCode::Char('c') => {
if let Some(section) = app.focused_mut() {
section.settings.cloud_on = !section.settings.cloud_on;
let on = section.settings.cloud_on;
app.persist_focused_settings();
app.banner = Some(if on {
"Cloud: ON for focused section (applies on next start)".into()
} else {
"Cloud: OFF for focused section (applies on next start)".into()
});
} else {
app.config.cloud_broadcast_enabled = !app.config.cloud_broadcast_enabled;
let on = app.config.cloud_broadcast_enabled;
if let Err(e) = app.config.save() {
log::error!("config save (cloud toggle): {e}");
}
if on && app.config.voicebird_api_key.is_empty() {
app.open_api_key_modal();
} else {
app.banner = Some(if on {
"Cloud: ON (next recording streams to voicebird.app)".into()
} else {
"Cloud: OFF (next recording is local-only)".into()
});
}
}
}
KeyCode::Char('l') => {
let langs = crate::app::CLOUD_LANGUAGES;
if let Some(section) = app.focused_mut() {
if !section.settings.cloud_on {
return;
}
let i = langs
.iter()
.position(|&l| l == section.settings.language)
.unwrap_or(0);
let next = (i + 1) % langs.len();
section.settings.language = langs[next].into();
app.persist_focused_settings();
} else if app.config.cloud_broadcast_enabled {
let i = langs
.iter()
.position(|&l| l == app.config.language)
.unwrap_or(0);
let next = (i + 1) % langs.len();
app.config.language = langs[next].into();
if let Err(e) = app.config.save() {
log::error!("config save (lang cycle): {e}");
}
}
}
#[cfg(not(windows))]
KeyCode::Char('e') if app.active_section_count() == 0 => {
app.export_transcript();
}
#[cfg(not(windows))]
KeyCode::Char('p') if app.active_section_count() == 0 => {
app.open_path_modal();
}
_ => {}
}
}
fn handle_path_modal(app: &mut App, key: KeyCode) {
match key {
KeyCode::Esc => {
app.path_buf = None;
app.mode = AppMode::Normal;
}
KeyCode::Enter => {
if let Some(buf) = app.path_buf.take() {
app.config.session_dir = buf.trim().to_string();
if let Err(e) = app.config.save() {
log::error!("config save (path modal): {e}");
app.banner = Some(format!("Save failed: {e}"));
} else {
app.banner = Some(format!(
"Output path → {}",
app.config.session_dir_expanded()
));
}
}
app.mode = AppMode::Normal;
}
KeyCode::Backspace => {
if let Some(buf) = app.path_buf.as_mut() {
buf.pop();
}
}
KeyCode::Char(ch) => {
if let Some(buf) = app.path_buf.as_mut() {
buf.push(ch);
}
}
_ => {}
}
}
fn handle_api_key_modal(app: &mut App, key: KeyCode) {
match key {
KeyCode::Esc => {
#[cfg(not(windows))]
{
app.config.cloud_broadcast_enabled = false;
if let Err(e) = app.config.save() {
log::error!("config save (modal cancel): {e}");
}
app.banner = Some("Cloud: OFF (cancelled API key entry)".into());
}
#[cfg(windows)]
{
app.banner = Some(
"Windows is cloud-only — press 'c' to set an API key before recording".into(),
);
}
app.api_key_buf = None;
app.mode = AppMode::Normal;
}
KeyCode::Enter => {
if let Some(buf) = app.api_key_buf.take() {
app.config.voicebird_api_key = buf.trim().to_string();
if let Err(e) = app.config.save() {
log::error!("config save (modal save): {e}");
app.banner = Some(format!("Save failed: {e}"));
} else {
app.banner = Some("API key saved — start a recording to verify".into());
}
}
app.mode = AppMode::Normal;
}
KeyCode::Backspace => {
if let Some(buf) = app.api_key_buf.as_mut() {
buf.pop();
}
}
KeyCode::Char(ch) => {
if let Some(buf) = app.api_key_buf.as_mut() {
buf.push(ch);
}
}
_ => {}
}
}
fn handle_picker_mode(app: &mut App, key: KeyCode) {
let download_in_flight = app
.picker
.as_ref()
.and_then(|p| p.downloading.as_ref())
.map(|arc| arc.lock().error.is_none())
.unwrap_or(false);
if download_in_flight {
return;
}
let catalog = voice_bird_cli::transcription::models::Catalog::builtin();
let total = catalog.all().len();
match key {
KeyCode::Up | KeyCode::Char('k') => {
if let Some(picker) = app.picker.as_mut() {
picker.index = picker.index.saturating_sub(1);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if let Some(picker) = app.picker.as_mut() {
if picker.index + 1 < total {
picker.index += 1;
}
}
}
KeyCode::Enter => {
let idx = app.picker.as_ref().map(|p| p.index).unwrap_or(0);
if let Some(entry) = catalog.all().get(idx).cloned() {
app.begin_model_download(&entry);
}
}
KeyCode::Esc => {
if app.config_was_loaded_from_disk {
app.mode = AppMode::Normal;
app.picker = None;
}
}
_ => {}
}
}
fn handle_help_mode(app: &mut App, key: KeyCode) {
match key {
KeyCode::Char('?') | KeyCode::Esc | KeyCode::Enter => {
app.toggle_help();
}
_ => {}
}
}
fn stop_all_sessions(app: &mut App) {
log::info!("Stopping all sessions");
app.stop_all_sections();
}
fn write_state_snapshot(app: &App, last_key: &str, path: &Path) {
use std::io::Write;
let status = match &app.status {
RecordingStatus::Idle => "Idle".to_string(),
RecordingStatus::Recording => "Recording".to_string(),
RecordingStatus::Error(s) => format!("Error: {s}"),
};
let device_names: Vec<String> = app.devices.iter().map(|d| d.name.clone()).collect();
let selected_device_name = app
.devices
.get(app.selected_device_index)
.map(|d| d.name.clone())
.unwrap_or_default();
let app_names: Vec<String> = app.apps.iter().map(|a| a.name.clone()).collect();
let selected_app_name = app
.selected_app_index
.and_then(|i| app.apps.get(i))
.map(|a| a.name.clone())
.unwrap_or_default();
let committed_arc = app.focused_committed();
let committed = committed_arc.lock();
let committed_count = committed.len();
let last_committed_text = committed.last().map(|c| c.text.clone()).unwrap_or_default();
drop(committed);
let tentative_text = app.focused_tentative().lock().clone();
let api_key_masked = if app.config.voicebird_api_key.is_empty() {
String::new()
} else {
let n = app.config.voicebird_api_key.len();
format!("•••{n}")
};
let api_key_buf_len = app.api_key_buf.as_ref().map(|s| s.len()).unwrap_or(0);
let snap = serde_json::json!({
"ts": chrono::Utc::now().to_rfc3339(),
"last_key": last_key,
"mode": format!("{:?}", app.mode),
"status": status,
"cloud_broadcast_active": app.focused_cloud_active(),
"cloud_broadcast_enabled": app.config.cloud_broadcast_enabled,
"language": app.config.language,
"default_model": app.config.default_model,
"engine_kind": app.focused_engine_kind(),
"duration_secs": app.duration,
"banner": app.banner,
"status_message": app.status_message,
"device_names": device_names,
"selected_device_index": app.selected_device_index,
"selected_device_name": selected_device_name,
"app_names": app_names,
"selected_app_index": app.selected_app_index,
"selected_app_name": selected_app_name,
"picker_focus": format!("{:?}", app.picker_focus),
"voicebird_api_key_masked": api_key_masked,
"api_key_buf_len": api_key_buf_len,
"committed_count": committed_count,
"last_committed_text": last_committed_text,
"tentative_text": tentative_text,
});
let line = match serde_json::to_string(&snap) {
Ok(s) => s,
Err(_) => return,
};
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
let _ = writeln!(f, "{line}");
}
}