From f88114f5026f69dec44dd5c2d0f0dd584f1b91f5 Mon Sep 17 00:00:00 2001 From: weby-homelab Date: Mon, 29 Jun 2026 15:15:34 +0300 Subject: [PATCH 1/5] feat: enhance OpenCode monitoring with subagent tree, dynamic db discovery, and git branch tracking --- src/collector/opencode.rs | 121 +++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 3 deletions(-) diff --git a/src/collector/opencode.rs b/src/collector/opencode.rs index 608e3b6..2b0e6f8 100644 --- a/src/collector/opencode.rs +++ b/src/collector/opencode.rs @@ -26,6 +26,8 @@ pub struct OpenCodeCollector { sqlite3_available: Option, /// Cached DB rows from the last slow-tick query. Reused on fast ticks. cached_db_sessions: Vec, + /// Cached DB subagent rows from the last slow-tick query. + cached_db_subagents: Vec, /// Whether the "sqlite3 missing" warning has been emitted (once). #[cfg(target_os = "windows")] warned_sqlite3_missing: bool, @@ -36,13 +38,14 @@ impl OpenCodeCollector { let data_dir = std::env::var("XDG_DATA_HOME") .map(PathBuf::from) .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default().join(".local/share")); - let db_path = data_dir.join("opencode").join("opencode.db"); + let db_path = resolve_db_path(&data_dir); #[cfg(target_os = "windows")] let db_path = windows_db_path(db_path); Self { db_path, sqlite3_available: None, cached_db_sessions: Vec::new(), + cached_db_subagents: Vec::new(), #[cfg(target_os = "windows")] warned_sqlite3_missing: false, } @@ -58,9 +61,21 @@ impl OpenCodeCollector { } fn collect_sessions(&mut self, shared: &super::SharedProcessData) -> Vec { + // If the database path doesn't exist, try resolving it again in case it was created since startup + if !self.db_path.exists() { + let data_dir = std::env::var("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default().join(".local/share")); + let db_path = resolve_db_path(&data_dir); + #[cfg(target_os = "windows")] + let db_path = windows_db_path(db_path); + self.db_path = db_path; + } + // Security: skip if db_path is a symlink (fail-closed) if is_symlink(&self.db_path) || !self.db_path.exists() { self.cached_db_sessions.clear(); + self.cached_db_subagents.clear(); return vec![]; } if !self.check_sqlite3() { @@ -78,6 +93,7 @@ impl OpenCodeCollector { ); } self.cached_db_sessions.clear(); + self.cached_db_subagents.clear(); return vec![]; } @@ -99,6 +115,9 @@ impl OpenCodeCollector { if let Some(rows) = self.query_sessions() { self.cached_db_sessions = rows; } + if let Some(sub_rows) = self.query_subagents() { + self.cached_db_subagents = sub_rows; + } } let now_ms = current_time_ms(); @@ -212,14 +231,35 @@ impl OpenCodeCollector { current_tasks, mem_mb, version: ds.version.clone(), - git_branch: String::new(), + git_branch: get_git_branch(&ds.directory), git_added: 0, git_modified: 0, token_history: vec![], context_history: vec![], compaction_count: 0, context_window, - subagents: vec![], + subagents: { + let mut subagents = Vec::new(); + for sub in &self.cached_db_subagents { + if sub.parent_id == ds.id { + let age_ms = now_ms.saturating_sub(sub.time_updated); + let since_update_secs = age_ms / 1000; + let status = if since_update_secs < 30 { + "working".to_string() + } else { + "done".to_string() + }; + let mut name = sub.title.clone(); + truncate_field(&mut name, 30); + subagents.push(crate::model::SubAgent { + name, + status, + tokens: sub.tokens, + }); + } + } + subagents + }, mem_file_count: 0, mem_line_count: 0, children, @@ -405,6 +445,37 @@ LIMIT {};"#, Some(sessions) } + + fn query_subagents(&self) -> Option> { + let sql = format!( + r#" +SELECT + id, parent_id, title, + (tokens_input + tokens_output + tokens_cache_read + tokens_cache_write) as tokens, + time_updated +FROM session +WHERE parent_id IS NOT NULL AND parent_id != '' +LIMIT {};"#, + MAX_SESSIONS + ); + let rows = self.run_query(&sql)?; + let mut subagents = Vec::new(); + for row in rows { + let id = row["id"].as_str().unwrap_or("").to_string(); + let parent_id = row["parent_id"].as_str().unwrap_or("").to_string(); + let title = sanitize_db_title(row["title"].as_str().unwrap_or("")); + let tokens = row["tokens"].as_u64().unwrap_or(0); + let time_updated = row["time_updated"].as_u64().unwrap_or(0); + subagents.push(DbSubAgent { + id, + parent_id, + title, + tokens, + time_updated, + }); + } + Some(subagents) + } } impl Default for OpenCodeCollector { @@ -419,6 +490,50 @@ impl super::AgentCollector for OpenCodeCollector { } } +struct DbSubAgent { + id: String, + parent_id: String, + title: String, + tokens: u64, + time_updated: u64, +} + +fn resolve_db_path(data_dir: &Path) -> PathBuf { + let base_dir = data_dir.join("opencode"); + let default = base_dir.join("opencode.db"); + if default.exists() { + return default; + } + if let Ok(entries) = fs::read_dir(&base_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "db") { + return path; + } + } + } + default +} + +fn get_git_branch(cwd: &str) -> String { + let git_dir = Path::new(cwd).join(".git"); + if !git_dir.exists() { + return String::new(); + } + let head_file = git_dir.join("HEAD"); + if let Ok(content) = fs::read_to_string(head_file) { + let content = content.trim(); + if let Some(ref_path) = content.strip_prefix("ref: ") { + if let Some(branch_name) = ref_path.rsplit('/').next() { + return branch_name.to_string(); + } + } else if content.len() >= 7 { + return content[..7].to_string(); + } + } + String::new() +} + struct DbSession { id: String, title: String, From a7ec38254cee2aa62244367b32e8a0a127f9b003 Mon Sep 17 00:00:00 2001 From: weby-homelab Date: Tue, 30 Jun 2026 18:45:22 +0300 Subject: [PATCH 2/5] feat: enhance OpenCode collector with robust status, mtime polling, subagent tree, and chat/tool logs --- src/collector/opencode.rs | 243 +++++++++++++++++++++++++++++++++----- 1 file changed, 214 insertions(+), 29 deletions(-) diff --git a/src/collector/opencode.rs b/src/collector/opencode.rs index 2b0e6f8..ed0da10 100644 --- a/src/collector/opencode.rs +++ b/src/collector/opencode.rs @@ -1,5 +1,5 @@ use super::{process, context_window_for_model}; -use crate::model::{AgentSession, ChildProcess, SessionStatus}; +use crate::model::{AgentSession, ChildProcess, SessionStatus, ChatMessage, ToolCall, ChatRole}; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::fs; @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; /// Maximum sessions to fetch from the DB per query. -const MAX_SESSIONS: u32 = 20; +const MAX_SESSIONS: u32 = 50; /// Collector for OpenCode sessions. /// @@ -18,16 +18,23 @@ const MAX_SESSIONS: u32 = 20; /// /// Uses `sqlite3 -readonly -json` for safe concurrent reads (WAL mode). /// DB rows are cached and only refreshed on `shared.slow_tick` (every ~10s) -/// so we don't fork a sqlite3 process every 2s. PID matching, status -/// derivation and the children walk run every tick using live process info. +/// OR when the database file/WAL modification time changes, enabling instant updates. pub struct OpenCodeCollector { db_path: PathBuf, /// Whether sqlite3 CLI is available (checked once). sqlite3_available: Option, - /// Cached DB rows from the last slow-tick query. Reused on fast ticks. + /// Cached DB rows from the last slow-tick or mtime-change query. Reused on fast ticks. cached_db_sessions: Vec, /// Cached DB subagent rows from the last slow-tick query. cached_db_subagents: Vec, + /// Cached chat messages by session ID. + cached_chat_messages: HashMap>, + /// Cached tool calls by session ID. + cached_tool_calls: HashMap>, + /// Last modification time of the DB file. + last_db_mtime: Option, + /// Last modification time of the WAL file. + last_wal_mtime: Option, /// Whether the "sqlite3 missing" warning has been emitted (once). #[cfg(target_os = "windows")] warned_sqlite3_missing: bool, @@ -46,6 +53,10 @@ impl OpenCodeCollector { sqlite3_available: None, cached_db_sessions: Vec::new(), cached_db_subagents: Vec::new(), + cached_chat_messages: HashMap::new(), + cached_tool_calls: HashMap::new(), + last_db_mtime: None, + last_wal_mtime: None, #[cfg(target_os = "windows")] warned_sqlite3_missing: false, } @@ -76,6 +87,8 @@ impl OpenCodeCollector { if is_symlink(&self.db_path) || !self.db_path.exists() { self.cached_db_sessions.clear(); self.cached_db_subagents.clear(); + self.cached_chat_messages.clear(); + self.cached_tool_calls.clear(); return vec![]; } if !self.check_sqlite3() { @@ -94,6 +107,8 @@ impl OpenCodeCollector { } self.cached_db_sessions.clear(); self.cached_db_subagents.clear(); + self.cached_chat_messages.clear(); + self.cached_tool_calls.clear(); return vec![]; } @@ -109,15 +124,44 @@ impl OpenCodeCollector { }) .collect(); - // Refresh DB rows on slow ticks only; reuse cache on fast ticks so - // we don't fork sqlite3 every 2s. - if shared.slow_tick { + // Check if DB or WAL modification times changed + let mut db_changed = false; + if let Ok(meta) = std::fs::metadata(&self.db_path) { + if let Ok(mtime) = meta.modified() { + if self.last_db_mtime.map_or(true, |last| mtime > last) { + self.last_db_mtime = Some(mtime); + db_changed = true; + } + } + } + let wal_path = self.db_path.with_extension("db-wal"); + if let Ok(meta) = std::fs::metadata(&wal_path) { + if let Ok(mtime) = meta.modified() { + if self.last_wal_mtime.map_or(true, |last| mtime > last) { + self.last_wal_mtime = Some(mtime); + db_changed = true; + } + } + } + + // Refresh DB rows on slow ticks or when the database actually changed + if shared.slow_tick || db_changed { if let Some(rows) = self.query_sessions() { self.cached_db_sessions = rows; } if let Some(sub_rows) = self.query_subagents() { self.cached_db_subagents = sub_rows; } + + let sids: Vec = self.cached_db_sessions.iter().map(|s| s.id.clone()).collect(); + if let Some(parts_map) = self.query_parts(&sids) { + self.cached_chat_messages.clear(); + self.cached_tool_calls.clear(); + for (sid, (chat, tools)) in parts_map { + self.cached_chat_messages.insert(sid.clone(), chat); + self.cached_tool_calls.insert(sid, tools); + } + } } let now_ms = current_time_ms(); @@ -137,17 +181,21 @@ impl OpenCodeCollector { let proc = shared.process_info.get(&matched_pid); let mem_mb = proc.map(|p| p.rss_kb / 1024).unwrap_or(0); - let age_ms = now_ms.saturating_sub(ds.time_updated); - let since_update_secs = age_ms / 1000; - let status = if since_update_secs < 30 { + // Precise status derivation from database message history: + // 1. If last message role is "user", model is thinking. + // 2. If last message role is "assistant" but completed time is not set, model is thinking. + // 3. Fallback to CPU usage check if a tool is executing but DB hasn't been committed yet. + let status = if ds.last_role == "user" { + SessionStatus::Thinking + } else if ds.last_role == "assistant" && ds.last_completed.is_none() { SessionStatus::Thinking } else { - let cpu_active = proc.is_some_and(|p| p.cpu_pct > 1.0); + let cpu_active = proc.is_some_and(|p| p.cpu_pct > 5.0); let has_active_child = process::has_active_descendant( matched_pid, &shared.children_map, &shared.process_info, - 5.0, + 10.0, ); if cpu_active || has_active_child { SessionStatus::Thinking @@ -212,6 +260,9 @@ impl OpenCodeCollector { 0.0 }; + let chat_messages = self.cached_chat_messages.get(&ds.id).cloned().unwrap_or_default(); + let tool_calls = self.cached_tool_calls.get(&ds.id).cloned().unwrap_or_default(); + sessions.push(AgentSession { agent_cli: "opencode", pid: matched_pid, @@ -264,9 +315,14 @@ impl OpenCodeCollector { mem_line_count: 0, children, initial_prompt: ds.title.clone(), - first_assistant_text: String::new(), - chat_messages: vec![], - tool_calls: vec![], + first_assistant_text: { + chat_messages.iter() + .find(|m| m.role == ChatRole::Assistant) + .map(|m| m.text.clone()) + .unwrap_or_default() + }, + chat_messages, + tool_calls, pending_since_ms: 0, thinking_since_ms: 0, file_accesses: vec![], @@ -280,22 +336,38 @@ impl OpenCodeCollector { sessions } + /// Find running opencode processes, excluding subagent processes + /// (descendants that are also binaries of name "opencode"). fn find_opencode_pids(process_info: &HashMap) -> Vec { - process_info - .iter() - .filter(|(_, info)| { - process::cmd_has_binary(&info.command, "opencode") && !info.command.contains("grep") - }) - .map(|(pid, _)| *pid) - .collect() + let mut pids = Vec::new(); + for (&pid, info) in process_info { + if process::cmd_has_binary(&info.command, "opencode") && !info.command.contains("grep") { + // Traverse ancestor chain to verify it is the root opencode process + let mut is_subagent = false; + let mut curr_ppid = info.ppid; + while curr_ppid > 1 { + if let Some(parent_info) = process_info.get(&curr_ppid) { + if process::cmd_has_binary(&parent_info.command, "opencode") { + is_subagent = true; + break; + } + curr_ppid = parent_info.ppid; + } else { + break; + } + } + if !is_subagent { + pids.push(pid); + } + } + } + pids } /// Match a running PID to a session by comparing its working directory /// with the DB session's `directory`, falling back to a command-line /// substring match. Returns `None` if no PID's cwd or command line ties - /// to this session — we deliberately do not fall back to "the only - /// opencode process" here, because that would mark every DB row as - /// alive whenever a single opencode is running in an unrelated dir. + /// to this session. #[cfg(test)] fn match_pid_to_session(pid_commands: &HashMap, session_dir: &str) -> Option { Self::match_pid_to_session_excluding(pid_commands, session_dir, &HashSet::new()) @@ -388,7 +460,13 @@ SELECT COALESCE((SELECT json_extract(m2.data, '$.providerID') FROM message m2 WHERE m2.session_id = s.id AND json_extract(m2.data, '$.role') = 'assistant' - ORDER BY m2.time_created DESC LIMIT 1), '') as provider + ORDER BY m2.time_created DESC LIMIT 1), '') as provider, + COALESCE((SELECT json_extract(m2.data, '$.role') + FROM message m2 WHERE m2.session_id = s.id + ORDER BY m2.time_created DESC LIMIT 1), '') as last_role, + (SELECT json_extract(m2.data, '$.time.completed') + FROM message m2 WHERE m2.session_id = s.id + ORDER BY m2.time_created DESC LIMIT 1) as last_completed FROM session s ORDER BY s.time_updated DESC LIMIT {};"#, @@ -400,7 +478,7 @@ LIMIT {};"#, let model_rows = self.run_query(&model_sql).unwrap_or_default(); // Build model lookup by session id - let mut model_map: HashMap = HashMap::new(); + let mut model_map: HashMap)> = HashMap::new(); for mr in &model_rows { if let Some(id) = mr["id"].as_str() { model_map.insert( @@ -408,6 +486,8 @@ LIMIT {};"#, ( sanitize_db_field(mr["model"].as_str().unwrap_or(""), 256), sanitize_db_field(mr["provider"].as_str().unwrap_or(""), 256), + sanitize_db_field(mr["last_role"].as_str().unwrap_or(""), 64), + mr["last_completed"].as_u64(), ), ); } @@ -416,7 +496,9 @@ LIMIT {};"#, let mut sessions = Vec::new(); for row in rows { let id = row["id"].as_str().unwrap_or("").to_string(); - let (model, provider) = model_map.remove(&id).unwrap_or_default(); + let (model, provider, last_role, last_completed) = model_map + .remove(&id) + .unwrap_or_else(|| ("".to_string(), "".to_string(), "".to_string(), None)); // Sanitize DB-sourced strings before they reach the TUI/JSON snapshot. let title = sanitize_db_title(row["title"].as_str().unwrap_or("")); @@ -440,6 +522,8 @@ LIMIT {};"#, total_cache_write: row["total_cache_write"].as_u64().unwrap_or(0), model, provider, + last_role, + last_completed, }); } @@ -476,6 +560,92 @@ LIMIT {};"#, } Some(subagents) } + + /// Query dialogue text messages and tool calls for active sessions. + fn query_parts(&self, session_ids: &[String]) -> Option, Vec)>> { + if session_ids.is_empty() { + return Some(HashMap::new()); + } + let formatted_ids: Vec = session_ids.iter().map(|id| format!("'{}'", id)).collect(); + let sql = format!( + r#" +SELECT + p.session_id, + json_extract(m.data, '$.role') as role, + p.data as part_data +FROM part p +JOIN message m ON p.message_id = m.id +WHERE p.session_id IN ({}) +ORDER BY p.time_created ASC;"#, + formatted_ids.join(",") + ); + + let rows = self.run_query(&sql)?; + let mut map: HashMap, Vec)> = HashMap::new(); + for id in session_ids { + map.insert(id.clone(), (Vec::new(), Vec::new())); + } + + for row in rows { + let session_id = row["session_id"].as_str().unwrap_or("").to_string(); + let role_str = row["role"].as_str().unwrap_or(""); + let part_data_str = row["part_data"].as_str().unwrap_or(""); + + if let Some((chat, tools)) = map.get_mut(&session_id) { + if let Ok(obj) = serde_json::from_str::(part_data_str) { + let part_type = obj["type"].as_str().unwrap_or(""); + if part_type == "text" || part_type == "reasoning" { + if let Some(text) = obj["text"].as_str() { + if !text.trim().is_empty() { + let role = if role_str == "user" { + ChatRole::User + } else { + ChatRole::Assistant + }; + let redacted_text = super::redact_secrets(&super::sanitize_terminal_text(text)); + chat.push(ChatMessage { + role, + text: redacted_text, + }); + } + } + } else if part_type == "tool" { + let name = obj["tool"].as_str().unwrap_or("").to_string(); + if !name.is_empty() { + let mut arg = String::new(); + if let Some(input) = obj["state"]["input"].as_object() { + if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) { + arg = cmd.to_string(); + } else if let Some(path) = input.get("filePath").and_then(|v| v.as_str()) { + arg = path.to_string(); + } else if let Some(path) = input.get("path").and_then(|v| v.as_str()) { + arg = path.to_string(); + } else if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) { + arg = pattern.to_string(); + } else if let Some(desc) = input.get("description").and_then(|v| v.as_str()) { + arg = desc.to_string(); + } else if !input.is_empty() { + if let Some(first_val) = input.values().next().and_then(|v| v.as_str()) { + arg = first_val.to_string(); + } + } + } + let start = obj["state"]["time"]["start"].as_u64().unwrap_or(0); + let end = obj["state"]["time"]["end"].as_u64().unwrap_or(0); + let duration_ms = end.saturating_sub(start); + tools.push(ToolCall { + name, + arg, + duration_ms, + }); + } + } + } + } + } + + Some(map) + } } impl Default for OpenCodeCollector { @@ -549,6 +719,8 @@ struct DbSession { total_cache_write: u64, model: String, provider: String, + last_role: String, + last_completed: Option, } /// Check if a path is a symlink (fail-closed: returns true on error). @@ -710,10 +882,23 @@ mod tests { command: "node /usr/bin/opencode run test".to_string(), }, ); + // Add a subagent process of PID 100 (should be filtered out) + info.insert( + 400, + process::ProcInfo { + pid: 400, + ppid: 100, + rss_kb: 300, + cpu_pct: 0.0, + command: "/home/user/.opencode/bin/opencode subagent".to_string(), + }, + ); + let pids = OpenCodeCollector::find_opencode_pids(&info); assert!(pids.contains(&100)); assert!(!pids.contains(&200)); // grep excluded assert!(pids.contains(&300)); + assert!(!pids.contains(&400)); // subagent excluded (parent is opencode) assert_eq!(pids.len(), 2); } From f88bb5ae8167799ebb31d89adf116f1db75819d9 Mon Sep 17 00:00:00 2001 From: weby-homelab Date: Tue, 30 Jun 2026 18:46:25 +0300 Subject: [PATCH 3/5] style: remove unused struct fields to fix compiler warnings --- src/collector/opencode.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/collector/opencode.rs b/src/collector/opencode.rs index ed0da10..903f503 100644 --- a/src/collector/opencode.rs +++ b/src/collector/opencode.rs @@ -511,9 +511,8 @@ LIMIT {};"#, title, directory, version, - // time_created and time_updated are in milliseconds since epoch + // time_created is in milliseconds since epoch time_created: row["time_created"].as_u64().unwrap_or(0), - time_updated: row["time_updated"].as_u64().unwrap_or(0), project_name, turn_count: row["turn_count"].as_u64().unwrap_or(0) as u32, total_input: row["total_input"].as_u64().unwrap_or(0), @@ -545,13 +544,11 @@ LIMIT {};"#, let rows = self.run_query(&sql)?; let mut subagents = Vec::new(); for row in rows { - let id = row["id"].as_str().unwrap_or("").to_string(); let parent_id = row["parent_id"].as_str().unwrap_or("").to_string(); let title = sanitize_db_title(row["title"].as_str().unwrap_or("")); let tokens = row["tokens"].as_u64().unwrap_or(0); let time_updated = row["time_updated"].as_u64().unwrap_or(0); subagents.push(DbSubAgent { - id, parent_id, title, tokens, @@ -661,7 +658,6 @@ impl super::AgentCollector for OpenCodeCollector { } struct DbSubAgent { - id: String, parent_id: String, title: String, tokens: u64, @@ -710,7 +706,6 @@ struct DbSession { directory: String, version: String, time_created: u64, - time_updated: u64, project_name: String, turn_count: u32, total_input: u64, From c190e9680b961d618f200f47e9894f06f3fcf4f4 Mon Sep 17 00:00:00 2001 From: weby-homelab Date: Tue, 30 Jun 2026 18:56:12 +0300 Subject: [PATCH 4/5] fix: resolve context window size and calculate context percent on active tokens --- src/collector/opencode.rs | 114 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 6 deletions(-) diff --git a/src/collector/opencode.rs b/src/collector/opencode.rs index 903f503..b3ffaa9 100644 --- a/src/collector/opencode.rs +++ b/src/collector/opencode.rs @@ -253,9 +253,14 @@ impl OpenCodeCollector { "-".to_string() }; - let context_window = context_window_for_model(&model, "", 0); + // Fetch the context limit from the local configuration file (~/.config/opencode/opencode.jsonc) + // falling back to context_window_for_model if missing. + let context_window = get_context_window_from_config(&ds.provider, &ds.model) + .unwrap_or_else(|| context_window_for_model(&model, "", 0)); + + // Calculate context percent on active context size (latest turn's tokens.total), not cumulative sum. let context_percent = if context_window > 0 { - ((ds.total_input + ds.total_output) as f64 / context_window as f64) * 100.0 + (ds.last_total_tokens as f64 / context_window as f64) * 100.0 } else { 0.0 }; @@ -466,7 +471,12 @@ SELECT ORDER BY m2.time_created DESC LIMIT 1), '') as last_role, (SELECT json_extract(m2.data, '$.time.completed') FROM message m2 WHERE m2.session_id = s.id - ORDER BY m2.time_created DESC LIMIT 1) as last_completed + ORDER BY m2.time_created DESC LIMIT 1) as last_completed, + COALESCE((SELECT json_extract(m2.data, '$.tokens.total') + FROM message m2 WHERE m2.session_id = s.id + AND json_extract(m2.data, '$.role') = 'assistant' + AND json_extract(m2.data, '$.tokens.total') IS NOT NULL + ORDER BY m2.time_created DESC LIMIT 1), 0) as last_total_tokens FROM session s ORDER BY s.time_updated DESC LIMIT {};"#, @@ -478,7 +488,7 @@ LIMIT {};"#, let model_rows = self.run_query(&model_sql).unwrap_or_default(); // Build model lookup by session id - let mut model_map: HashMap)> = HashMap::new(); + let mut model_map: HashMap, u64)> = HashMap::new(); for mr in &model_rows { if let Some(id) = mr["id"].as_str() { model_map.insert( @@ -488,6 +498,7 @@ LIMIT {};"#, sanitize_db_field(mr["provider"].as_str().unwrap_or(""), 256), sanitize_db_field(mr["last_role"].as_str().unwrap_or(""), 64), mr["last_completed"].as_u64(), + mr["last_total_tokens"].as_u64().unwrap_or(0), ), ); } @@ -496,9 +507,9 @@ LIMIT {};"#, let mut sessions = Vec::new(); for row in rows { let id = row["id"].as_str().unwrap_or("").to_string(); - let (model, provider, last_role, last_completed) = model_map + let (model, provider, last_role, last_completed, last_total_tokens) = model_map .remove(&id) - .unwrap_or_else(|| ("".to_string(), "".to_string(), "".to_string(), None)); + .unwrap_or_else(|| ("".to_string(), "".to_string(), "".to_string(), None, 0)); // Sanitize DB-sourced strings before they reach the TUI/JSON snapshot. let title = sanitize_db_title(row["title"].as_str().unwrap_or("")); @@ -523,6 +534,7 @@ LIMIT {};"#, provider, last_role, last_completed, + last_total_tokens, }); } @@ -700,6 +712,95 @@ fn get_git_branch(cwd: &str) -> String { String::new() } +/// Strip jsonc single-line and multi-line comments. +fn strip_jsonc_comments(content: &str) -> String { + let mut clean = String::new(); + let mut in_block_comment = false; + let mut in_string = false; + let mut escaped = false; + let mut lines = content.lines().peekable(); + + while let Some(line) = lines.next() { + let mut line_clean = String::new(); + let mut chars = line.chars().peekable(); + + while let Some(c) = chars.next() { + if in_block_comment { + if c == '*' && chars.peek() == Some(&'/') { + chars.next(); + in_block_comment = false; + } + } else if in_string { + line_clean.push(c); + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_string = false; + } + } else if c == '"' { + in_string = true; + line_clean.push(c); + } else if c == '/' && chars.peek() == Some(&'*') { + chars.next(); + in_block_comment = true; + } else if c == '/' && chars.peek() == Some(&'/') { + break; + } else { + line_clean.push(c); + } + } + + if !in_block_comment { + clean.push_str(&line_clean); + clean.push('\n'); + } + } + clean +} + +/// Resolve context limit from opencode.jsonc config file. +fn get_context_window_from_config(provider: &str, model_id: &str) -> Option { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| { + dirs::home_dir() + .map(|h| h.join(".config")) + .unwrap_or_default() + }); + #[allow(unused_mut)] + let mut config_path = config_dir.join("opencode").join("opencode.jsonc"); + + #[cfg(target_os = "windows")] + if !config_path.exists() { + for var in ["LOCALAPPDATA", "APPDATA"] { + if let Ok(base) = std::env::var(var) { + if !base.is_empty() { + let candidate = PathBuf::from(base).join("opencode").join("opencode.jsonc"); + if candidate.exists() { + config_path = candidate; + break; + } + } + } + } + } + + if !config_path.exists() { + return None; + } + let content = fs::read_to_string(&config_path).ok()?; + let clean_json = strip_jsonc_comments(&content); + let val: serde_json::Value = serde_json::from_str(&clean_json).ok()?; + + if let Some(context_limit) = val["provider"][provider]["models"][model_id]["limit"]["context"].as_u64() { + return Some(context_limit); + } + + None +} + struct DbSession { id: String, title: String, @@ -716,6 +817,7 @@ struct DbSession { provider: String, last_role: String, last_completed: Option, + last_total_tokens: u64, } /// Check if a path is a symlink (fail-closed: returns true on error). From b488678d9b2864ce9d6ceabb7acf510d659fcad9 Mon Sep 17 00:00:00 2001 From: weby-homelab Date: Tue, 30 Jun 2026 19:02:54 +0300 Subject: [PATCH 5/5] fix: address cargo clippy lint warnings for CI --- src/collector/opencode.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/collector/opencode.rs b/src/collector/opencode.rs index b3ffaa9..fadb966 100644 --- a/src/collector/opencode.rs +++ b/src/collector/opencode.rs @@ -128,7 +128,7 @@ impl OpenCodeCollector { let mut db_changed = false; if let Ok(meta) = std::fs::metadata(&self.db_path) { if let Ok(mtime) = meta.modified() { - if self.last_db_mtime.map_or(true, |last| mtime > last) { + if self.last_db_mtime.is_none_or(|last| mtime > last) { self.last_db_mtime = Some(mtime); db_changed = true; } @@ -137,7 +137,7 @@ impl OpenCodeCollector { let wal_path = self.db_path.with_extension("db-wal"); if let Ok(meta) = std::fs::metadata(&wal_path) { if let Ok(mtime) = meta.modified() { - if self.last_wal_mtime.map_or(true, |last| mtime > last) { + if self.last_wal_mtime.is_none_or(|last| mtime > last) { self.last_wal_mtime = Some(mtime); db_changed = true; } @@ -185,9 +185,9 @@ impl OpenCodeCollector { // 1. If last message role is "user", model is thinking. // 2. If last message role is "assistant" but completed time is not set, model is thinking. // 3. Fallback to CPU usage check if a tool is executing but DB hasn't been committed yet. - let status = if ds.last_role == "user" { - SessionStatus::Thinking - } else if ds.last_role == "assistant" && ds.last_completed.is_none() { + let status = if ds.last_role == "user" + || (ds.last_role == "assistant" && ds.last_completed.is_none()) + { SessionStatus::Thinking } else { let cpu_active = proc.is_some_and(|p| p.cpu_pct > 5.0); @@ -571,6 +571,7 @@ LIMIT {};"#, } /// Query dialogue text messages and tool calls for active sessions. + #[allow(clippy::type_complexity)] fn query_parts(&self, session_ids: &[String]) -> Option, Vec)>> { if session_ids.is_empty() { return Some(HashMap::new()); @@ -685,7 +686,7 @@ fn resolve_db_path(data_dir: &Path) -> PathBuf { if let Ok(entries) = fs::read_dir(&base_dir) { for entry in entries.flatten() { let path = entry.path(); - if path.is_file() && path.extension().map_or(false, |ext| ext == "db") { + if path.is_file() && path.extension().is_some_and(|ext| ext == "db") { return path; } } @@ -718,9 +719,8 @@ fn strip_jsonc_comments(content: &str) -> String { let mut in_block_comment = false; let mut in_string = false; let mut escaped = false; - let mut lines = content.lines().peekable(); - while let Some(line) = lines.next() { + for line in content.lines() { let mut line_clean = String::new(); let mut chars = line.chars().peekable();