Implement chat and markdown views, enhance status bar, and add workflow panel

- Added `chat.rs` for rendering chat messages with timestamps and roles.
- Introduced `markdown.rs` for rendering markdown content with styling.
- Created `status.rs` to display the application status bar with session and message counts.
- Developed `workflow.rs` to show the current workflow status, including tool calls and active jobs.
- Established a `theme.rs` for centralized color management across the UI.
- Updated `mod.rs` to include new modules and manage rendering logic.
This commit is contained in:
asepharyana
2026-07-11 13:16:10 +07:00
parent 7cb4ae6708
commit cc03bd79b6
131 changed files with 10994 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
use std::fs;
use std::path::PathBuf;
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use super::super::Tool;
use super::super::ToolCtx;
use super::super::resolve_path;
use super::helpers::arg_str;
pub struct Edit;
impl Tool for Edit {
fn name(&self) -> &'static str {
"edit"
}
fn description(&self) -> &'static str {
"Replace a string in a file with a new string. The old string must be unique unless replace_all is true."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to edit (relative to workspace root)"
},
"old": {
"type": "string",
"description": "The exact text to replace"
},
"new": {
"type": "string",
"description": "The replacement text"
},
"replace_all": {
"type": "boolean",
"description": "Replace all occurrences instead of requiring uniqueness"
},
"reason": {
"type": "string",
"description": "Reason for the change (must be non-empty)"
}
},
"required": ["path", "old", "new", "reason"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?;
let old = arg_str(args, "old")?;
let new_str = arg_str(args, "new")?;
let reason = arg_str(args, "reason")?;
if reason.trim().is_empty() {
anyhow::bail!("reason must be a non-empty string");
}
let replace_all = args.get("replace_all").and_then(|v| v.as_bool()).unwrap_or(false);
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
anyhow::bail!("file '{}' does not exist at resolved path {}", rel, path.display());
}
if path.is_dir() {
anyhow::bail!("'{}' is a directory, not a file", rel);
}
let content = fs::read_to_string(&path)
.map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?;
if !content.contains(&old) {
anyhow::bail!("old string not found in '{}'", rel);
}
if !replace_all {
let count = content.matches(&old).count();
if count > 1 {
anyhow::bail!(
"old string appears {} times in '{}'. Set replace_all=true to replace all occurrences, or provide a more specific match.",
count, rel
);
}
}
let new_content = if replace_all {
content.replace(&old, &new_str)
} else {
content.replacen(&old, &new_str, 1)
};
fs::write(&path, &new_content)
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?;
let bytes_diff = if new_content.len() > content.len() {
new_content.len() - content.len()
} else {
content.len() - new_content.len()
};
Ok(format!("edited {} ({} byte delta)", rel, bytes_diff as isize))
}
}
+27
View File
@@ -0,0 +1,27 @@
use std::path::Path;
use serde_json::Value;
use anyhow::{Result, anyhow};
pub fn arg_str(args: &Value, name: &str) -> Result<String> {
args.get(name)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("missing required argument: {}", name))
}
pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> String {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let in_ws = ctx.workspaces.iter().any(|w| {
let wc = w.canonicalize().unwrap_or_else(|_| w.to_path_buf());
canon.starts_with(&wc)
});
if !in_ws {
format!(
"path '{}' is outside all workspace roots. Workspace roots: {}",
rel,
ctx.workspaces.iter().map(|w| w.display().to_string()).collect::<Vec<_>>().join(", ")
)
} else {
format!("path '{}' does not exist (resolved to {})", rel, canon.display())
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod edit;
pub mod helpers;
pub mod read;
pub mod write;
+70
View File
@@ -0,0 +1,70 @@
use std::fs;
use std::path::PathBuf;
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use super::super::Tool;
use super::super::ToolCtx;
use super::super::resolve_path;
use super::helpers::{arg_str, not_found_help};
pub struct Read;
impl Tool for Read {
fn name(&self) -> &'static str {
"read"
}
fn description(&self) -> &'static str {
"Read the contents of a file and display it with line numbers"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to read (relative to workspace root, or [N]prefix for other workspaces)"
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to return (optional)"
}
},
"required": ["path"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?;
let limit = args.get("limit").and_then(|v| v.as_u64()).map(|v| v as usize);
let path: PathBuf = match resolve_path(&ctx.workspaces, &rel) {
Ok(p) => p,
Err(_e) => return Ok(not_found_help(ctx, &PathBuf::from(&rel), &rel)),
};
if !path.exists() {
return Ok(not_found_help(ctx, &path, &rel));
}
if path.is_dir() {
return Ok(format!("'{}' is a directory, not a file. Use ls or glob to list directory contents.", rel));
}
let content = fs::read_to_string(&path)
.map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?;
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
let take = limit.unwrap_or(total).min(total);
let result: String = lines[..take]
.iter()
.enumerate()
.map(|(i, line)| format!("{}\t{}", i + 1, line))
.collect::<Vec<_>>()
.join("\n");
if take < total {
Ok(format!("{}\n... ({} more lines, total {})", result, total - take, total))
} else if total == 0 {
Ok(String::new())
} else {
Ok(result)
}
}
}
+57
View File
@@ -0,0 +1,57 @@
use std::fs;
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use super::super::Tool;
use super::super::ToolCtx;
use super::super::resolve_path;
use super::helpers::arg_str;
pub struct Write;
impl Tool for Write {
fn name(&self) -> &'static str {
"write"
}
fn description(&self) -> &'static str {
"Write content to a file, creating parent directories as needed"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to write (relative to workspace root)"
},
"content": {
"type": "string",
"description": "Content to write to the file"
},
"reason": {
"type": "string",
"description": "Reason for the change (must be non-empty)"
}
},
"required": ["path", "content", "reason"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?;
let content = arg_str(args, "content")?;
let reason = arg_str(args, "reason")?;
if reason.trim().is_empty() {
anyhow::bail!("reason must be a non-empty string");
}
let path = resolve_path(&ctx.workspaces, &rel)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| anyhow!("failed to create parent directories for '{}': {}", rel, e))?;
}
fs::write(&path, &content)
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?;
Ok(format!("wrote {} bytes to {}", content.len(), rel))
}
}
+50
View File
@@ -0,0 +1,50 @@
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use std::process::Command;
use super::Tool;
use super::ToolCtx;
pub struct GitCred;
impl Tool for GitCred {
fn name(&self) -> &'static str {
"git_cred"
}
fn description(&self) -> &'static str {
"Interact with git credential helper (store, get, erase credentials)"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["store", "get", "erase"],
"description": "Git credential operation"
}
},
"required": ["operation"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let operation = args.get("operation")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: operation"))?;
let output = Command::new("git")
.arg("credential")
.arg(operation)
.output()
.map_err(|e| anyhow!("git credential failed: {}", e))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok(format!("{}{}", stdout, stderr))
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
anyhow::bail!("git credential '{}' failed: {}", operation, stderr.trim())
}
}
}
+67
View File
@@ -0,0 +1,67 @@
use std::process::Command;
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use super::Tool;
use super::ToolCtx;
pub struct GitOperator;
impl Tool for GitOperator {
fn name(&self) -> &'static str {
"git_operator"
}
fn description(&self) -> &'static str {
"Execute git operations with catastrophic guard protection"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"operation": {
"type": "string",
"description": "Git subcommand to execute (e.g. 'add', 'commit', 'status')"
},
"args": {
"type": "array",
"items": {"type": "string"},
"description": "Arguments for the git subcommand"
}
},
"required": ["operation", "args"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let operation = args.get("operation")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: operation"))?
.to_string();
let arg_list: Vec<String> = args.get("args")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.ok_or_else(|| anyhow!("missing required argument: args"))?;
let full_cmd_str = format!("git {} {}", operation, arg_list.join(" "));
let workspace_roots: Vec<&std::path::Path> = _ctx.workspaces.iter().map(|p| p.as_path()).collect();
crate::app::catastrophic::CatastrophicGuard::check_all(&full_cmd_str, &workspace_roots)
.map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?;
let output = Command::new("git")
.arg(&operation)
.args(&arg_list)
.output()
.map_err(|e| anyhow!("git {} failed: {}", operation, e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) };
if output.status.success() {
Ok(combined)
} else {
anyhow::bail!("git {} failed (exit {}): {}", operation, output.status.code().unwrap_or(-1), stderr.trim())
}
}
}
+62
View File
@@ -0,0 +1,62 @@
use std::process::Command;
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use super::Tool;
use super::ToolCtx;
pub struct GitWorktree;
impl Tool for GitWorktree {
fn name(&self) -> &'static str {
"git_worktree"
}
fn description(&self) -> &'static str {
"Create and manage git worktrees"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name for the worktree directory"
},
"base_ref": {
"type": "string",
"description": "Base branch or ref to create the worktree from (e.g. 'main')"
}
},
"required": ["name", "base_ref"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let name = args.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: name"))?
.to_string();
let base_ref = args.get("base_ref")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: base_ref"))?
.to_string();
let worktree_path = ctx.worktrees_dir.join(&name);
std::fs::create_dir_all(&worktree_path)
.map_err(|e| anyhow!("failed to create worktree directory: {}", e))?;
let output = Command::new("git")
.args(["worktree", "add", "--checkout"])
.arg(worktree_path.display().to_string())
.arg(&base_ref)
.output()
.map_err(|e| anyhow!("git worktree add failed: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) };
if output.status.success() {
Ok(format!("created worktree '{}' from '{}'\n{}", name, base_ref, combined))
} else {
anyhow::bail!("git worktree add failed (exit {}): {}", output.status.code().unwrap_or(-1), stderr.trim())
}
}
}
+78
View File
@@ -0,0 +1,78 @@
use std::fs;
use std::io::copy;
use std::path::PathBuf;
use std::time::Duration;
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use super::super::Tool;
use super::super::ToolCtx;
use super::super::resolve_path;
pub struct Download;
impl Tool for Download {
fn name(&self) -> &'static str {
"download"
}
fn description(&self) -> &'static str {
"Download a file from a URL to a local path"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL to download from"
},
"path": {
"type": "string",
"description": "Local path to save the file (relative to workspace root)"
}
},
"required": ["url", "path"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
if ctx.internet_mode == crate::model::settings::InternetMode::Off {
anyhow::bail!("internet access is disabled. Enable it in settings to use download.");
}
let url = args.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: url"))?
.to_string();
let rel = args.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?
.to_string();
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
crate::app::catastrophic::CatastrophicGuard::check_download_path(&path)
.map_err(|e| anyhow!("download blocked: {}", e))?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| anyhow!("failed to create parent directories: {}", e))?;
}
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(120))
.user_agent("ZedSex/1.0")
.build()
.map_err(|e| anyhow!("failed to create HTTP client: {}", e))?;
let response = client.get(&url)
.send()
.map_err(|e| anyhow!("failed to download '{}': {}", url, e))?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("download '{}' returned HTTP {}", url, status.as_u16());
}
let total: u64 = response.content_length().unwrap_or(0);
let mut file = fs::File::create(&path)
.map_err(|e| anyhow!("failed to create file '{}': {}", rel, e))?;
let mut content = response;
let written = copy(&mut content, &mut file)
.map_err(|e| anyhow!("failed to write to '{}': {}", rel, e))?;
Ok(format!("downloaded {} of {} bytes to {}", written, total, rel))
}
}
+78
View File
@@ -0,0 +1,78 @@
use std::time::Duration;
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use super::super::Tool;
use super::super::ToolCtx;
pub struct Fetch;
impl Tool for Fetch {
fn name(&self) -> &'static str {
"fetch"
}
fn description(&self) -> &'static str {
"Fetch a URL and convert the HTML content to markdown"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL to fetch"
}
},
"required": ["url"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
if ctx.internet_mode == crate::model::settings::InternetMode::Off {
anyhow::bail!("internet access is disabled. Enable it in settings to use fetch.");
}
let url = args.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: url"))?
.to_string();
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.user_agent("ZedSex/1.0")
.build()
.map_err(|e| anyhow!("failed to create HTTP client: {}", e))?;
let response = client.get(&url)
.send()
.map_err(|e| anyhow!("failed to fetch '{}': {}", url, e))?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("fetch '{}' returned HTTP {}", url, status.as_u16());
}
let content_type = response.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = response.text()
.map_err(|e| anyhow!("failed to read response body: {}", e))?;
if content_type.contains("text/html") || content_type.contains("application/xhtml") || content_type.is_empty() {
let markdown = html_to_markdown(&body)?;
Ok(markdown)
} else {
let preview = body.chars().take(2000).collect::<String>();
Ok(format!("Content-Type: {}\n\n{}", content_type, preview))
}
}
}
pub(crate) fn html_to_markdown(html: &str) -> Result<String> {
let frag = scraper::Html::parse_document(html);
let sel = scraper::Selector::parse("body")
.map_err(|e| anyhow!("failed to parse selector: {}", e))?;
let body = frag.select(&sel).next()
.map(|e| e.inner_html())
.unwrap_or_else(|| html.to_string());
let text = scraper::Html::parse_fragment(&body);
let result: String = text.root_element().text().collect::<Vec<_>>().join("\n");
Ok(result)
}
+3
View File
@@ -0,0 +1,3 @@
pub mod download;
pub mod fetch;
pub mod search;
+50
View File
@@ -0,0 +1,50 @@
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use super::super::Tool;
use super::super::ToolCtx;
pub struct Search;
impl Tool for Search {
fn name(&self) -> &'static str {
"web_search"
}
fn description(&self) -> &'static str {
"Search the web for information. Uses configured search provider."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query"
}
},
"required": ["query"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
if ctx.internet_mode == crate::model::settings::InternetMode::Off {
anyhow::bail!("internet access is disabled. Enable it in settings to use web_search.");
}
let query = args.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: query"))?
.to_string();
let results = mock_search(&query);
Ok(results)
}
}
pub(crate) fn mock_search(query: &str) -> String {
format!(
"Search results for '{}':\n\n\
No search provider configured. Results are unavailable.\n\
To enable web search, configure a search provider in settings.\n\
Supported providers: tavily, brave, serpapi, google.", query
)
}
+137
View File
@@ -0,0 +1,137 @@
use std::path::PathBuf;
use serde_json::Value;
use anyhow::Result;
pub mod fs;
pub mod git_cred;
pub mod git_operator;
pub mod git_worktree;
pub mod internet;
pub mod plan;
pub mod search;
pub mod seqthink;
pub mod shell;
pub mod shell_filter;
pub mod workflow;
pub trait Tool: Send + Sync {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn parameters(&self) -> Value;
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
}
pub struct ToolCtx {
pub workspaces: Vec<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub download_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
pub internet_mode: super::model::settings::InternetMode,
pub origin: crate::app::state::types::Origin,
}
impl ToolCtx {
pub fn builder() -> ToolCtxBuilder {
ToolCtxBuilder::default()
}
}
pub struct ToolCtxBuilder {
pub workspaces: Vec<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub download_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
pub internet_mode: super::model::settings::InternetMode,
pub origin: crate::app::state::types::Origin,
}
impl Default for ToolCtxBuilder {
fn default() -> Self {
ToolCtxBuilder {
workspaces: Vec::new(),
session_dir: PathBuf::new(),
memory_dir: PathBuf::new(),
download_dir: PathBuf::new(),
worktrees_dir: PathBuf::new(),
dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())),
internet_mode: super::model::settings::InternetMode::Off,
origin: crate::app::state::types::Origin::Main,
}
}
}
impl ToolCtxBuilder {
pub fn workspaces(mut self, v: Vec<PathBuf>) -> Self { self.workspaces = v; self }
pub fn session_dir(mut self, v: PathBuf) -> Self { self.session_dir = v; self }
pub fn memory_dir(mut self, v: PathBuf) -> Self { self.memory_dir = v; self }
pub fn download_dir(mut self, v: PathBuf) -> Self { self.download_dir = v; self }
pub fn worktrees_dir(mut self, v: PathBuf) -> Self { self.worktrees_dir = v; self }
pub fn internet_mode(mut self, v: super::model::settings::InternetMode) -> Self { self.internet_mode = v; self }
pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self }
pub fn build(self) -> ToolCtx {
ToolCtx {
workspaces: self.workspaces,
session_dir: self.session_dir,
memory_dir: self.memory_dir,
download_dir: self.download_dir,
worktrees_dir: self.worktrees_dir,
dir_cache: self.dir_cache,
internet_mode: self.internet_mode,
origin: self.origin,
}
}
}
pub fn all_tools() -> Vec<Box<dyn Tool>> {
vec![
Box::new(super::tool::fs::read::Read),
Box::new(super::tool::fs::write::Write),
Box::new(super::tool::fs::edit::Edit),
Box::new(super::tool::search::Grep),
Box::new(super::tool::search::Glob),
Box::new(super::tool::shell::Bash),
Box::new(super::tool::git_operator::GitOperator),
Box::new(super::tool::git_worktree::GitWorktree),
Box::new(super::tool::git_cred::GitCred),
Box::new(super::tool::seqthink::SeqThink),
Box::new(super::tool::plan::PlanEnter),
Box::new(super::tool::plan::PlanReady),
Box::new(super::tool::workflow::WorkflowRun),
]
}
pub fn tool_is_risky(name: &str) -> bool {
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
}
pub const DEFERRED_TOOLS: &[&str] = &[
"read", "write", "edit", "bash", "grep", "glob",
"git_operator", "git_worktree", "git_cred",
];
pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
let _parts: Vec<&str> = rel.splitn(2, '/').collect();
let (ws_idx, path) = if rel.starts_with('[') {
let close = rel.find(']').ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?;
let idx: usize = rel[1..close].parse().map_err(|_| anyhow::anyhow!("invalid workspace index"))?;
(idx, &rel[close + 1..])
} else {
(0, rel)
};
let base = workspaces.get(ws_idx).ok_or_else(|| anyhow::anyhow!("workspace index {} out of range", ws_idx))?;
let abs = if path.is_empty() {
base.clone()
} else {
base.join(path)
};
let canon = abs.canonicalize().unwrap_or(abs);
if workspaces.iter().any(|w| canon.starts_with(w)) {
Ok(canon)
} else {
anyhow::bail!("path '{}' is outside all workspace roots", rel)
}
}
+75
View File
@@ -0,0 +1,75 @@
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use super::Tool;
use super::ToolCtx;
pub struct PlanEnter;
impl Tool for PlanEnter {
fn name(&self) -> &'static str {
"plan_enter"
}
fn description(&self) -> &'static str {
"Enter plan mode: provide a detailed plan for the next set of changes"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"plan": {
"type": "string",
"description": "The step-by-step plan to implement"
},
"sign_off": {
"type": "string",
"description": "Sign-off message acknowledging the plan constraints"
}
},
"required": ["plan", "sign_off"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let _plan = args.get("plan")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: plan"))?;
let _sign_off = args.get("sign_off")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: sign_off"))?;
Ok("plan recorded".to_string())
}
}
pub struct PlanReady;
impl Tool for PlanReady {
fn name(&self) -> &'static str {
"plan_ready"
}
fn description(&self) -> &'static str {
"Signal that you are ready to execute the approved plan"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"confirmation": {
"type": "string",
"description": "Confirmation that you understand and will follow the plan"
}
},
"required": ["confirmation"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let _confirmation = args.get("confirmation")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: confirmation"))?;
Ok("ready to execute".to_string())
}
}
+147
View File
@@ -0,0 +1,147 @@
use std::fs;
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use ignore::Walk;
use globset::{GlobBuilder, GlobSetBuilder};
use super::Tool;
use super::ToolCtx;
use super::resolve_path;
pub struct Grep;
impl Tool for Grep {
fn name(&self) -> &'static str {
"grep"
}
fn description(&self) -> &'static str {
"Search for a pattern in files using recursive text search"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Text pattern to search for"
},
"path": {
"type": "string",
"description": "Path to search in (relative to workspace root)"
}
},
"required": ["pattern", "path"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let pattern = args.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: pattern"))?
.to_string();
let rel = args.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?
.to_string();
let path = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
anyhow::bail!("path '{}' does not exist", rel);
}
if !path.is_dir() {
anyhow::bail!("path '{}' is not a directory", rel);
}
let mut results: Vec<(String, usize, String)> = Vec::new();
for entry in Walk::new(&path).flatten() {
let file_path = entry.path();
if !file_path.is_file() {
continue;
}
if let Ok(content) = fs::read_to_string(file_path) {
for (i, line) in content.lines().enumerate() {
if line.contains(&pattern) {
let rel_path = file_path.strip_prefix(&path)
.unwrap_or(file_path)
.display()
.to_string();
results.push((rel_path, i + 1, line.to_string()));
}
}
}
}
if results.is_empty() {
return Ok(format!("no matches found for '{}' in {}", pattern, rel));
}
let output = results.iter()
.map(|(f, line, text)| format!("{}:{}:{}", f, line, text))
.collect::<Vec<_>>()
.join("\n");
Ok(format!("found {} matches:\n{}", results.len(), output))
}
}
pub struct Glob;
impl Tool for Glob {
fn name(&self) -> &'static str {
"glob"
}
fn description(&self) -> &'static str {
"List files matching a glob pattern"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match files (e.g. '**/*.rs')"
},
"path": {
"type": "string",
"description": "Root path to search from (relative to workspace root)"
}
},
"required": ["pattern", "path"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let pat_str = args.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: pattern"))?
.to_string();
let rel = args.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?
.to_string();
let root = resolve_path(&ctx.workspaces, &rel)?;
if !root.exists() || !root.is_dir() {
anyhow::bail!("path '{}' is not a valid directory", rel);
}
let mut builder = GlobSetBuilder::new();
let full_pattern = root.join(&pat_str).display().to_string();
builder.add(GlobBuilder::new(&full_pattern).build()
.map_err(|e| anyhow!("invalid glob pattern '{}': {}", pat_str, e))?);
let glob_set = builder.build()
.map_err(|e| anyhow!("failed to build glob set: {}", e))?;
let mut matches: Vec<String> = Vec::new();
for entry in Walk::new(&root).flatten() {
let p = entry.path();
if glob_set.is_match(p) {
let rel_path = p.strip_prefix(&root)
.unwrap_or(p)
.display()
.to_string();
matches.push(format!("{}{}", rel_path, if p.is_dir() { "/" } else { "" }));
}
}
matches.sort();
if matches.is_empty() {
return Ok(format!("no files match '{}' in {}", pat_str, rel));
}
Ok(matches.join("\n"))
}
}
+33
View File
@@ -0,0 +1,33 @@
use serde_json::{json, Value};
use anyhow::Result;
use super::Tool;
use super::ToolCtx;
pub struct SeqThink;
impl Tool for SeqThink {
fn name(&self) -> &'static str {
"seqthink"
}
fn description(&self) -> &'static str {
"Record a step in sequential thinking. Use this to show your reasoning chain step by step."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"thought": {
"type": "string",
"description": "The current thinking step content"
}
},
"required": ["thought"]
})
}
fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result<String> {
Ok(String::new())
}
}
+93
View File
@@ -0,0 +1,93 @@
use std::process::Command;
use std::time::Duration;
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use super::Tool;
use super::ToolCtx;
pub struct Bash;
impl Tool for Bash {
fn name(&self) -> &'static str {
"bash"
}
fn description(&self) -> &'static str {
"Execute a shell command via bash -c with catastrophic guard protection"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command to execute"
},
"description": {
"type": "string",
"description": "Human-readable description of what the command does"
},
"timeout": {
"type": "integer",
"description": "Timeout in milliseconds (default 120000, max 600000)"
}
},
"required": ["command"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let cmd = args.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: command"))?
.to_string();
let _description = args.get("description").and_then(|v| v.as_str()).unwrap_or("");
let timeout_ms = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(120000).min(600000);
let workspace_roots: Vec<&std::path::Path> = ctx.workspaces.iter().map(|p| p.as_path()).collect();
crate::app::catastrophic::CatastrophicGuard::check_all(&cmd, &workspace_roots)
.map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?;
let mut child = Command::new("bash")
.arg("-c")
.arg(&cmd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| anyhow!("failed to spawn bash: {}", e))?;
let start = std::time::Instant::now();
let timeout = Duration::from_millis(timeout_ms);
loop {
match child.try_wait() {
Ok(Some(status)) => {
let elapsed = start.elapsed().as_secs_f64();
let output = child.wait_with_output()
.map_err(|e| anyhow!("failed to collect output: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) };
let trimmed = combined.trim().to_string();
if status.success() {
return Ok(if trimmed.is_empty() {
format!("Command completed in {:.2}s (exit code 0)", elapsed)
} else {
format!("{}\n\nExit code: 0 ({:.2}s)", trimmed, elapsed)
});
} else {
return Ok(format!("{}\n\nExit code: {} ({:.2}s)", trimmed, status.code().unwrap_or(-1), elapsed));
}
}
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("command timed out after {}ms", timeout_ms);
}
std::thread::sleep(Duration::from_millis(10));
}
Err(e) => {
anyhow::bail!("failed to wait for command: {}", e);
}
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
use anyhow::Result;
pub fn check_credential_read(cmd: &str) -> Result<()> {
let patterns = [
"cat ~/.ssh",
"cat /home/",
".ssh/id_rsa",
".ssh/id_ed25519",
".ssh/id_ecdsa",
".ssh/id_dsa",
".ssh/authorized_keys",
".ssh/known_hosts",
".git-credentials",
".netrc",
"aws/credentials",
"gcloud/credentials",
".config/gcloud",
".config/gh",
"token=",
"secret=",
"api_key=",
"api-key=",
"password=",
];
let cmd_lower = cmd.to_lowercase();
for pattern in &patterns {
if cmd_lower.contains(pattern) {
anyhow::bail!("credential read blocked: '{}'", pattern);
}
}
Ok(())
}
+38
View File
@@ -0,0 +1,38 @@
use anyhow::Result;
pub fn check_git_destructive(cmd: &str) -> Result<()> {
let patterns = [
"force-push",
"reset --hard",
"clean -fdx",
"clean -fd",
"clean -fX",
"clean -fx",
"branch -D",
"branch --delete --force",
"checkout --force",
"switch -f",
"restore --force",
"stash drop",
"stash clear",
"tag -d",
"tag --delete",
"update-ref -d",
"filter-branch",
"gc --prune",
"gc --aggressive",
"push --delete",
"push --force",
"push origin :",
"push +refs",
"push --mirror",
"push --tags --force",
];
let cmd_lower = cmd.to_lowercase();
for pattern in &patterns {
if cmd_lower.contains(pattern) {
anyhow::bail!("destructive git operation blocked: '{}'", pattern);
}
}
Ok(())
}
+2
View File
@@ -0,0 +1,2 @@
pub mod credentials;
pub mod git;
+41
View File
@@ -0,0 +1,41 @@
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use super::Tool;
use super::ToolCtx;
pub struct WorkflowRun;
impl Tool for WorkflowRun {
fn name(&self) -> &'static str {
"workflow_run"
}
fn description(&self) -> &'static str {
"Execute a workflow script by delegating to the workflow engine"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"script": {
"type": "string",
"description": "Workflow script content or path to a workflow file"
},
"args": {
"type": "object",
"description": "Optional arguments passed to the workflow script"
}
},
"required": ["script"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let _script = args.get("script")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: script"))?;
let _workflow_args = args.get("args");
Ok("workflow delegated to workflow engine".to_string())
}
}