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
+66
View File
@@ -0,0 +1,66 @@
use crate::app::subagent::spawn::AgentDefinition;
pub fn builtin_agents() -> Vec<AgentDefinition> {
vec![
AgentDefinition::new(
"coder".to_string(),
"coder".to_string(),
).with_system_prompt(
"You are a coding agent. Write correct, idiomatic Rust code.".to_string()
).with_allowed_tools(
vec![
"read".to_string(),
"write".to_string(),
"edit".to_string(),
"bash".to_string(),
"grep".to_string(),
"glob".to_string(),
"git_operator".to_string(),
]
).with_max_steps(25),
AgentDefinition::new(
"reviewer".to_string(),
"reviewer".to_string(),
).with_system_prompt(
"You are a code reviewer. Focus on correctness, safety, and performance.".to_string()
).with_allowed_tools(
vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"recall".to_string(),
"remember".to_string(),
]
).with_max_steps(10),
AgentDefinition::new(
"researcher".to_string(),
"researcher".to_string(),
).with_system_prompt(
"You are a research agent. Search and synthesize information.".to_string()
).with_allowed_tools(
vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"search".to_string(),
"web_fetch".to_string(),
]
).with_max_steps(15),
AgentDefinition::new(
"planner".to_string(),
"planner".to_string(),
).with_system_prompt(
"You are a planning agent. Break down tasks into clear steps.".to_string()
).with_allowed_tools(
vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"plan".to_string(),
]
).with_max_steps(20),
]
}
+42
View File
@@ -0,0 +1,42 @@
use crate::app::subagent::spawn::AgentDefinition;
pub fn load_global_agents() -> Vec<AgentDefinition> {
let store = crate::model::store::Store::new();
let agents_dir = store.base_dir.join("agents");
if !agents_dir.exists() {
return Vec::new();
}
let mut agents = Vec::new();
if let Ok(entries) = std::fs::read_dir(&agents_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(def) = serde_json::from_str::<AgentDefinition>(&content) {
agents.push(def);
}
}
}
}
}
agents
}
pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> {
let store = crate::model::store::Store::new();
let agents_dir = store.base_dir.join("agents");
std::fs::create_dir_all(&agents_dir)?;
let path = agents_dir.join(format!("{}.json", def.name));
let content = serde_json::to_string_pretty(def)?;
std::fs::write(path, content)?;
Ok(())
}
pub fn remove_global_agent(name: &str) -> anyhow::Result<()> {
let store = crate::model::store::Store::new();
let path = store.base_dir.join("agents").join(format!("{}.json", name));
if path.exists() {
std::fs::remove_file(path)?;
}
Ok(())
}
+3
View File
@@ -0,0 +1,3 @@
pub mod builtin;
pub mod global;
pub mod session;
+35
View File
@@ -0,0 +1,35 @@
use std::path::Path;
use crate::app::subagent::spawn::AgentDefinition;
pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
let agents_file = session_dir.join("agents.json");
if !agents_file.exists() {
return Vec::new();
}
match std::fs::read_to_string(&agents_file) {
Ok(content) => {
serde_json::from_str(&content).unwrap_or_default()
}
Err(_) => Vec::new(),
}
}
pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> {
let agents_file = session_dir.join("agents.json");
let content = serde_json::to_string_pretty(agents)?;
std::fs::write(agents_file, content)?;
Ok(())
}
pub fn add_session_agent(session_dir: &Path, def: AgentDefinition) -> anyhow::Result<()> {
let mut agents = load_session_agents(session_dir);
agents.retain(|a| a.name != def.name);
agents.push(def);
save_session_agents(session_dir, &agents)
}
pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<()> {
let mut agents = load_session_agents(session_dir);
agents.retain(|a| a.name != name);
save_session_agents(session_dir, &agents)
}
+60
View File
@@ -0,0 +1,60 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub providers: HashMap<String, ProviderConfig>,
pub model_roles: HashMap<String, ModelRole>,
pub default_provider: String,
pub default_model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub api_base: String,
pub api_key_env: Option<String>,
pub default_model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRole {
pub provider: String,
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
}
impl Default for AppConfig {
fn default() -> Self {
let mut providers = HashMap::new();
providers.insert("openrouter".to_string(), ProviderConfig {
api_base: "https://openrouter.ai/api/v1".to_string(),
api_key_env: Some("OPENROUTER_API_KEY".to_string()),
default_model: Some("anthropic/claude-opus-4-8".to_string()),
});
let mut model_roles = HashMap::new();
model_roles.insert("default".to_string(), ModelRole {
provider: "openrouter".to_string(),
model: "anthropic/claude-opus-4-8".to_string(),
max_tokens: Some(8192),
temperature: Some(0.7),
});
AppConfig {
providers,
model_roles,
default_provider: "openrouter".to_string(),
default_model: "anthropic/claude-opus-4-8".to_string(),
}
}
}
impl AppConfig {
pub fn load() -> Self {
let store = super::store::Store::new();
let path = store.base_dir.join("app_config.json");
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
}
+46
View File
@@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation {
pub messages: Vec<crate::dto::chat::message::ChatMessage>,
pub system_prompt: String,
pub session_id: String,
pub model: String,
pub max_tokens: u32,
pub temperature: f32,
}
impl Conversation {
pub fn new(system_prompt: String, session_id: String) -> Self {
Conversation {
messages: Vec::new(),
system_prompt,
session_id,
model: "anthropic/claude-opus-4-8".to_string(),
max_tokens: 8192,
temperature: 0.7,
}
}
pub fn push(&mut self, msg: crate::dto::chat::message::ChatMessage) {
self.messages.push(msg);
}
pub fn rebuild_system(&mut self, new_prompt: String) {
self.system_prompt = new_prompt;
self.messages.retain(|m| {
!matches!(m.role, crate::dto::chat::message::Role::System)
});
}
pub fn to_api_messages(&self) -> Vec<crate::dto::chat::message::ChatMessage> {
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
msgs.push(crate::dto::chat::message::ChatMessage::system(&self.system_prompt));
msgs.extend(self.messages.iter().cloned());
msgs
}
pub fn len(&self) -> usize {
self.messages.len()
}
}
+64
View File
@@ -0,0 +1,64 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditLogEntry {
pub ts: i64,
pub tool: String,
pub path: String,
pub reason: String,
pub content_sha256: String,
pub bytes_delta: i64,
pub origin: String,
pub session_id: String,
}
#[derive(Debug, Clone)]
pub struct EditLog {
pub entries: Vec<EditLogEntry>,
pub path: std::path::PathBuf,
}
impl EditLog {
pub fn new(session_dir: &std::path::Path) -> Self {
EditLog {
entries: Vec::new(),
path: session_dir.join("edits.jsonl"),
}
}
pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> {
let line = serde_json::to_string(&entry)? + "\n";
let parent = self.path.parent().unwrap();
std::fs::create_dir_all(parent)?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
use std::io::Write;
file.write_all(line.as_bytes())?;
self.entries.push(entry);
Ok(())
}
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
let content = std::fs::read_to_string(path)?;
let entries: Vec<EditLogEntry> = content
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
Ok(EditLog {
entries,
path: path.to_path_buf(),
})
}
pub fn recent(&self, n: usize) -> &[EditLogEntry] {
let len = self.entries.len();
let start = len.saturating_sub(n);
&self.entries[start..]
}
pub fn len(&self) -> usize {
self.entries.len()
}
}
+153
View File
@@ -0,0 +1,153 @@
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::session::Session;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memory {
pub name: String,
pub description: String,
pub content: String,
pub kind: String,
pub created_at: i64,
pub updated_at: i64,
pub outcome: Option<String>,
pub lifecycle: String,
}
impl Memory {
pub fn slugify(s: &str) -> Option<String> {
let slug: String = s
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let slug: String = slug
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
if slug.is_empty() || slug.len() > 80 {
return None;
}
Some(slug)
}
pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
slug_path(memory_dir, &format!("{}.md", slug))
}
pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> {
let path = Self::path(memory_dir, &self.name);
let parent = path.parent().unwrap();
std::fs::create_dir_all(parent)?;
let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default();
let content = format!(
"---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n---\n\n{}",
self.name, self.description, self.kind, self.created_at, self.updated_at, self.lifecycle, outcome_line, self.content
);
let tmp = parent.join(format!(".{}.tmp", std::process::id()));
std::fs::write(&tmp, &content)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn read(memory_dir: &Path, name: &str) -> std::io::Result<Self> {
let path = Self::path(memory_dir, name);
let content = std::fs::read_to_string(&path)?;
Self::parse(&content)
}
pub fn parse(content: &str) -> std::io::Result<Self> {
let parts: Vec<&str> = content.splitn(2, "---\n").collect();
if parts.len() < 2 {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing frontmatter"));
}
let front: std::collections::HashMap<String, String> = parts[0]
.lines()
.filter_map(|l| {
let mut it = l.splitn(2, ':');
Some((it.next()?.trim().to_string(), it.next()?.trim().to_string()))
})
.collect();
let body = parts.get(1).unwrap_or(&"").trim().to_string();
Ok(Memory {
name: front.get("name").cloned().unwrap_or_default(),
description: front.get("description").cloned().unwrap_or_default(),
content: body,
kind: front.get("kind").cloned().unwrap_or_else(|| "reference".to_string()),
created_at: front.get("created_at").and_then(|v| v.parse().ok()).unwrap_or(0),
updated_at: front.get("updated_at").and_then(|v| v.parse().ok()).unwrap_or(0),
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
lifecycle: front.get("lifecycle").cloned().unwrap_or_else(|| "new".to_string()),
})
}
pub fn remove(memory_dir: &Path, name: &str) -> std::io::Result<()> {
let path = Self::path(memory_dir, name);
if path.exists() {
std::fs::remove_file(path)?;
}
Ok(())
}
pub fn list(memory_dir: &Path) -> Vec<String> {
let entries = match std::fs::read_dir(memory_dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name == "MEMORY.md" { return None; }
let slug = name.strip_suffix(".md")?.to_string();
Some(slug)
})
.collect()
}
pub fn load_index(memory_dir: &Path) -> Vec<String> {
let index_path = memory_dir.join("MEMORY.md");
let content = std::fs::read_to_string(index_path).unwrap_or_default();
content.lines().filter_map(|l| {
let l = l.trim();
if l.is_empty() || l.starts_with('#') { return None; }
l.split(']').next().and_then(|s| s.split('[').nth(1)).map(|s| s.to_string())
}).collect()
}
}
pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
let clean: String = raw.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '-' })
.collect();
let clean = clean.trim_start_matches('.').to_string();
memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean })
}
pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result<Memory> {
let now = chrono::Utc::now().timestamp_millis();
let lessons_content: String = lessons.iter()
.map(|l| format!("- {}: {}", l.name, l.description))
.collect::<Vec<_>>()
.join("\n");
let content = format!(
"# Session Retrospective\n\nSession: {}\nCreated: {}\nLessons learned:\n{}\n",
session.title, now, lessons_content,
);
let memory = Memory {
name: format!("retrospective-{}", session.id),
description: format!("End-of-session retrospective for {}", session.title),
content,
kind: "retrospective".to_string(),
created_at: now,
updated_at: now,
outcome: None,
lifecycle: "new".to_string(),
};
memory.write(session_dir)?;
Ok(memory)
}
+10
View File
@@ -0,0 +1,10 @@
pub mod agent_def;
pub mod app_config;
pub mod conversation;
pub mod editlog;
pub mod memory;
pub mod msglog;
pub mod session;
pub mod session_lock;
pub mod settings;
pub mod store;
+46
View File
@@ -0,0 +1,46 @@
use rusqlite::{Connection, params};
use anyhow::Result;
pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> {
let created_at = chrono::Utc::now().timestamp_millis();
conn.execute(
"INSERT OR REPLACE INTO blobs (session_id, blob_key, data, mime_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![session_id, blob_key, data, mime_type, created_at],
)?;
Ok(())
}
pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<Option<Vec<u8>>> {
let result = conn.query_row(
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
params![session_id, blob_key],
|row| row.get(0),
);
match result {
Ok(data) => Ok(Some(data)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<bool> {
let rows = conn.execute(
"DELETE FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
params![session_id, blob_key],
)?;
Ok(rows > 0)
}
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC"
)?;
let rows = stmt.query_map(params![session_id], |row| {
row.get::<_, String>(0)
})?;
let mut keys = Vec::new();
for row in rows {
keys.push(row?);
}
Ok(keys)
}
+4
View File
@@ -0,0 +1,4 @@
pub mod blobs;
pub mod query;
pub mod schema;
pub mod summary;
+68
View File
@@ -0,0 +1,68 @@
use rusqlite::{Connection, params};
use anyhow::Result;
use crate::dto::chat::message::{ChatMessage, Role};
pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result<i64> {
let content = msg.content.as_deref();
let tool_call_id = msg.tool_call_id.as_deref();
let tool_name = msg.name.as_deref();
let tool_arguments = msg.tool_calls.as_ref().map(|calls| {
serde_json::to_string(calls).unwrap_or_default()
});
let created_at = chrono::Utc::now().timestamp_millis();
let role_str = match msg.role {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => "system",
Role::Tool => "tool",
};
conn.execute(
"INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, tool_arguments, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![session_id, role_str, content, tool_call_id, tool_name, tool_arguments, created_at],
)?;
Ok(conn.last_insert_rowid())
}
pub fn query_messages(conn: &Connection, session_id: &str, limit: usize, offset: usize) -> Result<Vec<ChatMessage>> {
let mut stmt = conn.prepare(
"SELECT role, content, tool_call_id, tool_name, tool_arguments FROM messages WHERE session_id = ?1 ORDER BY id ASC LIMIT ?2 OFFSET ?3"
)?;
let rows = stmt.query_map(params![session_id, limit as i64, offset as i64], |row| {
let role_str: String = row.get(0)?;
let content: Option<String> = row.get(1)?;
let tool_call_id: Option<String> = row.get(2)?;
let tool_name: Option<String> = row.get(3)?;
let tool_arguments: Option<String> = row.get(4)?;
let role = match role_str.as_str() {
"user" => Role::User,
"assistant" => Role::Assistant,
"system" => Role::System,
"tool" => Role::Tool,
_ => Role::User,
};
let tool_calls = tool_arguments.and_then(|args| {
serde_json::from_str(&args).ok()
});
Ok(ChatMessage {
role,
content,
tool_calls,
tool_call_id,
name: tool_name,
})
})?;
let mut messages = Vec::new();
for row in rows {
messages.push(row?);
}
Ok(messages)
}
pub fn count_messages(conn: &Connection, session_id: &str) -> Result<i64> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM messages WHERE session_id = ?1",
params![session_id],
|row| row.get(0),
)?;
Ok(count)
}
+47
View File
@@ -0,0 +1,47 @@
use rusqlite::Connection;
use anyhow::Result;
pub fn init_schema(conn: &Connection) -> Result<()> {
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_name TEXT,
tool_arguments TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES archives(session_id)
);
CREATE TABLE IF NOT EXISTS archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
title TEXT,
model TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
message_count INTEGER DEFAULT 0,
token_count INTEGER DEFAULT 0,
summary TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
CREATE INDEX IF NOT EXISTS idx_archives_created_at ON archives(created_at);
CREATE TABLE IF NOT EXISTS blobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
blob_key TEXT NOT NULL,
data BLOB NOT NULL,
mime_type TEXT,
created_at INTEGER NOT NULL,
UNIQUE(session_id, blob_key)
);
"
)?;
Ok(())
}
+40
View File
@@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryRecord {
pub session_id: String,
pub title: String,
pub model: String,
pub message_count: usize,
pub token_count: usize,
pub summary: String,
pub created_at: i64,
pub updated_at: i64,
}
impl SummaryRecord {
pub fn new(session_id: String, title: String, model: String) -> Self {
let now = chrono::Utc::now().timestamp_millis();
SummaryRecord {
session_id,
title,
model,
message_count: 0,
token_count: 0,
summary: String::new(),
created_at: now,
updated_at: now,
}
}
pub fn update_summary(&mut self, summary: String) {
self.summary = summary;
self.updated_at = chrono::Utc::now().timestamp_millis();
}
pub fn increment_counts(&mut self, messages: usize, tokens: usize) {
self.message_count += messages;
self.token_count += tokens;
self.updated_at = chrono::Utc::now().timestamp_millis();
}
}
+85
View File
@@ -0,0 +1,85 @@
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use chrono::Utc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub created_at: i64,
pub updated_at: i64,
pub title: String,
pub model: String,
pub workspace_roots: Vec<PathBuf>,
pub message_count: u32,
pub token_count: u32,
pub archived: bool,
pub summary: Option<String>,
}
impl Session {
pub fn new(id: String, title: String) -> Self {
let now = Utc::now().timestamp_millis();
Session {
id,
created_at: now,
updated_at: now,
title,
model: "anthropic/claude-opus-4-8".to_string(),
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
message_count: 0,
token_count: 0,
archived: false,
summary: None,
}
}
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
base_dir.join("sessions").join(&self.id)
}
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
self.session_dir(base_dir).join("conversation.json")
}
pub fn edit_log_path(&self, base_dir: &Path) -> PathBuf {
self.session_dir(base_dir).join("edits.jsonl")
}
pub fn msglog_path(&self, base_dir: &Path) -> PathBuf {
self.session_dir(base_dir).join("msglog.db")
}
pub fn save(&self, base_dir: &Path) -> std::io::Result<()> {
let dir = self.session_dir(base_dir);
std::fs::create_dir_all(&dir)?;
let path = dir.join("session.json");
let data = serde_json::to_string_pretty(self)?;
let tmp = dir.join("session.json.tmp");
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn load(id: &str, base_dir: &Path) -> std::io::Result<Self> {
let path = base_dir.join("sessions").join(id).join("session.json");
let data = std::fs::read_to_string(path)?;
let session: Session = serde_json::from_str(&data)?;
Ok(session)
}
pub fn list(base_dir: &Path) -> Vec<Self> {
let sessions_dir = base_dir.join("sessions");
let entries = match std::fs::read_dir(&sessions_dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| {
let id = e.file_name().to_string_lossy().to_string();
Session::load(&id, base_dir).ok()
})
.collect()
}
}
+43
View File
@@ -0,0 +1,43 @@
use std::path::{Path, PathBuf};
use std::fs;
pub struct SessionLock {
path: PathBuf,
pid: u32,
}
impl SessionLock {
pub fn new(session_dir: &Path) -> Self {
SessionLock {
path: session_dir.join(".lock"),
pid: std::process::id(),
}
}
pub fn try_lock(&self) -> std::io::Result<bool> {
if self.path.exists() {
let content = fs::read_to_string(&self.path).unwrap_or_default();
if let Ok(pid) = content.trim().parse::<u32>() {
if self.is_alive(pid) {
return Ok(false);
}
}
}
fs::write(&self.path, self.pid.to_string())?;
Ok(true)
}
pub fn unlock(&self) {
let _ = fs::remove_file(&self.path);
}
fn is_alive(&self, pid: u32) -> bool {
unsafe { libc::kill(pid as i32, 0) == 0 }
}
}
impl Drop for SessionLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
+78
View File
@@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Default)]
pub enum InternetMode {
#[default]
Off,
ReadOnly,
Full,
}
impl InternetMode {
pub fn can_fetch(&self) -> bool {
matches!(self, InternetMode::Full)
}
pub fn can_download(&self) -> bool {
matches!(self, InternetMode::Full)
}
pub fn can_search(&self) -> bool {
matches!(self, InternetMode::Full)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub internet_mode: InternetMode,
pub provider: String,
pub model: String,
pub api_key: Option<String>,
pub max_tokens: u32,
pub temperature: f32,
pub review_enabled: bool,
pub review_max_lessons_per_run: usize,
pub adaptive_review_max_skip: u32,
pub workflow_max_concurrency: usize,
pub session_archive_enabled: bool,
}
impl Default for Settings {
fn default() -> Self {
Settings {
internet_mode: InternetMode::Off,
provider: "openrouter".to_string(),
model: "anthropic/claude-opus-4-8".to_string(),
api_key: None,
max_tokens: 8192,
temperature: 0.7,
review_enabled: true,
review_max_lessons_per_run: 5,
adaptive_review_max_skip: 3,
workflow_max_concurrency: 5,
session_archive_enabled: true,
}
}
}
impl Settings {
pub fn load() -> Self {
let store = super::store::Store::new();
let path = store.base_dir.join("settings.json");
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self) -> std::io::Result<()> {
let store = super::store::Store::new();
std::fs::create_dir_all(&store.base_dir)?;
let path = store.base_dir.join("settings.json");
let s = serde_json::to_string_pretty(self)?;
std::fs::write(path, s)?;
Ok(())
}
}
+36
View File
@@ -0,0 +1,36 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Store {
pub base_dir: PathBuf,
pub scratch_root: PathBuf,
pub memory_dir: PathBuf,
pub session_images_dir: PathBuf,
pub download_dir: PathBuf,
}
impl Store {
pub fn new() -> Self {
let base = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from(".local/share"))
.join("zesdex");
let scratch = std::env::temp_dir().join("zesdex-scratch");
Store {
memory_dir: base.join("memory"),
scratch_root: scratch,
session_images_dir: base.join("session-images"),
download_dir: base.join("downloads"),
base_dir: base,
}
}
pub fn ensure_dirs(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.base_dir)?;
std::fs::create_dir_all(&self.memory_dir)?;
std::fs::create_dir_all(&self.scratch_root)?;
std::fs::create_dir_all(&self.session_images_dir)?;
std::fs::create_dir_all(&self.download_dir)?;
Ok(())
}
}