diff --git a/src/app/review/mod.rs b/src/app/review/mod.rs index 76bf775..f441756 100644 --- a/src/app/review/mod.rs +++ b/src/app/review/mod.rs @@ -313,6 +313,7 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> { ); let mut ctx = build_subagent_context(def); ctx.session_dir = state.session_dir.clone(); + ctx.workspaces = state.workspace_roots.clone(); let probe_result = probe_build_test( &state.workspace_roots, state.settings.verify_command.as_deref(), diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs index 7ca9971..071d4ab 100644 --- a/src/app/runtime/actions/mod.rs +++ b/src/app/runtime/actions/mod.rs @@ -608,6 +608,9 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { format!("Starting workflow: {}…", &script.chars().take(40).collect::()), )); + let session_dir = state.session_dir.clone(); + let workspace_roots = state.workspace_roots.clone(); + std::thread::spawn(move || { use std::collections::HashMap; use std::sync::Arc; @@ -654,7 +657,9 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { }); let args: HashMap = HashMap::new(); - let result = crate::app::workflow::engine::run_workflow_tracked(&wf, &args, Some(live)); + let result = crate::app::workflow::engine::run_workflow_tracked( + &wf, &args, Some(live), &session_dir, &workspace_roots, + ); let (kind, message) = match result { Ok(summary) => ("workflow_done".to_string(), summary), diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs index 86d59fc..8ef461f 100644 --- a/src/app/state/misc.rs +++ b/src/app/state/misc.rs @@ -72,6 +72,7 @@ pub struct InputState { pub autocomplete_candidates: Vec, pub autocomplete_idx: usize, pub autocomplete_visible: bool, + pub history_file: Option, } const COMMANDS: &[&str] = &[ @@ -110,6 +111,7 @@ impl InputState { autocomplete_candidates: Vec::new(), autocomplete_idx: 0, autocomplete_visible: false, + history_file: None, } } @@ -219,14 +221,22 @@ impl InputState { } } - /// Submit the current buffer: push it to history, clear the buffer, - /// and return the submitted text. - /// - /// Return: the text that was in the buffer before clearing. pub fn submit(&mut self) -> String { let result = self.buffer.clone(); if !result.is_empty() { - self.history.push(result.clone()); + if self.history.last() != Some(&result) { + self.history.push(result.clone()); + if let Some(ref path) = self.history_file { + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + use std::io::Write; + let _ = writeln!(file, "{}", result); + } + } + } self.history_idx = None; } self.buffer.clear(); diff --git a/src/app/state/rest.rs b/src/app/state/rest.rs index 0c48ada..a412cac 100644 --- a/src/app/state/rest.rs +++ b/src/app/state/rest.rs @@ -104,7 +104,7 @@ impl AppStateRest { tracing::warn!("[state] session_dir has no file_name component, using empty session_id"); String::new() }); - let state = AppStateRest { + let mut state = AppStateRest { settings, app_config, @@ -133,6 +133,34 @@ impl AppStateRest { quit: false, }; + // Load project-specific history + let base_dir = state.memory_dir.parent().unwrap_or(&state.memory_dir); + if let Some(root) = state.workspace_roots.first() { + if let Ok(abs_root) = std::fs::canonicalize(root) { + use sha2::Digest; + let mut hasher = sha2::Sha256::new(); + hasher.update(abs_root.to_string_lossy().as_bytes()); + let hash_hex = format!("{:x}", hasher.finalize()); + let folder_name = abs_root.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "root".to_string()); + let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]); + let history_dir = base_dir.join("history"); + let _ = std::fs::create_dir_all(&history_dir); + let history_file = history_dir.join(history_filename); + + if let Ok(content) = std::fs::read_to_string(&history_file) { + let history: Vec = content + .lines() + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .collect(); + state.input.history = history; + } + state.input.history_file = Some(history_file); + } + } + // Fire-and-forget background LSP provisioning. // // Flow: spawn OS thread -> provision_all() probes/installs every diff --git a/src/app/subagent/context.rs b/src/app/subagent/context.rs index 6c0daeb..3ef9c23 100644 --- a/src/app/subagent/context.rs +++ b/src/app/subagent/context.rs @@ -14,6 +14,7 @@ pub struct SubagentContext { pub allowed_tools: Vec, pub max_steps: usize, pub session_dir: PathBuf, + pub workspaces: Vec, } /// Build a `SubagentContext` from an `AgentDefinition`. @@ -23,8 +24,8 @@ pub struct SubagentContext { /// fall back to an empty list (i.e. "all tools allowed") for other roles. /// `max_steps` is read from the definition, defaulting to 25 if absent. /// -/// Return: a context with empty `system_prompt` and `session_dir`, -/// resolved `max_steps`, and the resolved allowed-tool list. +/// Return: a context with empty `system_prompt`, empty `workspaces`, +/// empty `session_dir`, resolved `max_steps`, and the resolved allowed-tool list. pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext { let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| { if def.role == "reviewer" { @@ -39,5 +40,6 @@ pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext { allowed_tools, max_steps, session_dir: PathBuf::new(), + workspaces: Vec::new(), } } diff --git a/src/app/subagent/engine.rs b/src/app/subagent/engine.rs index 7bf2da5..062d336 100644 --- a/src/app/subagent/engine.rs +++ b/src/app/subagent/engine.rs @@ -94,6 +94,7 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender) -> an let tool_ctx = crate::tool::ToolCtx::builder() .session_dir(ctx.session_dir.clone()) + .workspaces(ctx.workspaces.clone()) .origin(crate::app::state::types::Origin::SubAgent) .build(); diff --git a/src/app/workflow/engine.rs b/src/app/workflow/engine.rs index 6ca21c8..f7a8441 100644 --- a/src/app/workflow/engine.rs +++ b/src/app/workflow/engine.rs @@ -84,6 +84,8 @@ fn spawn_single_agent( prompt: &str, findings_snapshot: Vec, live: Option<&LiveStateFn>, + session_dir: &std::path::Path, + workspaces: &[std::path::PathBuf], ) -> anyhow::Result { use crate::app::subagent::context::build_subagent_context; use crate::app::subagent::engine::run_subagent; @@ -104,6 +106,8 @@ fn spawn_single_agent( let def = AgentDefinition::new(agent_name.to_string(), "coder".to_string()) .with_max_steps(50); let mut ctx = build_subagent_context(def); + ctx.session_dir = session_dir.to_path_buf(); + ctx.workspaces = workspaces.to_vec(); let findings_section = if findings_snapshot.is_empty() { String::new() @@ -182,6 +186,8 @@ pub fn execute_primitive( concurrency_cap: usize, continue_on_error: bool, live: Option<&LiveStateFn>, + session_dir: &std::path::Path, + workspaces: &[std::path::PathBuf], ) -> anyhow::Result> { match primitive { ScriptPrimitive::Agent(prompt) => { @@ -189,7 +195,7 @@ pub fn execute_primitive( let findings_snapshot = FINDINGS.lock().map(|f| f.clone()).unwrap_or_default(); let agent_id = uuid::Uuid::new_v4().to_string(); let agent_name = resolved.chars().take(40).collect::(); - match spawn_single_agent(&agent_id, &agent_name, &resolved, findings_snapshot, live) { + match spawn_single_agent(&agent_id, &agent_name, &resolved, findings_snapshot, live, session_dir, workspaces) { Ok(text) => Ok(vec![text]), Err(e) => { if continue_on_error { @@ -219,12 +225,16 @@ pub fn execute_primitive( let results = Arc::clone(&results); let cap = concurrency_cap; let live_clone = live.cloned(); + let session_dir = session_dir.to_path_buf(); + let workspaces = workspaces.to_vec(); std::thread::spawn(move || { let _permit = sem.acquire(); let result = execute_primitive( &script, &args, cap, continue_on_error, live_clone.as_ref(), + &session_dir, + &workspaces, ); if let Ok(mut locked) = results.lock() { locked.push((idx, result)); @@ -258,7 +268,7 @@ pub fn execute_primitive( // the global FINDINGS mutex. let mut all = Vec::new(); for (idx, script) in scripts.iter().enumerate() { - match execute_primitive(script, args, concurrency_cap, continue_on_error, live) { + match execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces) { Ok(outputs) => all.extend(outputs), Err(e) => { if continue_on_error { @@ -273,7 +283,7 @@ pub fn execute_primitive( } ScriptPrimitive::Phase { name: _name, script } => { - execute_primitive(script, args, concurrency_cap, continue_on_error, live) + execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces) } } } @@ -282,8 +292,13 @@ pub fn execute_primitive( /// summary string. Uses no live-state callback. /// /// Return: a human-readable summary string. -pub fn run_workflow(script: &WorkflowScript, args: &HashMap) -> anyhow::Result { - run_workflow_tracked(script, args, None) +pub fn run_workflow( + script: &WorkflowScript, + args: &HashMap, + session_dir: &std::path::Path, + workspaces: &[std::path::PathBuf], +) -> anyhow::Result { + run_workflow_tracked(script, args, None, session_dir, workspaces) } /// Run a `WorkflowScript` with real-time live-state callbacks so the TUI @@ -297,6 +312,8 @@ pub fn run_workflow_tracked( script: &WorkflowScript, args: &HashMap, live: Option, + session_dir: &std::path::Path, + workspaces: &[std::path::PathBuf], ) -> anyhow::Result { if let Ok(mut findings) = FINDINGS.lock() { findings.clear(); @@ -312,6 +329,7 @@ pub fn run_workflow_tracked( let results = execute_primitive( &script.script, args, concurrency_cap, script.options.continue_on_error, live_ref, + session_dir, workspaces, )?; let summary = if results.is_empty() { diff --git a/src/ipc/protocol.rs b/src/ipc/protocol.rs index 95048f4..7a67bdf 100644 --- a/src/ipc/protocol.rs +++ b/src/ipc/protocol.rs @@ -47,6 +47,8 @@ pub enum ClientRequest { Submit(String), Resize(u16, u16), Close, + ScrollUp, + ScrollDown, } /// Flattened chat message sent from daemon to client for transcript display. diff --git a/src/main.rs b/src/main.rs index 5fd4d30..36e04e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ use std::sync::Mutex; use anyhow::Result; use crossterm::execute; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; +use crossterm::event::{EnableMouseCapture, DisableMouseCapture}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; @@ -116,7 +117,7 @@ fn run_single_process() -> Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; @@ -124,7 +125,7 @@ fn run_single_process() -> Result<()> { let run_result = run_loop(&mut state, &mut terminal); let mut restore_stdout = io::stdout(); - let _ = execute!(restore_stdout, LeaveAlternateScreen); + let _ = execute!(restore_stdout, LeaveAlternateScreen, DisableMouseCapture); let _ = disable_raw_mode(); if let Err(e) = run_result { @@ -413,6 +414,14 @@ fn run_daemon() -> Result<()> { apply_action(&mut state, Action::Resize(w, h)); apply_action(&mut state, Action::Tick); } + ClientRequest::ScrollUp => { + apply_action(&mut state, Action::ScrollUp); + apply_action(&mut state, Action::Tick); + } + ClientRequest::ScrollDown => { + apply_action(&mut state, Action::ScrollDown); + apply_action(&mut state, Action::Tick); + } ClientRequest::Close => { running = false; } @@ -449,7 +458,7 @@ fn run_daemon() -> Result<()> { /// through the daemon, since the daemon has no notion of "this client /// wants to leave" beyond the explicit `Close` request. fn run_attach(session_id: &str) -> Result<()> { - use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; + use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; use ipc::protocol::ClientRequest; let store = model::store::Store::new(); @@ -460,7 +469,7 @@ fn run_attach(session_id: &str) -> Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; @@ -512,6 +521,13 @@ fn run_attach(session_id: &str) -> Result<()> { Event::Resize(w, h) => { client.send(&ClientRequest::Resize(w, h))?; } + Event::Mouse(mouse_event) => { + if mouse_event.kind == MouseEventKind::ScrollUp { + client.send(&ClientRequest::ScrollUp)?; + } else if mouse_event.kind == MouseEventKind::ScrollDown { + client.send(&ClientRequest::ScrollDown)?; + } + } _ => {} } } else { @@ -544,7 +560,7 @@ fn run_attach(session_id: &str) -> Result<()> { })?; } - let _ = execute!(io::stdout(), LeaveAlternateScreen); + let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); let _ = disable_raw_mode(); let _ = client_state.settings.save(); @@ -569,6 +585,7 @@ fn run_loop( if let Err(ref _e) = result { let _ = terminal.clear(); + let _ = execute!(io::stdout(), DisableMouseCapture); let _ = disable_raw_mode(); let _ = execute!(io::stdout(), LeaveAlternateScreen); } diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 8934725..6c53d87 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -110,6 +110,8 @@ impl Default for ToolCtxBuilder { impl ToolCtxBuilder { /// Set the session directory. pub fn session_dir(mut self, v: PathBuf) -> Self { self.session_dir = v; self } + /// Set the workspaces. + pub fn workspaces(mut self, v: Vec) -> Self { self.workspaces = v; self } /// Set the origin (main process vs. daemon-attached). pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self } /// Set the lsp_manager. diff --git a/src/tool/spawn.rs b/src/tool/spawn.rs index 91b5404..bfb3e27 100644 --- a/src/tool/spawn.rs +++ b/src/tool/spawn.rs @@ -110,6 +110,8 @@ impl Tool for SpawnAgents { max_concurrency, true, live.as_ref(), + &_ctx.session_dir, + &_ctx.workspaces, )?; format_results(results, "parallel") } @@ -193,6 +195,8 @@ impl Tool for SpawnPipeline { 1, false, live.as_ref(), + &_ctx.session_dir, + &_ctx.workspaces, )?; format_results(results, "pipeline") } diff --git a/src/tool/workflow.rs b/src/tool/workflow.rs index 619f0ca..240a6f8 100644 --- a/src/tool/workflow.rs +++ b/src/tool/workflow.rs @@ -76,7 +76,9 @@ impl Tool for WorkflowRun { }) .unwrap_or_default(); - crate::app::workflow::engine::run_workflow(&workflow_script, &workflow_args) + crate::app::workflow::engine::run_workflow( + &workflow_script, &workflow_args, &_ctx.session_dir, &_ctx.workspaces, + ) } }