use std::path::Path;
use std::sync::Arc;
use ratatui::widgets::ListState;
use crate::error::SessionError;
use crate::session::{SessionCommand, SessionEvent, SessionId, SessionStatus};
use crate::tui::popup::{ActionSelectPopupState, IssueItem, LoadingOperation, WorktreeItem};
use super::state::{AppState, SessionSnapshot};
use super::tuiapp::TuiApp;
fn adjust_list_selection(list_state: &mut ListState, len: usize) {
if let Some(selected) = list_state.selected() {
if selected >= len && len > 0 {
list_state.select(Some(len - 1));
} else if len == 0 {
list_state.select(None);
}
}
}
impl TuiApp {
pub(super) async fn resize_terminal(&mut self, rows: u16, cols: u16) {
self.size = (rows, cols);
for parser in self.terminal_buffers.values_mut() {
parser.set_size(rows, cols);
}
for session in &self.sessions {
let _ = self
.cmd_tx
.send(SessionCommand::Resize {
id: session.id,
rows,
cols,
})
.await;
}
}
pub(super) fn show_error(&mut self, message: String) {
let from_popup = matches!(self.state, AppState::WorkspacePopup);
self.state = AppState::ErrorPopup {
message,
from_popup,
};
}
pub(super) fn transition_to_error(&mut self, message: String) {
use super::state::FlowContext;
let from_popup = !matches!(self.state.flow_context(), FlowContext::Normal);
self.state = AppState::ErrorPopup {
message,
from_popup,
};
}
pub(super) fn return_to_base(&mut self) {
use super::state::FlowContext;
self.state = match self.state.flow_context() {
FlowContext::IssueFlow | FlowContext::WorkspaceFlow => AppState::WorkspacePopup,
FlowContext::Normal => AppState::Normal,
};
self.cleanup_flow_state();
}
pub(super) fn cleanup_flow_state(&mut self) {
self.action_select_state = crate::tui::popup::ActionSelectPopupState::new();
}
pub(super) fn cancel_issue_flow(&mut self) {
self.cleanup_flow_state();
self.state = AppState::WorkspacePopup;
}
pub(super) async fn create_session_with_branch(
&mut self,
branch: Option<String>,
) -> Result<(), SessionError> {
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
self.cmd_tx
.send(SessionCommand::Create {
cmd: "claude".to_string(),
args: self.default_args.clone(),
cwd: None,
branch_name: branch,
rows: self.size.0,
cols: self.size.1,
response_tx,
})
.await
.map_err(|_| {
SessionError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"Failed to send create command",
))
})?;
if let Ok(result) = response_rx.await {
result?;
}
Ok(())
}
pub(super) async fn adopt_worktree(&mut self, path: &Path) -> Result<(), SessionError> {
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
self.cmd_tx
.send(SessionCommand::CreateFromWorktree {
worktree_path: path.to_path_buf(),
cmd: "claude".to_string(),
args: self.default_args.clone(),
rows: self.size.0,
cols: self.size.1,
response_tx,
})
.await
.map_err(|_| {
SessionError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"Failed to send adopt command",
))
})?;
if let Ok(result) = response_rx.await {
result?;
}
Ok(())
}
pub(super) async fn close_session(&mut self, id: SessionId) {
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
if self
.cmd_tx
.send(SessionCommand::CloseSession { id, response_tx })
.await
.is_err()
{
self.show_error("Failed to send close command".to_string());
return;
}
match response_rx.await {
Ok(Ok(_worktree_path)) => {
self.sessions.retain(|s| s.id != id);
self.terminal_buffers.remove(&id);
self.scroll_offsets.remove(&id);
self.pending_sessions.remove(&id);
self.notification_manager
.lock()
.await
.unregister_session(&id);
if self.active_idx >= self.sessions.len() && !self.sessions.is_empty() {
self.active_idx = self.sessions.len() - 1;
}
adjust_list_selection(&mut self.popup_state.session_list, self.sessions.len());
}
Ok(Err(e)) => self.show_error(e.to_string()),
Err(_) => self.show_error("Session manager disconnected".to_string()),
}
}
pub(super) async fn refresh_worktrees(&mut self) {
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
if self
.cmd_tx
.send(SessionCommand::ListWorktrees { response_tx })
.await
.is_err()
{
return;
}
let timeout = tokio::time::timeout(std::time::Duration::from_millis(100), response_rx);
if let Ok(Ok(worktree_infos)) = timeout.await {
self.worktrees = worktree_infos
.into_iter()
.map(|info| WorktreeItem::new(info.branch, info.path, info.status))
.collect();
}
}
pub(super) async fn terminate_current_session(&mut self) -> Result<(), SessionError> {
if let Some(session) = self.sessions.get(self.active_idx) {
let id = session.id;
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
self.cmd_tx
.send(SessionCommand::Terminate { id, response_tx })
.await
.map_err(|_| {
SessionError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"Failed to send terminate command",
))
})?;
if let Ok(result) = response_rx.await {
result?;
}
}
Ok(())
}
pub(super) fn switch_session(&mut self, idx: usize) {
if idx < self.sessions.len() {
self.active_idx = idx;
}
}
pub(super) fn next_session(&mut self) {
if !self.sessions.is_empty() {
self.active_idx = (self.active_idx + 1) % self.sessions.len();
}
}
pub(super) fn prev_session(&mut self) {
if !self.sessions.is_empty() {
self.active_idx = if self.active_idx == 0 {
self.sessions.len() - 1
} else {
self.active_idx - 1
};
}
}
pub(super) async fn send_input(&mut self, data: &[u8]) {
let Some(session_id) = self.sessions.get(self.active_idx).map(|s| s.id) else {
return;
};
if data.contains(&b'\r') || data.contains(&b'\n') {
self.clear_pending(&session_id);
}
if let Err(e) = self
.cmd_tx
.send(SessionCommand::SendInput {
id: session_id,
data: data.to_vec(),
})
.await
{
tracing::error!("Failed to send input: {e}");
}
}
pub(super) fn scroll(&mut self, delta: i32) {
if let Some(session) = self.sessions.get(self.active_idx) {
let offset = self.scroll_offsets.entry(session.id).or_insert(0);
if delta < 0 {
*offset = offset.saturating_add(delta.unsigned_abs() as usize);
} else {
*offset = offset.saturating_sub(delta.unsigned_abs() as usize);
}
}
}
pub fn handle_session_event(&mut self, event: SessionEvent) {
match event {
SessionEvent::Created {
id,
branch,
auto_input,
} => self.handle_created(id, branch, auto_input),
SessionEvent::Output { id, data } => self.handle_output(id, &data),
SessionEvent::TitleChanged { id, title } => self.handle_title_changed(id, title),
SessionEvent::Terminated { id, exit_code } => self.handle_terminated(id, exit_code),
SessionEvent::Error { id, error } => tracing::error!("Session {id} error: {error}"),
SessionEvent::WorktreesRefreshed {
worktrees,
fetch_pending,
} => {
self.handle_worktrees_refreshed(worktrees, fetch_pending);
}
SessionEvent::WorktreeDeleted { path, result } => {
self.handle_worktree_deleted(&path, result);
}
SessionEvent::WorktreePulled { path, result } => {
self.handle_worktree_pulled(&path, result);
}
SessionEvent::HookReceived { event } => self.handle_hook_received(&event),
SessionEvent::IssuesFetched { result } => self.handle_issues_fetched(result),
SessionEvent::IssueFetched { issue_number } => self.handle_issue_fetched(issue_number),
SessionEvent::IssueActionsFetched {
issue_number,
result,
} => {
self.handle_issue_actions_fetched(issue_number, result);
}
SessionEvent::CostFetched { cost } => {
self.today_cost = cost;
}
}
}
fn handle_created(
&mut self,
id: SessionId,
branch: Option<String>,
auto_input: Vec<crate::session::AutoInputStep>,
) {
let name = format!("session-{}", self.sessions.len() + 1);
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let manager = Arc::clone(&self.notification_manager);
let name_clone = name.clone();
handle.spawn(async move {
manager.lock().await.register_session(id, name_clone);
});
}
self.sessions.push(SessionSnapshot {
id,
name,
status: SessionStatus::Running,
branch,
});
if !auto_input.is_empty() {
self.pending_auto_input = Some((id, auto_input));
self.auto_input_ready = false;
self.auto_input_next_at = None;
}
self.terminal_buffers
.insert(id, vt100::Parser::new(self.size.0, self.size.1, 1000));
self.active_idx = self.sessions.len() - 1;
}
fn handle_output(&mut self, id: SessionId, data: &[u8]) {
if let Some(parser) = self.terminal_buffers.get_mut(&id) {
parser.process(data);
}
if let Some((session_id, _)) = &self.pending_auto_input
&& *session_id == id
&& !self.auto_input_ready
{
const READY_MARKER: &[u8] = b"Claude Code";
if data.windows(READY_MARKER.len()).any(|w| w == READY_MARKER) {
self.auto_input_ready = true;
self.auto_input_next_at =
Some(std::time::Instant::now() + std::time::Duration::from_millis(100));
}
}
}
fn handle_title_changed(&mut self, id: SessionId, title: String) {
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == id) {
session.name.clone_from(&title);
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let manager = Arc::clone(&self.notification_manager);
handle.spawn(async move {
manager.lock().await.register_session(id, title);
});
}
}
}
fn handle_terminated(&mut self, id: SessionId, exit_code: Option<i32>) {
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == id) {
session.status = SessionStatus::Terminated { exit_code };
}
if matches!(self.state, AppState::Normal)
&& self
.sessions
.get(self.active_idx)
.is_some_and(|s| s.id == id)
{
self.state = AppState::SessionTerminatedPopup {
session_id: id,
exit_code,
};
}
}
fn handle_worktrees_refreshed(
&mut self,
worktrees: Vec<crate::worktree::WorktreeInfo>,
fetch_pending: bool,
) {
self.worktrees = worktrees
.into_iter()
.map(|info| WorktreeItem::new(info.branch, info.path, info.status))
.collect();
if !fetch_pending {
self.popup_state.loading = LoadingOperation::Idle;
}
if !self.worktrees.is_empty() && self.popup_state.worktree_list.selected().is_none() {
self.popup_state.worktree_list.select(Some(0));
}
}
fn handle_worktree_deleted(&mut self, path: &std::path::Path, result: Result<(), String>) {
self.popup_state.loading = LoadingOperation::Idle;
match result {
Ok(()) => {
self.worktrees.retain(|w| w.path != path);
adjust_list_selection(&mut self.popup_state.worktree_list, self.worktrees.len());
}
Err(e) => self.show_error(e),
}
}
fn handle_worktree_pulled(
&mut self,
path: &std::path::Path,
result: Result<crate::worktree::GitWorktreeStatus, String>,
) {
match result {
Ok(status) => {
if let Some(item) = self.worktrees.iter_mut().find(|w| w.path == path) {
item.status = status;
}
}
Err(e) => self.show_error(e),
}
self.popup_state.loading = LoadingOperation::Idle;
}
fn handle_hook_received(&mut self, event: &crate::hooks::HookEvent) {
if event.requires_attention() {
self.mark_pending(&event.session_id);
}
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let event_clone = event.clone();
let manager = Arc::clone(&self.notification_manager);
handle.spawn(async move {
manager.lock().await.handle(event_clone).await;
});
}
tracing::debug!(
"Received hook event: {:?} from session {}",
event.event_type,
event.session_id
);
}
fn handle_issues_fetched(&mut self, result: Result<Vec<crate::github::GitHubIssue>, String>) {
match result {
Ok(issues) => {
self.issues = issues
.into_iter()
.map(|i| IssueItem::new(i.number, &i.title, i.labels_display()))
.collect();
if !self.issues.is_empty() {
self.popup_state.issue_list.select(Some(0));
}
}
Err(e) => tracing::warn!("Failed to fetch issues: {e}"),
}
}
fn handle_issue_fetched(&mut self, issue_number: u32) {
if let AppState::IssueLoading {
issue_number: current,
..
} = self.state
&& current == issue_number
{
self.state = AppState::IssueLoading {
issue_number,
phase: LoadingOperation::GeneratingActions { issue_number },
};
}
}
fn handle_issue_actions_fetched(
&mut self,
issue_number: u32,
result: Result<Vec<crate::github::ActionChoice>, String>,
) {
match result {
Ok(choices) if !choices.is_empty() => {
self.state = AppState::ActionSelectPopup {
issue_number,
choices,
};
self.action_select_state = ActionSelectPopupState::new();
self.action_select_state.list_state.select(Some(0));
}
Ok(_) => self.transition_to_error("No action choices generated".to_string()),
Err(e) => self.transition_to_error(format!("Failed to generate actions: {e}")),
}
}
pub(super) async fn process_pending_auto_input(&mut self) {
use std::time::{Duration, Instant};
let Some((session_id, ref mut steps)) = self.pending_auto_input else {
return;
};
if !self.auto_input_ready {
return;
}
if let Some(next_at) = self.auto_input_next_at
&& Instant::now() < next_at
{
return;
}
let Some(step) = steps.first().cloned() else {
self.pending_auto_input = None;
self.auto_input_ready = false;
self.auto_input_next_at = None;
return;
};
let _ = self
.cmd_tx
.send(SessionCommand::SendInput {
id: session_id,
data: step.data,
})
.await;
steps.remove(0);
if steps.is_empty() {
self.pending_auto_input = None;
self.auto_input_ready = false;
self.auto_input_next_at = None;
} else if let Some(next_step) = steps.first() {
self.auto_input_next_at =
Some(Instant::now() + Duration::from_millis(next_step.delay_ms));
}
}
}