diff --git a/cats/__pycache__/cat.cpython-314.pyc b/cats/__pycache__/cat.cpython-314.pyc index f32bc4f..f245b1c 100644 Binary files a/cats/__pycache__/cat.cpython-314.pyc and b/cats/__pycache__/cat.cpython-314.pyc differ diff --git a/cats/cat.py b/cats/cat.py index 408c329..2a70a83 100644 --- a/cats/cat.py +++ b/cats/cat.py @@ -16,6 +16,10 @@ class Cat: return f"{self.name} purrs loudly." return f"{self.name} tolerates the petting." + def begs(self) -> str: + self.mood = "needy" + return f"{self.name} begs for snacks with big, dramatic eyes." + def describe(self) -> str: return f"{self.name} is a {self.age} year old cat feeling {self.mood}." diff --git a/cats/test_cat.py b/cats/test_cat.py index 1b9a8b9..8a2133d 100644 --- a/cats/test_cat.py +++ b/cats/test_cat.py @@ -41,6 +41,14 @@ def test_describe(): assert "neutral" in desc +def test_begs_changes_mood(): + cat = Cat("Nala", 3) + result = cat.begs() + assert cat.mood == "needy" + assert "begs" in result + assert "snacks" in result + + def test_create_cat_helper(): cat = create_cat("Simba", 2) assert isinstance(cat, Cat) @@ -55,6 +63,7 @@ def run_all_tests() -> None: test_pet_when_neutral, test_pet_when_happy, test_describe, + test_begs_changes_mood, test_create_cat_helper, ] diff --git a/src/domain/prompting/user.rs b/src/domain/prompting/user.rs index b966975..8c0b80b 100644 --- a/src/domain/prompting/user.rs +++ b/src/domain/prompting/user.rs @@ -8,29 +8,7 @@ pub fn get_user_prompt(_model_type: ModelType, request: &dyn Request) -> String if request.mode() == AgentModeType::BuildFromPlan { return _get_build_from_plan_prompt(request); } - - let mut formatted = String::new(); - - // Add history section - formatted.push_str("previous requests history:\n"); - - if request.history().is_empty() { - formatted.push_str(" (no previous requests)\n"); - } else { - for req in request.history() { - formatted.push_str(&format!(" - request: {}\n", req.prompt())); - if let Some(result) = req.result_summary() { - formatted.push_str(&format!(" result: {}\n", result)); - } else { - formatted.push_str(" result: (no result yet)\n"); - } - } - } - - // Add current request - formatted.push_str(&format!("CURRENT REQUEST: {}", request.current_request())); - - formatted + request.current_request().to_string() } fn _get_build_from_plan_prompt(request: &dyn Request) -> String { @@ -38,11 +16,11 @@ fn _get_build_from_plan_prompt(request: &dyn Request) -> String { for item in &todo_list.items { if item.status != TodoListStatus::Completed { return format!( - "Title: {}\n\nDescription:\n{}", + "TODO Item Title: {}\n\nDescription:\n{}", item.title, item.description ); } } } - "All TODO items are completed.".to_string() + "Report very briefly what was done without using any tools, since all the TODO items are completed.".to_string() } \ No newline at end of file diff --git a/src/domain/workflow/workflow.rs b/src/domain/workflow/workflow.rs index e39e848..8e294ed 100644 --- a/src/domain/workflow/workflow.rs +++ b/src/domain/workflow/workflow.rs @@ -88,13 +88,6 @@ impl Workflow { self.chain.add_history(request.get_history_steps()); self.chain.set_todo_list(request.get_session_plan()); - // Add current user message as a step - let user_step = ChainStep::user_message( - request.current_request().to_string(), - request.images().to_vec(), - ); - self.chain.steps.push(user_step); - let result = self._run(request, cancel, max_tool_calls, None); self._end_tracing(); result diff --git a/src/infrastructure/api_clients/openai/dto/request.rs b/src/infrastructure/api_clients/openai/dto/request.rs index a627031..cacaa74 100644 --- a/src/infrastructure/api_clients/openai/dto/request.rs +++ b/src/infrastructure/api_clients/openai/dto/request.rs @@ -3,6 +3,7 @@ use crate::domain::{Chain, ModelType}; use crate::domain::prompting::format_todo_list_message; use serde::Serialize; use serde_json::Value; +use crate::domain::workflow::step::StepType::{AssistantResponse, ToolCall, UserMessage}; #[derive(Debug, Serialize)] pub struct RequestDTO { @@ -61,6 +62,13 @@ impl InputContent { image_url: data_url, } } + + pub fn function(text: String) -> Self { + Self::Text { + kind: "function".to_string(), + text, + } + } } #[derive(Debug, Serialize)] @@ -74,18 +82,21 @@ pub(super) struct ToolDto { const ROLE_USER: &str = "user"; const ROLE_SYSTEM: &str = "system"; +const ROLE_ASSISTANT: &str = "assistant"; +const INPUT_STATUS_IN_PROGRESS: &str = "in_progress"; +const INPUT_STATUS_COMPLETED: &str = "completed"; +const INPUT_STATUS_FAILED: &str = "failed"; impl RequestDTO { pub(crate) fn new( model: String, system_prompt: String, - _user_prompt: String, - _images: Vec, + user_prompt: String, tools: &[&dyn Tool], chain: &crate::domain::workflow::Chain, ) -> Self { // User request is now part of the chain, no need to add separately - let input = InputDto::from_chain(chain); + let input = InputDto::build(user_prompt, chain); Self { model, @@ -113,40 +124,26 @@ impl ToolDto { } impl InputDto { - fn from_chain(chain: &Chain) -> Vec { + fn build(user_prompt: String, chain: &Chain) -> Vec { let steps = chain.get_steps_with_history(); - let num_steps = steps.len(); let mut result: Vec = steps .iter() .enumerate() .map(|(idx, step)| { // Determine status - let is_last_step = idx == num_steps - 1; - let is_user_message = step.step_type == "user_message"; + let is_user_message = step.step_type == UserMessage.as_str(); - let status = if is_last_step && is_user_message { - // Current user message is in progress - "in_progress" - } else if step.is_successful.unwrap_or(false) { - "completed" + let status = if is_user_message || step.is_successful.unwrap_or(false) { + INPUT_STATUS_COMPLETED } else { - "failed" - }; - - // Determine role based on step type - let role = if step.step_type == "assistant_response" { - "assistant".to_string() - } else if is_user_message { - ROLE_USER.to_string() - } else { - ROLE_SYSTEM.to_string() // Tool results remain "system" + INPUT_STATUS_FAILED }; + let mut role = ROLE_ASSISTANT.to_string(); // Build content items - let content_items = if step.step_type == "assistant_response" { - vec![InputContent::output_text(step.get_output(ModelType::OpenAI))] - } else if is_user_message { + let content_items = if is_user_message { + role = ROLE_USER.to_string(); // For user messages, include text and images let mut items = vec![InputContent::text(step.input_payload.clone())]; @@ -159,7 +156,7 @@ impl InputDto { items } else { - vec![InputContent::text(step.get_output(ModelType::OpenAI))] + vec![InputContent::output_text(step.get_output(ModelType::OpenAI))] }; Self { @@ -183,12 +180,20 @@ impl InputDto { content: vec![InputContent::text(todo_message)], role: ROLE_SYSTEM.to_string(), kind: "message".to_string(), - status: "completed".to_string(), + status: INPUT_STATUS_COMPLETED.to_string(), }; result.push(todo_input); } } + // and adding the current user message at the end + result.push(Self { + content: vec![InputContent::text(user_prompt)], + role: ROLE_USER.to_string(), + kind: "message".to_string(), + status: INPUT_STATUS_IN_PROGRESS.to_string(), + }); + result } } diff --git a/src/infrastructure/api_clients/openai/translator.rs b/src/infrastructure/api_clients/openai/translator.rs index f179da1..058fb41 100644 --- a/src/infrastructure/api_clients/openai/translator.rs +++ b/src/infrastructure/api_clients/openai/translator.rs @@ -27,7 +27,6 @@ pub fn build_request_dto( model.to_string(), system_prompt.to_string(), user_prompt.to_string(), - images.to_vec(), tools, chain, ); diff --git a/src/infrastructure/cli/components/commands_menu.rs b/src/infrastructure/cli/components/commands_menu.rs index b3ae09b..d654850 100644 --- a/src/infrastructure/cli/components/commands_menu.rs +++ b/src/infrastructure/cli/components/commands_menu.rs @@ -27,6 +27,10 @@ pub fn commands_items() -> Vec { label: "Settings", description: "Toggle behavior trees and tracing", }, + CommandsMenuItem { + label: "Continue", + description: "Resume a previous session", + }, CommandsMenuItem { label: "Exit", description: "Close the application", diff --git a/src/infrastructure/cli/controls.rs b/src/infrastructure/cli/controls.rs index c0f776c..687205a 100644 --- a/src/infrastructure/cli/controls.rs +++ b/src/infrastructure/cli/controls.rs @@ -8,8 +8,8 @@ use crate::infrastructure::cli::helpers::{ delete_next_char, delete_prev_char, insert_char, next_char_boundary, prev_char_boundary, }; use crate::infrastructure::cli::state::{ - FileChangesViewMode, LoadStatus, PopupInput, PopupState, TodoListViewMode, UiMode, UiState, - MAIN_BODY_SCROLL_STEP, + FileChangesViewMode, LoadStatus, PopupInput, PopupState, SessionPreview, TodoListViewMode, + UiMode, UiState, MAIN_BODY_SCROLL_STEP, }; use crate::infrastructure::cli::views::main_view::{ model_entries, openai_available_entries, openai_available_filtered, @@ -336,7 +336,8 @@ fn handle_commands_key( 0 => select_openai_model::open_model_popup(conn, state), 1 => open_mode_popup(state), 2 => change_settings::open_settings_popup(conn, state), - 3 => request_exit(bus, state), + 3 => open_continue_popup(conn, state), + 4 => request_exit(bus, state), _ => state.mode = UiMode::Normal, }, _ => {} @@ -607,6 +608,31 @@ fn handle_popup_key( _ => {} } } + PopupState::ContinueSelect { sessions, selected } => { + let count = sessions.len(); + match key.code { + KeyCode::Esc => state.mode = UiMode::Normal, + KeyCode::Up => { + if *selected > 0 { + *selected -= 1; + } + } + KeyCode::Down => { + if *selected + 1 < count { + *selected += 1; + } + } + KeyCode::Enter => { + let session_id = sessions[*selected].id; + state.session_id = Some(session_id); + let _ = bus.ui_to_agent_tx.send(UiToAgentEvent::SessionContinueEvent { + session_id, + }); + state.mode = UiMode::Normal; + } + _ => {} + } + } } Ok(()) @@ -1242,6 +1268,9 @@ fn submit_input(bus: &EventBus, conn: &DbPool, state: &mut UiState) -> Result<() ":m" | ":model" | "/m" | "/model" => { select_openai_model::open_model_popup(conn, state); } + "/continue" | "/c" => { + open_continue_popup(conn, state); + } _ => { let prompt = value.trim_end().to_string(); @@ -1258,7 +1287,8 @@ fn submit_input(bus: &EventBus, conn: &DbPool, state: &mut UiState) -> Result<() .send(UiToAgentEvent::RequestEvent { prompt, images, - mode: state.agent_mode, // Pass current mode + mode: state.agent_mode, + session_id: state.session_id, }) .map_err(|e| e.to_string())?; } @@ -1268,6 +1298,79 @@ fn submit_input(bus: &EventBus, conn: &DbPool, state: &mut UiState) -> Result<() Ok(()) } +fn open_continue_popup(conn: &DbPool, state: &mut UiState) { + let result = (|| -> Result<(), String> { + let conn_guard = conn + .get() + .map_err(|e| format!("Failed to get connection: {}", e))?; + + let project_name = std::env::current_dir() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_else(|| "default".to_string()); + + let projects_repo = + crate::repository::ProjectsRepository::new(&*conn_guard); + let project = projects_repo + .find_by_name(&project_name)? + .ok_or_else(|| "not_found".to_string())?; + + let sessions_repo = + crate::repository::SessionsRepository::new(&*conn_guard); + let rows = sessions_repo.find_by_project_recent(project.id, 5)?; + + if rows.is_empty() { + return Err("no_sessions".to_string()); + } + + let sessions: Vec = rows + .into_iter() + .map(|row| { + let epoch: u64 = row.created_at.parse().unwrap_or(0); + SessionPreview { + id: row.id, + name: row.name, + created_at: crate::infrastructure::cli::state::format_relative_time(epoch), + } + }) + .collect(); + + state.mode = UiMode::Popup(PopupState::ContinueSelect { + sessions, + selected: 0, + }); + Ok(()) + })(); + + match result { + Ok(()) => {} + Err(e) if e == "not_found" => { + state.push_progress(crate::infrastructure::cli::state::ProgressEntry { + text: "No project found for current directory".to_string(), + kind: crate::infrastructure::cli::state::ProgressKind::Error, + active: false, + request_id: None, + }); + } + Err(e) if e == "no_sessions" => { + state.push_progress(crate::infrastructure::cli::state::ProgressEntry { + text: "No previous sessions in this project".to_string(), + kind: crate::infrastructure::cli::state::ProgressKind::Info, + active: false, + request_id: None, + }); + } + Err(e) => { + state.push_progress(crate::infrastructure::cli::state::ProgressEntry { + text: format!("Error loading sessions: {}", e), + kind: crate::infrastructure::cli::state::ProgressKind::Error, + active: false, + request_id: None, + }); + } + } +} + fn open_mode_popup(state: &mut UiState) { let selected = match state.agent_mode { AgentModeType::Build => 0, diff --git a/src/infrastructure/cli/state.rs b/src/infrastructure/cli/state.rs index ab1899a..a4b29e6 100644 --- a/src/infrastructure/cli/state.rs +++ b/src/infrastructure/cli/state.rs @@ -197,6 +197,46 @@ pub enum PopupState { scope: String, selected: usize, }, + ContinueSelect { + sessions: Vec, + selected: usize, + }, +} + +#[derive(Debug, Clone)] +pub struct SessionPreview { + pub id: i64, + pub name: String, + pub created_at: String, +} + +pub fn format_relative_time(epoch_secs: u64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let delta = now.saturating_sub(epoch_secs); + if delta < 60 { + return "just now".to_string(); + } + let mins = delta / 60; + if mins < 60 { + return format!("{} min ago", mins); + } + let hours = mins / 60; + if hours < 24 { + return format!("{} hr ago", hours); + } + let days = hours / 24; + if days < 7 { + return format!("{} days ago", days); + } + let weeks = days / 7; + if weeks < 5 { + return format!("{} weeks ago", weeks); + } + let months = days / 30; + format!("{} months ago", months) } pub struct UiState { @@ -222,6 +262,7 @@ pub struct UiState { pub todo_list: Option, pub should_quit: bool, pub attached_images: Vec, + pub session_id: Option, } impl UiState { @@ -260,6 +301,7 @@ impl UiState { todo_list: None, should_quit: false, attached_images: Vec::new(), + session_id: None, } } diff --git a/src/infrastructure/cli/views/main_view.rs b/src/infrastructure/cli/views/main_view.rs index 90bac7b..587eda9 100644 --- a/src/infrastructure/cli/views/main_view.rs +++ b/src/infrastructure/cli/views/main_view.rs @@ -434,6 +434,58 @@ fn render_popup( theme, ); } + PopupState::ContinueSelect { sessions, selected } => { + if sessions.is_empty() { + return; + } + let height = (sessions.len() + 4) as u16; + let width = min( + (size.width as f32 * 0.7) as u16, + size.width.saturating_sub(2), + ); + let height = height.min(size.height.saturating_sub(2)); + let area = centered_rect(size, width, height); + frame.render_widget(Clear, area); + + let inner = area.inner(&ratatui::layout::Margin { + horizontal: 1, + vertical: 1, + }); + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(1)]) + .split(inner); + + let title = Paragraph::new(Text::from("Continue Session")) + .style( + Style::default() + .fg(theme.info_text) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center); + frame.render_widget(title, sections[0]); + + let items: Vec = sessions + .iter() + .map(|s| { + let label = format!("{} ({})", s.name, s.created_at); + ListItem::new(Line::from(Span::styled( + label, + Style::default().fg(theme.info_text), + ))) + }) + .collect(); + let list = List::new(items) + .highlight_style( + Style::default() + .fg(theme.active) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + let mut list_state = list_state(*selected); + frame.render_stateful_widget(list, sections[1], &mut list_state); + } } } diff --git a/src/infrastructure/event_bus.rs b/src/infrastructure/event_bus.rs index e271ad4..8c218e2 100644 --- a/src/infrastructure/event_bus.rs +++ b/src/infrastructure/event_bus.rs @@ -46,6 +46,10 @@ pub enum UiToAgentEvent { prompt: String, images: Vec, mode: AgentModeType, // NEW: Pass mode to agent + session_id: Option, + }, + SessionContinueEvent { + session_id: i64, }, PermissionUpdateEvent { request_id: u64, diff --git a/src/infrastructure/event_controller.rs b/src/infrastructure/event_controller.rs index 6a5d188..e416ebf 100644 --- a/src/infrastructure/event_controller.rs +++ b/src/infrastructure/event_controller.rs @@ -131,11 +131,92 @@ impl EventController { }; match event { - UiToAgentEvent::RequestEvent { prompt, images, mode } => { + UiToAgentEvent::SessionContinueEvent { session_id } => { if worker_running { send_failure_and_continue!(self, "Request already running"); } + self.current_session_id = Some(session_id); + + let conn_guard = match self.conn.get() { + Ok(guard) => guard, + Err(e) => { + send_failure_and_continue!(self, format!("Failed to get connection: {}", e)); + } + }; + + let sessions_repo = crate::repository::SessionsRepository::new(&*conn_guard); + let session_row = match sessions_repo.find_by_id(session_id) { + Ok(Some(row)) => row, + Ok(None) => { + send_failure_and_continue!(self, format!("Session {} not found", session_id)); + } + Err(e) => { + send_failure_and_continue!(self, e); + } + }; + + let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::SessionStartedEvent { + title: session_row.name.clone(), + }); + + drop(conn_guard); + + let requests_repo = crate::repository::SessionRequestsRepository::new(self.conn.clone()); + let requests = match requests_repo.find_by_session(session_id) { + Ok(rows) => rows, + Err(e) => { + send_failure_and_continue!(self, e); + } + }; + + if let Some(last_request) = requests.last() { + let label = request_label(&last_request.prompt); + let status = match &last_request.result_summary { + Some(summary) if summary.starts_with("Success") => RequestStatus::Success, + Some(_) => RequestStatus::Failure, + None => RequestStatus::Failure, + }; + let summary = last_request.result_summary.clone(); + + let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::RequestStartedEvent { + request_id: 0, + label, + prompt: last_request.prompt.clone(), + }); + let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::RequestFinishedEvent { + request_id: 0, + status, + summary, + final_message: None, + }); + } + + // Load TODO list if it exists and is not fully completed + let conn_guard2 = match self.conn.get() { + Ok(guard) => guard, + Err(_) => continue, + }; + let todo_repo = crate::repository::TodoListRepository::new(&*conn_guard2); + if let Ok(Some(todo_row)) = todo_repo.get_by_session(session_id) { + if let Ok(todo_list) = serde_json::from_str::(&todo_row.content) { + if !todo_list.items.is_empty() && !todo_list.is_completed() { + let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::TodoListUpdateEvent { + items: todo_list.items, + }); + } + } + } + } + UiToAgentEvent::RequestEvent { prompt, images, mode, session_id } => { + if worker_running { + send_failure_and_continue!(self, "Request already running"); + } + + if let Some(sid) = session_id { + self.current_session_id = Some(sid); + } + let engine = match self.engine.clone() { Some(engine) => engine, None => { diff --git a/src/repository/sessions.rs b/src/repository/sessions.rs index 9febc66..c1ed335 100644 --- a/src/repository/sessions.rs +++ b/src/repository/sessions.rs @@ -62,6 +62,30 @@ impl<'a> SessionsRepository<'a> { Ok(result) } + + pub fn find_by_project_recent( + &self, + project_id: i64, + limit: usize, + ) -> Result, String> { + let mut stmt = self + .conn + .prepare( + "SELECT id, project_id, name, created_at FROM sessions WHERE project_id = ? ORDER BY created_at DESC LIMIT ?", + ) + .map_err(|e| e.to_string())?; + + let rows = stmt + .query_map(params![project_id, limit as i64], SessionRow::from_row) + .map_err(|e| e.to_string())?; + + let mut sessions = Vec::new(); + for row in rows { + sessions.push(row.map_err(|e| e.to_string())?); + } + + Ok(sessions) + } } fn chrono_now() -> String {