feat: enhance workspace management and history tracking in application state

This commit is contained in:
asepharyana
2026-07-12 18:09:03 +07:00
parent a59feb23c4
commit 8a11a469f9
12 changed files with 112 additions and 20 deletions
+1
View File
@@ -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(),
+6 -1
View File
@@ -608,6 +608,9 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
format!("Starting workflow: {}", &script.chars().take(40).collect::<String>()),
));
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<String, String> = 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),
+15 -5
View File
@@ -72,6 +72,7 @@ pub struct InputState {
pub autocomplete_candidates: Vec<String>,
pub autocomplete_idx: usize,
pub autocomplete_visible: bool,
pub history_file: Option<PathBuf>,
}
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();
+29 -1
View File
@@ -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<String> = 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
+4 -2
View File
@@ -14,6 +14,7 @@ pub struct SubagentContext {
pub allowed_tools: Vec<String>,
pub max_steps: usize,
pub session_dir: PathBuf,
pub workspaces: Vec<PathBuf>,
}
/// 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(),
}
}
+1
View File
@@ -94,6 +94,7 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> 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();
+23 -5
View File
@@ -84,6 +84,8 @@ fn spawn_single_agent(
prompt: &str,
findings_snapshot: Vec<String>,
live: Option<&LiveStateFn>,
session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf],
) -> anyhow::Result<String> {
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<Vec<String>> {
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::<String>();
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<String, String>) -> anyhow::Result<String> {
run_workflow_tracked(script, args, None)
pub fn run_workflow(
script: &WorkflowScript,
args: &HashMap<String, String>,
session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf],
) -> anyhow::Result<String> {
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<String, String>,
live: Option<LiveStateFn>,
session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf],
) -> anyhow::Result<String> {
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() {
+2
View File
@@ -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.
+22 -5
View File
@@ -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);
}
+2
View File
@@ -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<PathBuf>) -> 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.
+4
View File
@@ -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")
}
+3 -1
View File
@@ -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,
)
}
}