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:
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod sessions;
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod actions;
|
||||
pub mod commands;
|
||||
pub mod event_loop;
|
||||
pub mod shortsend;
|
||||
pub mod stream;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod tools;
|
||||
pub mod turn;
|
||||
@@ -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(),
|
||||
}]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub struct UsageStats {
|
||||
pub tokens_in: u64,
|
||||
pub tokens_out: u64,
|
||||
pub api_calls: u64,
|
||||
pub review_tokens: u64,
|
||||
pub total_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionRuntime {
|
||||
pub messages: Vec<crate::dto::chat::message::ChatMessage>,
|
||||
pub tool_call_results: Vec<ToolCallResult>,
|
||||
pub pending_tool_queue: Vec<PendingTool>,
|
||||
pub bash_jobs: Vec<BashJobRef>,
|
||||
pub subagent_queue: usize,
|
||||
pub edit_count: u32,
|
||||
pub consecutive_empty_reviews: u32,
|
||||
pub session_start: i64,
|
||||
pub lesson_count: u32,
|
||||
pub lessons_user: u32,
|
||||
pub lessons_feedback: u32,
|
||||
pub lessons_project: u32,
|
||||
pub lessons_reference: u32,
|
||||
pub lessons_active: u32,
|
||||
pub lessons_stale: u32,
|
||||
pub lessons_contradicted: u32,
|
||||
pub lessons_human: u32,
|
||||
pub lessons_verified: u32,
|
||||
pub lessons_unverified: u32,
|
||||
pub review_count: u32,
|
||||
pub session_dir: PathBuf,
|
||||
pub usage: UsageStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallResult {
|
||||
pub tool_call_id: String,
|
||||
pub tool_name: String,
|
||||
pub output: String,
|
||||
pub is_error: bool,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingTool {
|
||||
pub tool_name: String,
|
||||
pub args: serde_json::Value,
|
||||
pub execution_model: crate::app::state::types::ExecutionModel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BashJobRef {
|
||||
pub id: String,
|
||||
pub command: String,
|
||||
pub started_at: i64,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
impl SessionRuntime {
|
||||
pub fn new(session_dir: PathBuf) -> Self {
|
||||
SessionRuntime {
|
||||
messages: Vec::new(),
|
||||
tool_call_results: Vec::new(),
|
||||
pending_tool_queue: Vec::new(),
|
||||
bash_jobs: Vec::new(),
|
||||
subagent_queue: 0,
|
||||
edit_count: 0,
|
||||
consecutive_empty_reviews: 0,
|
||||
session_start: chrono::Utc::now().timestamp_millis(),
|
||||
lesson_count: 0,
|
||||
lessons_user: 0,
|
||||
lessons_feedback: 0,
|
||||
lessons_project: 0,
|
||||
lessons_reference: 0,
|
||||
lessons_active: 0,
|
||||
lessons_stale: 0,
|
||||
lessons_contradicted: 0,
|
||||
lessons_human: 0,
|
||||
lessons_verified: 0,
|
||||
lessons_unverified: 0,
|
||||
review_count: 0,
|
||||
session_dir,
|
||||
usage: UsageStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_message(&mut self, msg: crate::dto::chat::message::ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
pub fn record_api_call(&mut self, tokens_in: u64, tokens_out: u64, duration_ms: u64) {
|
||||
self.usage.tokens_in += tokens_in;
|
||||
self.usage.tokens_out += tokens_out;
|
||||
self.usage.api_calls += 1;
|
||||
self.usage.total_ms += duration_ms;
|
||||
}
|
||||
|
||||
pub fn record_review_tokens(&mut self, tokens: u64) {
|
||||
self.usage.review_tokens += tokens;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateSnapshot {
|
||||
pub snapshot: serde_json::Value,
|
||||
}
|
||||
|
||||
impl StateSnapshot {
|
||||
pub fn new() -> Self {
|
||||
StateSnapshot {
|
||||
snapshot: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result<Vec<u8>> {
|
||||
Ok(serde_json::to_vec(snapshot)?)
|
||||
}
|
||||
|
||||
pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> {
|
||||
Ok(serde_json::from_slice(data)?)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentMode {
|
||||
Auto,
|
||||
Normal,
|
||||
Plan,
|
||||
Yolo,
|
||||
}
|
||||
|
||||
impl AgentMode {
|
||||
pub fn auto_approve(&self) -> bool {
|
||||
matches!(self, AgentMode::Auto | AgentMode::Yolo)
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
AgentMode::Auto => "Auto",
|
||||
AgentMode::Normal => "Normal",
|
||||
AgentMode::Plan => "Plan",
|
||||
AgentMode::Yolo => "Yolo",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PanelKind {
|
||||
Chat,
|
||||
Agents,
|
||||
Bash,
|
||||
Workflow,
|
||||
Help,
|
||||
SessionHub,
|
||||
}
|
||||
|
||||
impl PanelKind {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
PanelKind::Chat => "Chat",
|
||||
PanelKind::Agents => "Agents",
|
||||
PanelKind::Bash => "Bash",
|
||||
PanelKind::Workflow => "Workflow",
|
||||
PanelKind::Help => "Help",
|
||||
PanelKind::SessionHub => "Sessions",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ToastKind {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
Lesson,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Toast {
|
||||
pub kind: ToastKind,
|
||||
pub message: String,
|
||||
pub created_at: i64,
|
||||
pub lifetime_ms: u64,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
pub fn new(kind: ToastKind, message: String) -> Self {
|
||||
Toast {
|
||||
kind,
|
||||
message,
|
||||
created_at: chrono::Utc::now().timestamp_millis(),
|
||||
lifetime_ms: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expired(&self, now_ms: i64) -> bool {
|
||||
now_ms - self.created_at > self.lifetime_ms as i64
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Overlay {
|
||||
None,
|
||||
Help,
|
||||
Settings,
|
||||
Agents,
|
||||
Bash,
|
||||
QuitConfirm,
|
||||
SessionHub,
|
||||
Workflow,
|
||||
Onboard,
|
||||
OnboardProvider,
|
||||
Picker,
|
||||
KeyInput,
|
||||
Editor,
|
||||
Effort,
|
||||
Mcp,
|
||||
Security,
|
||||
Todo,
|
||||
Rewind,
|
||||
Learning,
|
||||
Usage,
|
||||
Loading,
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
pub fn is_active(self) -> bool {
|
||||
!matches!(self, Overlay::None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TranscriptCache {
|
||||
pub messages: Vec<super::rest::ChatMessageDisplay>,
|
||||
pub max_lines: usize,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl TranscriptCache {
|
||||
pub fn new(max_lines: usize) -> Self {
|
||||
TranscriptCache {
|
||||
messages: Vec::new(),
|
||||
max_lines,
|
||||
dirty: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExecutionModel {
|
||||
Inline,
|
||||
Deferred,
|
||||
AsyncTokio,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub enum Origin {
|
||||
Main,
|
||||
SubAgent,
|
||||
Reviewer,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use std::path::PathBuf;
|
||||
use super::spawn::AgentDefinition;
|
||||
|
||||
pub const REVIEWER_ALLOWED: &[&str] = &["read", "grep", "glob", "recall", "remember"];
|
||||
|
||||
pub struct SubagentContext {
|
||||
pub definition: AgentDefinition,
|
||||
pub system_prompt: String,
|
||||
pub allowed_tools: Vec<String>,
|
||||
pub max_steps: usize,
|
||||
pub session_dir: PathBuf,
|
||||
pub origin: crate::app::state::types::Origin,
|
||||
}
|
||||
|
||||
pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext {
|
||||
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
|
||||
if def.role == "reviewer" {
|
||||
REVIEWER_ALLOWED.iter().map(|s| s.to_string()).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
});
|
||||
SubagentContext {
|
||||
definition: def,
|
||||
system_prompt: String::new(),
|
||||
allowed_tools,
|
||||
max_steps: 25,
|
||||
session_dir: PathBuf::new(),
|
||||
origin: crate::app::state::types::Origin::SubAgent,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use tokio::sync::mpsc;
|
||||
use super::context::SubagentContext;
|
||||
use super::event::SubagentEvent;
|
||||
|
||||
pub const MAX_AGENT_STEPS: usize = 25;
|
||||
|
||||
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
|
||||
let mut output = String::new();
|
||||
for step in 0..ctx.max_steps.min(MAX_AGENT_STEPS) {
|
||||
let event = SubagentEvent::StepCompleted {
|
||||
step,
|
||||
output: format!("step {} completed", step),
|
||||
};
|
||||
let _ = tx.blocking_send(event);
|
||||
output.push_str(&format!("step {} completed\n", step));
|
||||
}
|
||||
let _ = tx.blocking_send(SubagentEvent::Completed { output: output.clone() });
|
||||
Ok(output)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubagentEvent {
|
||||
StepCompleted {
|
||||
step: usize,
|
||||
output: String,
|
||||
},
|
||||
StepFailed {
|
||||
step: usize,
|
||||
error: String,
|
||||
},
|
||||
Completed {
|
||||
output: String,
|
||||
},
|
||||
Failed {
|
||||
error: String,
|
||||
},
|
||||
ToolCall {
|
||||
tool: String,
|
||||
args: Value,
|
||||
},
|
||||
ToolResult {
|
||||
tool: String,
|
||||
output: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod context;
|
||||
pub mod engine;
|
||||
pub mod event;
|
||||
pub mod spawn;
|
||||
@@ -0,0 +1,55 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDefinition {
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
pub max_steps: Option<usize>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl AgentDefinition {
|
||||
pub fn new(name: String, role: String) -> Self {
|
||||
AgentDefinition {
|
||||
name,
|
||||
role,
|
||||
system_prompt: None,
|
||||
allowed_tools: None,
|
||||
max_steps: None,
|
||||
temperature: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_system_prompt(mut self, prompt: String) -> Self {
|
||||
self.system_prompt = Some(prompt);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
|
||||
self.allowed_tools = Some(tools);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
||||
self.max_steps = Some(steps);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_temperature(mut self, temp: f32) -> Self {
|
||||
self.temperature = Some(temp);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_agent_defs(base: AgentDefinition, overrides: AgentDefinition) -> AgentDefinition {
|
||||
AgentDefinition {
|
||||
name: base.name,
|
||||
role: base.role,
|
||||
system_prompt: overrides.system_prompt.or(base.system_prompt),
|
||||
allowed_tools: overrides.allowed_tools.or(base.allowed_tools),
|
||||
max_steps: overrides.max_steps.or(base.max_steps),
|
||||
temperature: overrides.temperature.or(base.temperature),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::script::{ScriptPrimitive, WorkflowScript};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentState {
|
||||
Idle,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentStatus {
|
||||
pub state: AgentState,
|
||||
pub started_at: Option<i64>,
|
||||
pub completed_at: Option<i64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl AgentStatus {
|
||||
pub fn new() -> Self {
|
||||
AgentStatus {
|
||||
state: AgentState::Idle,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowAgent {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub status: AgentStatus,
|
||||
}
|
||||
|
||||
impl WorkflowAgent {
|
||||
pub fn new(id: String, name: String) -> Self {
|
||||
WorkflowAgent {
|
||||
id,
|
||||
name,
|
||||
status: AgentStatus::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkflowEngine {
|
||||
pub agents: Vec<WorkflowAgent>,
|
||||
pub concurrency_cap: usize,
|
||||
pub findings: Vec<String>,
|
||||
}
|
||||
|
||||
impl WorkflowEngine {
|
||||
pub fn new() -> Self {
|
||||
WorkflowEngine {
|
||||
agents: Vec::new(),
|
||||
concurrency_cap: 5,
|
||||
findings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_concurrency_cap(mut self, cap: usize) -> Self {
|
||||
self.concurrency_cap = cap;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_agent(&mut self, agent: WorkflowAgent) {
|
||||
self.agents.push(agent);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_primitive(primitive: &ScriptPrimitive, args: &HashMap<String, String>) -> anyhow::Result<()> {
|
||||
match primitive {
|
||||
ScriptPrimitive::Agent(name) => {
|
||||
let _agent_name = name;
|
||||
let _args = args;
|
||||
Ok(())
|
||||
}
|
||||
ScriptPrimitive::Parallel(scripts) => {
|
||||
for script in scripts {
|
||||
execute_primitive(script, args)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ScriptPrimitive::Pipeline(scripts) => {
|
||||
for script in scripts {
|
||||
execute_primitive(script, args)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ScriptPrimitive::Phase { name: _name, script } => {
|
||||
execute_primitive(script, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) -> anyhow::Result<String> {
|
||||
let concurrency_cap = if script.options.max_concurrency > 0 {
|
||||
script.options.max_concurrency.min(5)
|
||||
} else {
|
||||
5
|
||||
};
|
||||
let _cap = concurrency_cap;
|
||||
execute_primitive(&script.script, args)?;
|
||||
Ok("workflow completed".to_string())
|
||||
}
|
||||
|
||||
pub fn note_finding(text: &str) {
|
||||
let _finding = text;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod engine;
|
||||
pub mod script;
|
||||
@@ -0,0 +1,37 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ScriptPrimitive {
|
||||
Agent(String),
|
||||
Parallel(Vec<ScriptPrimitive>),
|
||||
Pipeline(Vec<ScriptPrimitive>),
|
||||
Phase {
|
||||
name: String,
|
||||
script: Box<ScriptPrimitive>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScriptOptions {
|
||||
pub max_concurrency: usize,
|
||||
pub continue_on_error: bool,
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for ScriptOptions {
|
||||
fn default() -> Self {
|
||||
ScriptOptions {
|
||||
max_concurrency: 5,
|
||||
continue_on_error: false,
|
||||
timeout_ms: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowScript {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub script: ScriptPrimitive,
|
||||
pub options: ScriptOptions,
|
||||
}
|
||||
Reference in New Issue
Block a user