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:
1 parent
7cb4ae6708
commit
cc03bd79b6
131 files changed
+10994
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
.env
|
||||||
Generated
+4193
File diff suppressed because it is too large.
Load diff
+42
@@ -0,0 +1,42 @@
|
|||||||
|
[package]
|
||||||
|
name = "zesdex"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.30.2"
|
||||||
|
crossterm = "0.28"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json", "stream", "blocking", "native-tls-vendored"] }
|
||||||
|
dom_smoothie = "0.18.0"
|
||||||
|
fast_html2md = "0.0.62"
|
||||||
|
scraper = "0.27.0"
|
||||||
|
url = "2"
|
||||||
|
percent-encoding = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
serde_yaml_ng = "0.9"
|
||||||
|
anyhow = "1"
|
||||||
|
include_dir = "0.7"
|
||||||
|
uuid = { version = "1", features = ["v4", "v5"] }
|
||||||
|
dirs = "5"
|
||||||
|
futures-util = "0.3"
|
||||||
|
pulldown-cmark = { version = "0.13", default-features = false }
|
||||||
|
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
ignore = "0.4"
|
||||||
|
regex = "1"
|
||||||
|
globset = "0.4"
|
||||||
|
infer = "0.16"
|
||||||
|
base64 = "0.22"
|
||||||
|
sha2 = "0.10"
|
||||||
|
libc = "0.2"
|
||||||
|
rmcp = { version = "1.8", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "zesdex"
|
||||||
|
path = "src/main.rs"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use super::job::BashJob;
|
||||||
|
|
||||||
|
pub(crate) fn bash_jobs_map() -> &'static Mutex<HashMap<String, BashJob>> {
|
||||||
|
static JOBS: OnceLock<Mutex<HashMap<String, BashJob>>> = OnceLock::new();
|
||||||
|
JOBS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
||||||
|
let mut map = bash_jobs_map().lock().ok()?;
|
||||||
|
let job = map.get_mut(id)?;
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
while let Some(line) = job.try_read_line() {
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
if lines.is_empty() { None } else { Some(lines) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bash_kill(id: &str) -> anyhow::Result<()> {
|
||||||
|
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?;
|
||||||
|
let job = map.remove(id);
|
||||||
|
if job.is_some() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("bash job '{}' not found", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_bash_job(job: BashJob) -> String {
|
||||||
|
let id = job.id.clone();
|
||||||
|
if let Ok(mut map) = bash_jobs_map().lock() {
|
||||||
|
map.insert(id.clone(), job);
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::io::BufRead;
|
||||||
|
|
||||||
|
pub struct BashJob {
|
||||||
|
pub id: String,
|
||||||
|
pub command: String,
|
||||||
|
pub started_at: i64,
|
||||||
|
pub output_rx: mpsc::Receiver<String>,
|
||||||
|
pub exit_code: Option<i32>,
|
||||||
|
pub handle: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_bash_job(command: String) -> BashJob {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let started_at = chrono::Utc::now().timestamp_millis();
|
||||||
|
let (output_tx, output_rx) = mpsc::channel::<String>();
|
||||||
|
let cmd = command.clone();
|
||||||
|
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let child = Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&cmd)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn();
|
||||||
|
match child {
|
||||||
|
Ok(mut child) => {
|
||||||
|
if let Some(stdout) = child.stdout.take() {
|
||||||
|
let reader = std::io::BufReader::new(stdout);
|
||||||
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
|
let _ = output_tx.send(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let status = child.wait();
|
||||||
|
let code = status.ok().and_then(|s| s.code());
|
||||||
|
let _ = output_tx.send(format!("__exit:{}", code.unwrap_or(-1)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = output_tx.send(format!("__error:{}", e));
|
||||||
|
let _ = output_tx.send("__exit:-1".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
BashJob {
|
||||||
|
id,
|
||||||
|
command,
|
||||||
|
started_at,
|
||||||
|
output_rx,
|
||||||
|
exit_code: None,
|
||||||
|
handle: Some(handle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BashJob {
|
||||||
|
pub fn try_read_line(&mut self) -> Option<String> {
|
||||||
|
match self.output_rx.try_recv() {
|
||||||
|
Ok(line) => {
|
||||||
|
if line.starts_with("__exit:") {
|
||||||
|
self.exit_code = line.strip_prefix("__exit:").and_then(|s| s.parse().ok());
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_running(&self) -> bool {
|
||||||
|
self.exit_code.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod control;
|
||||||
|
pub mod job;
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub struct CatastrophicGuard;
|
||||||
|
|
||||||
|
impl CatastrophicGuard {
|
||||||
|
pub fn check_git_operation(cmd: &str) -> Result<(), String> {
|
||||||
|
let patterns = [
|
||||||
|
"force-push",
|
||||||
|
"reset --hard",
|
||||||
|
"clean -f",
|
||||||
|
"clean -d",
|
||||||
|
"clean -x",
|
||||||
|
"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",
|
||||||
|
];
|
||||||
|
let cmd_lower = cmd.to_lowercase();
|
||||||
|
for pattern in patterns {
|
||||||
|
if cmd_lower.contains(pattern) {
|
||||||
|
return Err(format!("catastrophic git operation blocked: '{}'", pattern));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_shell_command(cmd: &str) -> Result<(), String> {
|
||||||
|
let dangerous = [
|
||||||
|
":(){ :|:& };:",
|
||||||
|
"> /dev/sda",
|
||||||
|
"dd if=",
|
||||||
|
"mkfs.",
|
||||||
|
"format ",
|
||||||
|
"fdisk",
|
||||||
|
"parted",
|
||||||
|
"mkswap",
|
||||||
|
"swapoff",
|
||||||
|
"shutdown",
|
||||||
|
"reboot",
|
||||||
|
"poweroff",
|
||||||
|
"init 0",
|
||||||
|
"init 6",
|
||||||
|
"halt",
|
||||||
|
"> /dev/mem",
|
||||||
|
"> /dev/kmem",
|
||||||
|
"chmod 000",
|
||||||
|
"chown -R 0:0",
|
||||||
|
];
|
||||||
|
let cmd_lower = cmd.to_lowercase();
|
||||||
|
for pattern in dangerous {
|
||||||
|
if cmd_lower.contains(pattern) {
|
||||||
|
return Err(format!("catastrophic shell command blocked: '{}'", pattern));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_delete_path(path: &Path, _workspace_roots: &[&Path]) -> Result<(), String> {
|
||||||
|
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||||
|
if canon == *"/"
|
||||||
|
|| canon == *"/home"
|
||||||
|
|| canon == *"/root"
|
||||||
|
{
|
||||||
|
return Err("catastrophic delete blocked: system directory".to_string());
|
||||||
|
}
|
||||||
|
let in_workspace = _workspace_roots.iter().any(|w| {
|
||||||
|
let wc = w.canonicalize().unwrap_or_else(|_| w.to_path_buf());
|
||||||
|
canon.starts_with(&wc)
|
||||||
|
});
|
||||||
|
if !in_workspace {
|
||||||
|
return Err("catastrophic delete blocked: outside all workspace roots".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_credential_pattern(cmd: &str) -> Result<(), String> {
|
||||||
|
let patterns = [
|
||||||
|
"cat ~/.ssh",
|
||||||
|
"cat /home/",
|
||||||
|
".ssh/id_rsa",
|
||||||
|
".ssh/id_ed25519",
|
||||||
|
".ssh/authorized_keys",
|
||||||
|
".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) {
|
||||||
|
return Err(format!("credential read blocked: '{}'", pattern));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_download_path(path: &Path) -> Result<(), String> {
|
||||||
|
let name = path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let sensitive = [
|
||||||
|
"id_rsa",
|
||||||
|
"id_ed25519",
|
||||||
|
"authorized_keys",
|
||||||
|
"known_hosts",
|
||||||
|
".netrc",
|
||||||
|
".git-credentials",
|
||||||
|
"credentials.json",
|
||||||
|
"service-account",
|
||||||
|
"secret",
|
||||||
|
"key.pem",
|
||||||
|
"key.p8",
|
||||||
|
"id_ecdsa",
|
||||||
|
"id_dsa",
|
||||||
|
"config",
|
||||||
|
];
|
||||||
|
let name_lower = name.to_lowercase();
|
||||||
|
for s in &sensitive {
|
||||||
|
if name_lower.contains(s) {
|
||||||
|
return Err(format!("sensitive download blocked: '{}'", s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_all(cmd: &str, _workspace_roots: &[&Path]) -> Result<(), String> {
|
||||||
|
Self::check_shell_command(cmd)?;
|
||||||
|
Self::check_git_operation(cmd)?;
|
||||||
|
Self::check_credential_pattern(cmd)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Verdict {
|
||||||
|
Allow,
|
||||||
|
Block(String),
|
||||||
|
Escalate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Verdict {
|
||||||
|
pub fn is_allowed(&self) -> bool {
|
||||||
|
matches!(self, Verdict::Allow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Harness;
|
||||||
|
|
||||||
|
impl Harness {
|
||||||
|
pub fn classify(_cmd: &str, mode: &super::state::types::AgentMode) -> Verdict {
|
||||||
|
if mode.auto_approve() {
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
Verdict::Allow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_verdict(text: &str) -> Option<Verdict> {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||||
|
if let Some(verdict) = v.get("verdict").and_then(|v| v.as_str()) {
|
||||||
|
return match verdict.to_lowercase().as_str() {
|
||||||
|
"allow" => Some(Verdict::Allow),
|
||||||
|
"block" => Some(Verdict::Block(
|
||||||
|
v.get("reason").and_then(|r| r.as_str()).unwrap_or("blocked").to_string()
|
||||||
|
)),
|
||||||
|
"escalate" => Some(Verdict::Escalate),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for line in trimmed.lines() {
|
||||||
|
let l = line.trim().to_lowercase();
|
||||||
|
if l.starts_with("verdict: allow") {
|
||||||
|
return Some(Verdict::Allow);
|
||||||
|
}
|
||||||
|
if l.starts_with("verdict: block") {
|
||||||
|
let reason = line.split_once(':').map(|x| x.1).unwrap_or("blocked").trim().to_string();
|
||||||
|
return Some(Verdict::Block(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if trimmed.to_lowercase().contains("allow") {
|
||||||
|
return Some(Verdict::Allow);
|
||||||
|
}
|
||||||
|
if trimmed.to_lowercase().contains("block") {
|
||||||
|
return Some(Verdict::Block("blocked by classifier".to_string()));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn classify(_cmd: &str, mode: &super::state::types::AgentMode) -> Verdict {
|
||||||
|
Harness::classify(_cmd, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Harness {
|
||||||
|
fn default() -> Self {
|
||||||
|
Harness
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum McpTransport {
|
||||||
|
Stdio {
|
||||||
|
command: String,
|
||||||
|
args: Vec<String>,
|
||||||
|
},
|
||||||
|
StreamableHttp {
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct McpToolInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub input_schema: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct McpServer {
|
||||||
|
pub name: String,
|
||||||
|
pub transport: McpTransport,
|
||||||
|
pub tools: Vec<McpToolInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpServer {
|
||||||
|
pub fn new(name: String, transport: McpTransport) -> Self {
|
||||||
|
McpServer {
|
||||||
|
name,
|
||||||
|
transport,
|
||||||
|
tools: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct McpManager {
|
||||||
|
pub servers: Vec<McpServer>,
|
||||||
|
pub running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
McpManager {
|
||||||
|
servers: Vec::new(),
|
||||||
|
running: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_server(&mut self, server: McpServer) {
|
||||||
|
self.servers.push(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_server(&mut self, name: &str) {
|
||||||
|
self.servers.retain(|s| s.name != name);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_server(&self, name: &str) -> Option<&McpServer> {
|
||||||
|
self.servers.iter().find(|s| s.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all_tools(&self) -> Vec<&McpToolInfo> {
|
||||||
|
self.servers.iter().flat_map(|s| s.tools.iter()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_all(&mut self) -> anyhow::Result<()> {
|
||||||
|
self.running = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_all(&mut self) -> anyhow::Result<()> {
|
||||||
|
self.running = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod manager;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
pub mod catastrophic;
|
||||||
|
pub mod harness;
|
||||||
|
pub mod mode;
|
||||||
|
pub mod runtime;
|
||||||
|
pub mod state;
|
||||||
|
pub mod workflow;
|
||||||
|
pub mod subagent;
|
||||||
|
pub mod review;
|
||||||
|
pub mod bgbash;
|
||||||
|
pub mod mcp;
|
||||||
|
pub mod sec;
|
||||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,73 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod agents;
|
||||||
|
pub mod bash;
|
||||||
|
pub mod editor;
|
||||||
|
pub mod effort;
|
||||||
|
pub mod help;
|
||||||
|
pub mod key_input;
|
||||||
|
pub mod loading;
|
||||||
|
pub mod mcp;
|
||||||
|
pub mod onboard;
|
||||||
|
pub mod onboard_provider;
|
||||||
|
pub mod picker;
|
||||||
|
pub mod quit_confirm;
|
||||||
|
pub mod rewind;
|
||||||
|
pub mod security;
|
||||||
|
pub mod session_hub;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod todo;
|
||||||
|
pub mod workflow;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ModeKind {
|
||||||
|
Chat,
|
||||||
|
Agents,
|
||||||
|
Bash,
|
||||||
|
Workflow,
|
||||||
|
Help,
|
||||||
|
Settings,
|
||||||
|
SessionHub,
|
||||||
|
QuitConfirm,
|
||||||
|
Onboard,
|
||||||
|
OnboardProvider,
|
||||||
|
Picker,
|
||||||
|
KeyInput,
|
||||||
|
Editor,
|
||||||
|
Effort,
|
||||||
|
Mcp,
|
||||||
|
Security,
|
||||||
|
Todo,
|
||||||
|
Rewind,
|
||||||
|
Loading,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModeKind {
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ModeKind::Chat => "Chat",
|
||||||
|
ModeKind::Agents => "Agents",
|
||||||
|
ModeKind::Bash => "Bash",
|
||||||
|
ModeKind::Workflow => "Workflow",
|
||||||
|
ModeKind::Help => "Help",
|
||||||
|
ModeKind::Settings => "Settings",
|
||||||
|
ModeKind::SessionHub => "SessionHub",
|
||||||
|
ModeKind::QuitConfirm => "QuitConfirm",
|
||||||
|
ModeKind::Onboard => "Onboard",
|
||||||
|
ModeKind::OnboardProvider => "OnboardProvider",
|
||||||
|
ModeKind::Picker => "Picker",
|
||||||
|
ModeKind::KeyInput => "KeyInput",
|
||||||
|
ModeKind::Editor => "Editor",
|
||||||
|
ModeKind::Effort => "Effort",
|
||||||
|
ModeKind::Mcp => "MCP",
|
||||||
|
ModeKind::Security => "Security",
|
||||||
|
ModeKind::Todo => "Todo",
|
||||||
|
ModeKind::Rewind => "Rewind",
|
||||||
|
ModeKind::Loading => "Loading",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_overlay(self) -> bool {
|
||||||
|
!matches!(self, ModeKind::Chat | ModeKind::Agents | ModeKind::Bash | ModeKind::Workflow)
|
||||||
|
}
|
||||||
|
}
|
||||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,172 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum Confidence {
|
||||||
|
Human,
|
||||||
|
Verified,
|
||||||
|
Unverified,
|
||||||
|
Auto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum LessonLifecycle {
|
||||||
|
New,
|
||||||
|
Active,
|
||||||
|
Stale,
|
||||||
|
Contradicted,
|
||||||
|
Superseded,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum LessonScope {
|
||||||
|
Project,
|
||||||
|
Global,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Provenance {
|
||||||
|
pub session_turn: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub reviewer: Origin,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Lesson {
|
||||||
|
pub name: String,
|
||||||
|
pub content: String,
|
||||||
|
pub confidence: Confidence,
|
||||||
|
pub outcome: Option<String>,
|
||||||
|
pub lifecycle: LessonLifecycle,
|
||||||
|
pub scope: LessonScope,
|
||||||
|
pub contradiction_with: Option<String>,
|
||||||
|
pub provenance: Provenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ViolationEscalation {
|
||||||
|
None,
|
||||||
|
Warning,
|
||||||
|
Escalate,
|
||||||
|
Block,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ShadowStatus {
|
||||||
|
Trial,
|
||||||
|
Graduated,
|
||||||
|
Rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ShadowCheck {
|
||||||
|
pub pattern: String,
|
||||||
|
pub trial_window: u32,
|
||||||
|
pub trial_count: u32,
|
||||||
|
pub trial_passed: u32,
|
||||||
|
pub status: ShadowStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ReviewSystem {
|
||||||
|
pub pending: bool,
|
||||||
|
pub queue_capacity: usize,
|
||||||
|
pub repeated_violations: HashMap<String, u32>,
|
||||||
|
pub shadow_violations: Vec<ShadowCheck>,
|
||||||
|
pub violation_window: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReviewSystem {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
ReviewSystem {
|
||||||
|
pending: false,
|
||||||
|
queue_capacity: 1,
|
||||||
|
repeated_violations: HashMap::new(),
|
||||||
|
shadow_violations: Vec::new(),
|
||||||
|
violation_window: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.pending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_escalation(&self, pattern: &str) -> ViolationEscalation {
|
||||||
|
let count = self.repeated_violations.get(pattern).copied().unwrap_or(0);
|
||||||
|
match count {
|
||||||
|
0 | 1 => ViolationEscalation::None,
|
||||||
|
2 => ViolationEscalation::Warning,
|
||||||
|
3 | 4 => ViolationEscalation::Escalate,
|
||||||
|
_ => ViolationEscalation::Block,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_violation(&mut self, pattern: &str) -> ViolationEscalation {
|
||||||
|
let entry = self.repeated_violations.entry(pattern.to_string()).or_insert(0);
|
||||||
|
*entry += 1;
|
||||||
|
self.check_escalation(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_skip_review(consecutive_empty: u32) -> bool {
|
||||||
|
consecutive_empty >= 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_pending_lesson(name: &str, content: &str, provenance: Provenance) -> Lesson {
|
||||||
|
Lesson {
|
||||||
|
name: name.to_string(),
|
||||||
|
content: content.to_string(),
|
||||||
|
confidence: Confidence::Unverified,
|
||||||
|
outcome: None,
|
||||||
|
lifecycle: LessonLifecycle::New,
|
||||||
|
scope: LessonScope::Project,
|
||||||
|
contradiction_with: None,
|
||||||
|
provenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_lesson_calibration(state: &mut AppStateRest, lesson: &Lesson) {
|
||||||
|
if lesson.confidence != Confidence::Unverified {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if state.mode.auto_approve() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.push_toast(Toast::new(
|
||||||
|
ToastKind::Lesson,
|
||||||
|
format!("Lesson '{}' is pending review. Keep or discard?", lesson.name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||||
|
if state.mode == AgentMode::Plan {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if origin != Origin::Main {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let runtime = match &state.session_runtime {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
if !state.settings.review_enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if runtime.consecutive_empty_reviews >= state.settings.adaptive_review_max_skip {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||||
|
let _origin = Origin::Reviewer;
|
||||||
|
state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
|
||||||
|
crate::dto::chat::message::Role::System,
|
||||||
|
"review triggered".to_string(),
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
use crate::app::mode::ModeKind;
|
||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::state::types::Overlay;
|
||||||
|
use crate::dto::chat::message::ChatMessage;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub enum Action {
|
||||||
|
Quit,
|
||||||
|
ForceQuit,
|
||||||
|
SwitchMode(ModeKind),
|
||||||
|
SubmitInput(String),
|
||||||
|
InsertChar(char),
|
||||||
|
DeleteChar,
|
||||||
|
DeleteCharRight,
|
||||||
|
CursorLeft,
|
||||||
|
CursorRight,
|
||||||
|
HistoryUp,
|
||||||
|
HistoryDown,
|
||||||
|
ScrollUp,
|
||||||
|
ScrollDown,
|
||||||
|
OpenOverlay(Overlay),
|
||||||
|
CloseOverlay,
|
||||||
|
ToggleYoloArm,
|
||||||
|
ToolResult {
|
||||||
|
tool_call_id: String,
|
||||||
|
output: String,
|
||||||
|
is_error: bool,
|
||||||
|
},
|
||||||
|
StreamToken(String),
|
||||||
|
StreamDone,
|
||||||
|
StreamError(String),
|
||||||
|
SystemNote {
|
||||||
|
kind: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
RunCommand(String),
|
||||||
|
QuitConfirm,
|
||||||
|
Resize(u16, u16),
|
||||||
|
Tick,
|
||||||
|
RecordUsage {
|
||||||
|
tokens_in: u64,
|
||||||
|
tokens_out: u64,
|
||||||
|
duration_ms: u64,
|
||||||
|
},
|
||||||
|
RecordReviewTokens {
|
||||||
|
tokens: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||||
|
match action {
|
||||||
|
Action::Quit => {
|
||||||
|
state.quit = true;
|
||||||
|
}
|
||||||
|
Action::ForceQuit => {
|
||||||
|
state.quit = true;
|
||||||
|
}
|
||||||
|
Action::SwitchMode(mode) => {
|
||||||
|
state.misc.overlay = match mode {
|
||||||
|
ModeKind::Chat
|
||||||
|
| ModeKind::Agents
|
||||||
|
| ModeKind::Bash
|
||||||
|
| ModeKind::Workflow => Overlay::None,
|
||||||
|
ModeKind::Help => Overlay::Help,
|
||||||
|
ModeKind::Settings => Overlay::Settings,
|
||||||
|
ModeKind::SessionHub => Overlay::SessionHub,
|
||||||
|
ModeKind::QuitConfirm => Overlay::QuitConfirm,
|
||||||
|
ModeKind::Onboard => Overlay::Onboard,
|
||||||
|
ModeKind::OnboardProvider => Overlay::OnboardProvider,
|
||||||
|
ModeKind::Picker => Overlay::Picker,
|
||||||
|
ModeKind::KeyInput => Overlay::KeyInput,
|
||||||
|
ModeKind::Editor => Overlay::Editor,
|
||||||
|
ModeKind::Effort => Overlay::Effort,
|
||||||
|
ModeKind::Mcp => Overlay::Mcp,
|
||||||
|
ModeKind::Security => Overlay::Security,
|
||||||
|
ModeKind::Todo => Overlay::Todo,
|
||||||
|
ModeKind::Rewind => Overlay::Rewind,
|
||||||
|
ModeKind::Loading => Overlay::Loading,
|
||||||
|
};
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::SubmitInput(text) => {
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
rt.push_message(ChatMessage::user(text.clone()));
|
||||||
|
let api_key = state.settings.api_key.clone();
|
||||||
|
let model = state.settings.model.clone();
|
||||||
|
let msgs = rt.messages.clone();
|
||||||
|
let pending = state.pending_api_response.clone();
|
||||||
|
if let Some(key) = api_key {
|
||||||
|
if !key.is_empty() {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let client = crate::service::openrouter::OpenRouterClient::new(key, model);
|
||||||
|
match client.chat(&msgs) {
|
||||||
|
Ok(response) => {
|
||||||
|
if let Ok(mut guard) = pending.lock() {
|
||||||
|
*guard = Some(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Ok(mut guard) = pending.lock() {
|
||||||
|
*guard = Some(format!("Error: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::InsertChar(c) => {
|
||||||
|
state.input.insert(c);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::DeleteChar => {
|
||||||
|
state.input.delete_left();
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::DeleteCharRight => {
|
||||||
|
state.input.delete_right();
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::CursorLeft => {
|
||||||
|
state.input.char_left();
|
||||||
|
}
|
||||||
|
Action::CursorRight => {
|
||||||
|
state.input.char_right();
|
||||||
|
}
|
||||||
|
Action::HistoryUp => {
|
||||||
|
state.input.history_up();
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::HistoryDown => {
|
||||||
|
state.input.history_down();
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::ScrollUp => {
|
||||||
|
let total = state.transcript_cache.messages.len();
|
||||||
|
state.scroll.scroll_up();
|
||||||
|
state.scroll.scroll_down(total);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::ScrollDown => {
|
||||||
|
let total = state.transcript_cache.messages.len();
|
||||||
|
state.scroll.scroll_down(total);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::OpenOverlay(overlay) => {
|
||||||
|
state.misc.overlay = overlay;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::CloseOverlay => {
|
||||||
|
state.misc.overlay = Overlay::None;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::ToggleYoloArm => {
|
||||||
|
state.misc.yolo_armed = !state.misc.yolo_armed;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::ToolResult {
|
||||||
|
tool_call_id,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
} => {
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
rt.push_message(ChatMessage::tool_result(tool_call_id.clone(), output.clone()));
|
||||||
|
rt.tool_call_results.push(
|
||||||
|
crate::app::state::runtime::ToolCallResult {
|
||||||
|
tool_call_id,
|
||||||
|
tool_name: String::new(),
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
duration_ms: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::StreamToken(token) => {
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
let found = rt.messages.iter_mut().rev().find(|m| {
|
||||||
|
matches!(m.role, crate::dto::chat::message::Role::Assistant)
|
||||||
|
});
|
||||||
|
if let Some(last) = found {
|
||||||
|
let current = last.content.take().unwrap_or_default();
|
||||||
|
last.content = Some(current + &token);
|
||||||
|
} else {
|
||||||
|
let mut msg = ChatMessage::assistant(None);
|
||||||
|
msg.content = Some(token);
|
||||||
|
rt.push_message(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::StreamDone => {
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::StreamError(msg) => {
|
||||||
|
let toast = crate::app::state::types::Toast::new(
|
||||||
|
crate::app::state::types::ToastKind::Error,
|
||||||
|
msg,
|
||||||
|
);
|
||||||
|
state.push_toast(toast);
|
||||||
|
}
|
||||||
|
Action::SystemNote { kind: _kind, message } => {
|
||||||
|
let toast = crate::app::state::types::Toast::new(
|
||||||
|
crate::app::state::types::ToastKind::Info,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
state.push_toast(toast);
|
||||||
|
}
|
||||||
|
Action::RunCommand(text) => {
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
rt.push_message(ChatMessage::user(text));
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::QuitConfirm => {
|
||||||
|
state.misc.overlay = Overlay::QuitConfirm;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::Resize(w, _h) => {
|
||||||
|
state.scroll.set_max_visible(w as usize);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::Tick => {
|
||||||
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
|
state.misc.drain_expired_toasts(now_ms);
|
||||||
|
let api_response = if let Ok(mut guard) = state.pending_api_response.lock() {
|
||||||
|
guard.take()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(response) = api_response {
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
if response.starts_with("Error:") {
|
||||||
|
let toast = crate::app::state::types::Toast::new(
|
||||||
|
crate::app::state::types::ToastKind::Error,
|
||||||
|
response,
|
||||||
|
);
|
||||||
|
state.push_toast(toast);
|
||||||
|
} else {
|
||||||
|
rt.push_message(ChatMessage::assistant(Some(response)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::RecordUsage { tokens_in, tokens_out, duration_ms } => {
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
rt.record_api_call(tokens_in, tokens_out, duration_ms);
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::RecordReviewTokens { tokens } => {
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
rt.record_review_tokens(tokens);
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
pub mod sessions;
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod actions;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod event_loop;
|
||||||
|
pub mod shortsend;
|
||||||
|
pub mod stream;
|
||||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod tools;
|
||||||
|
pub mod turn;
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,29 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub struct SecDaemon {
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
pub running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecDaemon {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
SecDaemon {
|
||||||
|
pid: None,
|
||||||
|
running: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&mut self) -> Result<()> {
|
||||||
|
self.running = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&mut self) -> Result<()> {
|
||||||
|
self.running = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn health_check() -> Result<bool> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod daemon;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StateDiff {
|
||||||
|
changes: Vec<Change>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Change {
|
||||||
|
pub path: String,
|
||||||
|
pub kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StateDiff {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
StateDiff { changes: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_change(&mut self, path: String, kind: String) {
|
||||||
|
self.changes.push(Change { path, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.changes.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.changes.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_diff(before: &serde_json::Value, after: &serde_json::Value) -> Vec<Change> {
|
||||||
|
if before == after {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
vec![Change {
|
||||||
|
path: ".".to_string(),
|
||||||
|
kind: "modified".to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -0,0 +1,195 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use super::types::Overlay;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DirCache {
|
||||||
|
entries: Arc<RwLock<Vec<PathBuf>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DirCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
DirCache {
|
||||||
|
entries: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set(&self, paths: Vec<PathBuf>) {
|
||||||
|
let mut w = self.entries.write().await;
|
||||||
|
*w = paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self) -> Vec<PathBuf> {
|
||||||
|
let r = self.entries.read().await;
|
||||||
|
r.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScrollState {
|
||||||
|
pub offset: usize,
|
||||||
|
pub max_visible: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScrollState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
ScrollState {
|
||||||
|
offset: 0,
|
||||||
|
max_visible: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_up(&mut self) {
|
||||||
|
if self.offset > 0 {
|
||||||
|
self.offset -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_down(&mut self, total: usize) {
|
||||||
|
let max_offset = total.saturating_sub(self.max_visible);
|
||||||
|
if self.offset < max_offset {
|
||||||
|
self.offset += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_to_bottom(&mut self, total: usize) {
|
||||||
|
self.offset = total.saturating_sub(self.max_visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_max_visible(&mut self, max: usize) {
|
||||||
|
self.max_visible = max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct InputState {
|
||||||
|
pub buffer: String,
|
||||||
|
pub cursor: usize,
|
||||||
|
pub history: Vec<String>,
|
||||||
|
pub history_idx: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
InputState {
|
||||||
|
buffer: String::new(),
|
||||||
|
cursor: 0,
|
||||||
|
history: Vec::new(),
|
||||||
|
history_idx: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn char_left(&mut self) {
|
||||||
|
if self.cursor > 0 {
|
||||||
|
self.cursor -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn char_right(&mut self) {
|
||||||
|
if self.cursor < self.buffer.len() {
|
||||||
|
self.cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, c: char) {
|
||||||
|
self.buffer.insert(self.cursor, c);
|
||||||
|
self.cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_left(&mut self) {
|
||||||
|
if self.cursor > 0 {
|
||||||
|
self.cursor -= 1;
|
||||||
|
self.buffer.remove(self.cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_right(&mut self) {
|
||||||
|
if self.cursor < self.buffer.len() {
|
||||||
|
self.buffer.remove(self.cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn submit(&mut self) -> String {
|
||||||
|
let result = self.buffer.clone();
|
||||||
|
if !result.is_empty() {
|
||||||
|
self.history.push(result.clone());
|
||||||
|
self.history_idx = None;
|
||||||
|
}
|
||||||
|
self.buffer.clear();
|
||||||
|
self.cursor = 0;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn history_up(&mut self) {
|
||||||
|
if self.history.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let idx = match self.history_idx {
|
||||||
|
Some(i) if i > 0 => i - 1,
|
||||||
|
None => self.history.len() - 1,
|
||||||
|
Some(_) => return,
|
||||||
|
};
|
||||||
|
self.history_idx = Some(idx);
|
||||||
|
self.buffer = self.history[idx].clone();
|
||||||
|
self.cursor = self.buffer.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn history_down(&mut self) {
|
||||||
|
match self.history_idx {
|
||||||
|
Some(i) if i < self.history.len() - 1 => {
|
||||||
|
let idx = i + 1;
|
||||||
|
self.history_idx = Some(idx);
|
||||||
|
self.buffer = self.history[idx].clone();
|
||||||
|
self.cursor = self.buffer.len();
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
self.history_idx = None;
|
||||||
|
self.buffer.clear();
|
||||||
|
self.cursor = 0;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.buffer.clear();
|
||||||
|
self.cursor = 0;
|
||||||
|
self.history_idx = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MiscState {
|
||||||
|
pub overlay: Overlay,
|
||||||
|
pub toasts: Vec<super::types::Toast>,
|
||||||
|
pub dirty: bool,
|
||||||
|
pub yolo_armed: bool,
|
||||||
|
pub security_armed: bool,
|
||||||
|
pub security_acknowledged: bool,
|
||||||
|
pub esc_press_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MiscState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
MiscState {
|
||||||
|
overlay: Overlay::None,
|
||||||
|
toasts: Vec::new(),
|
||||||
|
dirty: true,
|
||||||
|
yolo_armed: false,
|
||||||
|
security_armed: false,
|
||||||
|
security_acknowledged: false,
|
||||||
|
esc_press_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_toast(&mut self, toast: super::types::Toast) {
|
||||||
|
self.toasts.push(toast);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<super::types::Toast> {
|
||||||
|
let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect();
|
||||||
|
self.toasts.retain(|t| !t.expired(now_ms));
|
||||||
|
expired
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod input;
|
||||||
|
pub mod misc;
|
||||||
|
pub mod rest;
|
||||||
|
pub mod runtime;
|
||||||
|
pub mod scroll;
|
||||||
|
pub mod diff;
|
||||||
|
pub mod snapshot;
|
||||||
|
pub mod types;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use super::misc::{DirCache, InputState, MiscState, ScrollState};
|
||||||
|
use super::runtime::SessionRuntime;
|
||||||
|
use super::types::{AgentMode, Origin, Toast, TranscriptCache};
|
||||||
|
use crate::model::editlog::EditLog;
|
||||||
|
use crate::model::settings::Settings;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ChatMessageDisplay {
|
||||||
|
pub role: crate::dto::chat::message::Role,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatMessageDisplay {
|
||||||
|
pub fn new(role: crate::dto::chat::message::Role, content: String) -> Self {
|
||||||
|
ChatMessageDisplay {
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CronJob {
|
||||||
|
pub id: String,
|
||||||
|
pub description: String,
|
||||||
|
pub cron_expr: String,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppStateRest {
|
||||||
|
pub mode: AgentMode,
|
||||||
|
pub settings: Settings,
|
||||||
|
pub workspace_roots: Vec<PathBuf>,
|
||||||
|
pub session_dir: PathBuf,
|
||||||
|
pub memory_dir: PathBuf,
|
||||||
|
pub download_dir: PathBuf,
|
||||||
|
pub worktrees_dir: PathBuf,
|
||||||
|
pub current_dir: PathBuf,
|
||||||
|
pub dir_cache: Arc<RwLock<DirCache>>,
|
||||||
|
pub edit_log: EditLog,
|
||||||
|
pub session_runtime: Option<SessionRuntime>,
|
||||||
|
pub sessions: Vec<crate::model::session::Session>,
|
||||||
|
pub crons: Vec<CronJob>,
|
||||||
|
pub transcript_cache: TranscriptCache,
|
||||||
|
pub scroll: ScrollState,
|
||||||
|
pub input: InputState,
|
||||||
|
pub misc: MiscState,
|
||||||
|
pub pending_api_response: Arc<Mutex<Option<String>>>,
|
||||||
|
pub dirty: bool,
|
||||||
|
pub quit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppStateRest {
|
||||||
|
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self {
|
||||||
|
let settings = Settings::load();
|
||||||
|
let download_dir = memory_dir.parent().unwrap_or(&memory_dir).join("downloads");
|
||||||
|
let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees");
|
||||||
|
let dir_cache = DirCache::new();
|
||||||
|
AppStateRest {
|
||||||
|
mode: AgentMode::Normal,
|
||||||
|
settings,
|
||||||
|
workspace_roots,
|
||||||
|
session_dir: session_dir.clone(),
|
||||||
|
memory_dir,
|
||||||
|
download_dir,
|
||||||
|
worktrees_dir,
|
||||||
|
current_dir: std::env::current_dir().unwrap_or_default(),
|
||||||
|
pending_api_response: Arc::new(Mutex::new(None)),
|
||||||
|
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
||||||
|
edit_log: EditLog::new(&session_dir),
|
||||||
|
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
|
||||||
|
sessions: Vec::new(),
|
||||||
|
crons: Vec::new(),
|
||||||
|
transcript_cache: TranscriptCache::new(200),
|
||||||
|
scroll: ScrollState::new(),
|
||||||
|
input: InputState::new(),
|
||||||
|
misc: MiscState::new(),
|
||||||
|
dirty: true,
|
||||||
|
quit: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mode(&self) -> AgentMode {
|
||||||
|
self.mode
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_mode(&mut self, mode: AgentMode) {
|
||||||
|
self.mode = mode;
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
|
||||||
|
self.transcript_cache.messages.push(msg);
|
||||||
|
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
|
||||||
|
self.transcript_cache.messages.remove(0);
|
||||||
|
}
|
||||||
|
self.transcript_cache.dirty = true;
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_toast(&mut self, toast: Toast) {
|
||||||
|
self.misc.push_toast(toast);
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
|
||||||
|
crate::tool::ToolCtx {
|
||||||
|
workspaces: self.workspace_roots.clone(),
|
||||||
|
session_dir: self.session_dir.clone(),
|
||||||
|
memory_dir: self.memory_dir.clone(),
|
||||||
|
download_dir: self.download_dir.clone(),
|
||||||
|
worktrees_dir: self.worktrees_dir.clone(),
|
||||||
|
dir_cache: self.dir_cache.clone(),
|
||||||
|
internet_mode: self.settings.internet_mode.clone(),
|
||||||
|
origin: Origin::Main,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 50 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user