use std::collections::{HashMap, HashSet};
use std::io::Stdout;
use std::sync::Arc;
use ratatui::{Terminal, backend::CrosstermBackend};
use tokio::sync::{Mutex, mpsc};
use crate::config::NotificationConfig;
use crate::error::SessionError;
use crate::notification::NotificationManager;
use crate::session::{SessionCommand, SessionEvent, SessionId};
use crate::tui::popup::{
ActionSelectPopupState, IssueItem, LoadingOperation, WorkspacePopupState, WorktreeItem,
};
use super::action::AppAction;
use super::state::{AppState, SessionSnapshot};
const THROBBER_TICK_MS: u64 = 80;
#[allow(clippy::struct_excessive_bools)]
pub struct TuiApp {
pub(super) state: AppState,
pub(super) sessions: Vec<SessionSnapshot>,
pub(super) active_idx: usize,
pub(super) terminal_buffers: HashMap<SessionId, vt100::Parser>,
pub(super) cmd_tx: mpsc::Sender<SessionCommand>,
pub(super) size: (u16, u16),
pub(super) popup_state: WorkspacePopupState,
pub(super) worktrees: Vec<WorktreeItem>,
pub(super) issues: Vec<IssueItem>,
pub(super) quit_selected_yes: bool,
pub(super) pending_sessions: HashSet<SessionId>,
pub(super) default_args: Vec<String>,
pub(super) scroll_offsets: HashMap<SessionId, usize>,
pub(super) should_quit: bool,
pub(super) pending_auto_input: Option<(SessionId, Vec<crate::session::AutoInputStep>)>,
pub(super) auto_input_ready: bool,
pub(super) auto_input_next_at: Option<std::time::Instant>,
pub(super) action_select_state: ActionSelectPopupState,
pub(super) issue_loading_throbber: throbber_widgets_tui::ThrobberState,
pub(super) today_cost: Option<f64>,
pub(super) last_throbber_tick: std::time::Instant,
pub(super) notification_manager: Arc<Mutex<NotificationManager>>,
}
impl TuiApp {
#[must_use]
pub fn new(
cmd_tx: mpsc::Sender<SessionCommand>,
notification_config: NotificationConfig,
default_args: Vec<String>,
) -> Self {
let notification_manager =
Arc::new(Mutex::new(NotificationManager::new(notification_config)));
Self {
state: AppState::Normal,
sessions: Vec::new(),
active_idx: 0,
terminal_buffers: HashMap::new(),
cmd_tx,
size: (24, 80),
popup_state: WorkspacePopupState::new(),
worktrees: Vec::new(),
issues: Vec::new(),
quit_selected_yes: false,
pending_sessions: HashSet::new(),
default_args,
scroll_offsets: HashMap::new(),
should_quit: false,
pending_auto_input: None,
auto_input_ready: false,
auto_input_next_at: None,
action_select_state: ActionSelectPopupState::new(),
issue_loading_throbber: throbber_widgets_tui::ThrobberState::default(),
today_cost: None,
last_throbber_tick: std::time::Instant::now(),
notification_manager,
}
}
#[must_use]
pub fn state(&self) -> &AppState {
&self.state
}
#[must_use]
pub fn session_count(&self) -> usize {
self.sessions.len()
}
#[must_use]
pub fn should_quit(&self) -> bool {
self.should_quit
}
#[must_use]
pub fn default_args(&self) -> &[String] {
&self.default_args
}
pub fn set_size(&mut self, rows: u16, cols: u16) {
self.size = (rows, cols);
for parser in self.terminal_buffers.values_mut() {
parser.set_size(rows, cols);
}
}
pub fn mark_pending(&mut self, id: &SessionId) {
self.pending_sessions.insert(*id);
}
pub fn clear_pending(&mut self, id: &SessionId) {
self.pending_sessions.remove(id);
}
#[must_use]
pub fn pending_count(&self) -> usize {
self.pending_sessions.len()
}
#[must_use]
pub fn is_pending(&self, id: &SessionId) -> bool {
self.pending_sessions.contains(id)
}
pub async fn dispatch(&mut self, action: AppAction) -> Result<(), SessionError> {
match action {
AppAction::TerminateCurrentSession => self.dispatch_terminate().await,
AppAction::CloseSession { id } => self.close_session(id).await,
AppAction::SwitchSession(idx) => self.switch_session(idx),
AppAction::NextSession => self.next_session(),
AppAction::PrevSession => self.prev_session(),
AppAction::ShowWorkspacePopup => self.dispatch_show_workspace().await,
AppAction::HidePopup => {
self.state = AppState::Normal;
self.popup_state.clear_input();
}
AppAction::ShowConfirmQuit => {
self.state = AppState::ConfirmQuit;
self.quit_selected_yes = false;
}
AppAction::DismissError => self.return_to_base(),
AppAction::ConfirmQuit | AppAction::Quit => self.should_quit = true,
AppAction::ToggleQuitSelection => self.quit_selected_yes = !self.quit_selected_yes,
AppAction::SendInput(data) => self.send_input(&data).await,
AppAction::Scroll(delta) => self.scroll(delta),
AppAction::ResizeTerminal(rows, cols) => self.resize_terminal(rows, cols).await,
AppAction::SelectNext => self.select_next(),
AppAction::SelectPrev => self.select_prev(),
AppAction::CrossSectionNext => self.cross_section_next(),
AppAction::CrossSectionPrev => self.cross_section_prev(),
AppAction::NextPopupSection => self.popup_state.next_section(),
AppAction::PrevPopupSection => self.popup_state.prev_section(),
AppAction::ConfirmSelection => self.dispatch_confirm_selection(),
AppAction::InputChar(c) => self.popup_state.input_char(c),
AppAction::InputBackspace => self.popup_state.input_backspace(),
AppAction::InputCursorLeft => self.popup_state.cursor_left(),
AppAction::InputCursorRight => self.popup_state.cursor_right(),
AppAction::CreateSessionWithBranch(branch) => {
self.dispatch_create_with_branch(branch).await;
}
AppAction::AdoptWorktree { path } => self.dispatch_adopt_worktree(&path).await,
AppAction::DeleteWorktree { path } => self.dispatch_delete_worktree(path).await,
AppAction::PullWorktree { path } => self.dispatch_pull_worktree(path).await,
AppAction::SelectIssue { number } => self.dispatch_select_issue(number).await,
AppAction::SelectActionChoice { index } => self.dispatch_select_action(index),
AppAction::TogglePermissionsChoice => self.dispatch_toggle_permissions(),
AppAction::ConfirmDangerousPermissions => self.dispatch_confirm_permissions().await,
AppAction::CancelIssueFlow => self.cancel_issue_flow(),
}
Ok(())
}
async fn dispatch_terminate(&mut self) {
if let Err(e) = self.terminate_current_session().await {
self.show_error(e.to_string());
}
}
async fn dispatch_show_workspace(&mut self) {
self.state = AppState::WorkspacePopup;
self.popup_state = WorkspacePopupState::new();
self.popup_state.session_list.select(Some(self.active_idx));
self.refresh_worktrees().await;
if !self.worktrees.is_empty() {
self.popup_state.worktree_list.select(Some(0));
}
if !self.issues.is_empty() {
self.popup_state.issue_list.select(Some(0));
}
self.popup_state.loading = LoadingOperation::Fetching;
let _ = self
.cmd_tx
.send(SessionCommand::RefreshWorktreesAsync)
.await;
let _ = self.cmd_tx.send(SessionCommand::FetchIssuesAsync).await;
}
fn dispatch_confirm_selection(&mut self) {
if let Some(idx) = self.popup_state.session_list.selected() {
self.switch_session(idx);
self.state = AppState::Normal;
self.popup_state.clear_input();
}
}
async fn dispatch_create_with_branch(&mut self, branch: String) {
match self.create_session_with_branch(Some(branch)).await {
Ok(()) => {
self.popup_state.clear_input();
self.state = AppState::Normal;
}
Err(e) => self.show_error(e.to_string()),
}
}
async fn dispatch_adopt_worktree(&mut self, path: &std::path::Path) {
match self.adopt_worktree(path).await {
Ok(()) => self.state = AppState::Normal,
Err(e) => self.show_error(e.to_string()),
}
}
async fn dispatch_delete_worktree(&mut self, path: std::path::PathBuf) {
self.popup_state.loading = LoadingOperation::Deleting { path: path.clone() };
let _ = self
.cmd_tx
.send(SessionCommand::DeleteWorktreeAsync { path })
.await;
}
async fn dispatch_pull_worktree(&mut self, path: std::path::PathBuf) {
self.popup_state.loading = LoadingOperation::Pulling { path: path.clone() };
let _ = self
.cmd_tx
.send(SessionCommand::PullWorktreeAsync { path })
.await;
}
async fn dispatch_select_issue(&mut self, number: u32) {
self.state = AppState::IssueLoading {
issue_number: number,
phase: LoadingOperation::FetchingIssue {
issue_number: number,
},
};
let _ = self
.cmd_tx
.send(SessionCommand::GenerateIssueActions {
issue_number: number,
})
.await;
}
fn dispatch_select_action(&mut self, index: usize) {
if let AppState::ActionSelectPopup { ref choices, .. } = self.state
&& let Some(choice) = choices.get(index)
{
self.state = AppState::ConfirmPermissions {
branch: choice.branch.clone(),
prompt: choice.prompt.clone(),
selected_yes: false,
};
}
}
fn dispatch_toggle_permissions(&mut self) {
if let AppState::ConfirmPermissions {
ref mut selected_yes,
..
} = self.state
{
*selected_yes = !*selected_yes;
}
}
async fn dispatch_confirm_permissions(&mut self) {
let Some((branch, prompt)) = (match &self.state {
AppState::ConfirmPermissions { branch, prompt, .. } => {
Some((branch.clone(), prompt.clone()))
}
_ => None,
}) else {
return;
};
let auto_input = vec![
crate::session::AutoInputStep {
data: b"/plan".to_vec(),
delay_ms: 0,
},
crate::session::AutoInputStep {
data: b"\r".to_vec(),
delay_ms: 50,
},
crate::session::AutoInputStep {
data: prompt.into_bytes(),
delay_ms: 500,
},
crate::session::AutoInputStep {
data: b"\r".to_vec(),
delay_ms: 50,
},
];
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
let _ = self
.cmd_tx
.send(SessionCommand::CreateWithAutoInput {
cmd: "claude".to_string(),
args: vec!["--dangerously-skip-permissions".to_string()],
cwd: None,
branch_name: Some(branch),
rows: self.size.0,
cols: self.size.1,
auto_input,
response_tx,
})
.await;
match response_rx.await {
Ok(Ok(_id)) => self.state = AppState::Normal,
Ok(Err(e)) => self.show_error(e.to_string()),
Err(_) => self.show_error("Session creation cancelled".to_string()),
}
}
pub async fn run(
&mut self,
mut event_rx: mpsc::Receiver<SessionEvent>,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> anyhow::Result<()> {
use crossterm::event::{poll, read};
use std::time::Duration;
terminal.draw(|f| self.render(f))?;
loop {
while let Ok(event) = event_rx.try_recv() {
self.handle_session_event(event);
}
self.process_pending_auto_input().await;
if poll(Duration::from_millis(16))? {
let event = read()?;
if let Some(action) = self.handle_event(&event) {
self.dispatch(action).await?;
}
}
if self.last_throbber_tick.elapsed() >= Duration::from_millis(THROBBER_TICK_MS) {
if self.popup_state.loading != LoadingOperation::Idle {
self.popup_state.throbber_state.calc_next();
}
if matches!(self.state, AppState::IssueLoading { .. }) {
self.issue_loading_throbber.calc_next();
}
self.last_throbber_tick = std::time::Instant::now();
}
if self.should_quit {
break;
}
terminal.draw(|f| self.render(f))?;
}
Ok(())
}
}
#[cfg(test)]
#[path = "tuiapp_tests.rs"]
mod tests;