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

- Added `chat.rs` for rendering chat messages with timestamps and roles.
- Introduced `markdown.rs` for rendering markdown content with styling.
- Created `status.rs` to display the application status bar with session and message counts.
- Developed `workflow.rs` to show the current workflow status, including tool calls and active jobs.
- Established a `theme.rs` for centralized color management across the UI.
- Updated `mod.rs` to include new modules and manage rendering logic.
This commit is contained in:
asepharyana
2026-07-11 13:16:10 +07:00
parent 7cb4ae6708
commit cc03bd79b6
131 changed files with 10994 additions and 0 deletions
+38
View File
@@ -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
}
+75
View File
@@ -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()
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod control;
pub mod job;
+152
View File
@@ -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(())
}
}
+66
View File
@@ -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
}
}
+78
View File
@@ -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(())
}
}
+1
View File
@@ -0,0 +1 @@
pub mod manager;
+11
View File
@@ -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;
View File
View File
View File
View File
View File
View File
View File
View File
+73
View File
@@ -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)
}
}
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+172
View File
@@ -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(())
}
+263
View File
@@ -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;
}
}
}
View File
+1
View File
@@ -0,0 +1 @@
pub mod sessions;
+5
View File
@@ -0,0 +1,5 @@
pub mod actions;
pub mod commands;
pub mod event_loop;
pub mod shortsend;
pub mod stream;
View File
View File
View File
+2
View File
@@ -0,0 +1,2 @@
pub mod tools;
pub mod turn;
View File
View File
+29
View File
@@ -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)
}
+1
View File
@@ -0,0 +1 @@
pub mod daemon;
+40
View File
@@ -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(),
}]
}
View File
+195
View File
@@ -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
}
}
+8
View File
@@ -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;
+125
View File
@@ -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,
}
}
}
+105
View File
@@ -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;
}
}
View File
+22
View File
@@ -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)?)
}
+141
View File
@@ -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,
}
+31
View File
@@ -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,
}
}
+19
View File
@@ -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)
}
+27
View File
@@ -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,
},
}
+4
View File
@@ -0,0 +1,4 @@
pub mod context;
pub mod engine;
pub mod event;
pub mod spawn;
+55
View File
@@ -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),
}
}
+113
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
pub mod engine;
pub mod script;
+37
View File
@@ -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,
}