#[allow(unused_imports)]
use crate::sync_util::LockExt;
use crossterm::style::Color;
use crate::agent::tools::task::SubagentChatEvent;
#[cfg(feature = "plugin")]
use crate::plugin::PluginManager;
use crate::session::{self, MessageRole, Session, ToolCallEntry, ToolCallState};
#[cfg(feature = "plugin")]
use crate::ui::events::sanitize_output;
use crate::ui::panel_data;
use crate::ui::renderer::Renderer;
#[cfg(feature = "plugin")]
use crate::ui::theme;
#[cfg(feature = "plugin")]
use crate::ui::colors::parse_plugin_color;
pub(crate) fn apply_subagent_panel_event(
rows: &mut indexmap::IndexMap<String, (String, String, Vec<String>)>,
event: &SubagentChatEvent,
) {
use SubagentChatEvent as E;
match event {
E::Spawn { id, prompt } => {
let files = panel_data::extract_file_paths_from_prompt(prompt);
rows.insert(id.clone(), ("running".to_string(), prompt.clone(), files));
}
E::Complete { id, .. } | E::Failed { id, .. } | E::Aborted { id } => {
rows.shift_remove(id);
}
E::Token { .. } | E::Reasoning { .. } | E::ToolCall { .. } | E::ToolResult { .. } => {}
}
}
pub(crate) fn render_agent_stream(
buf: &str,
start_line: &mut Option<usize>,
base_color: Color,
renderer: &mut Renderer,
) -> anyhow::Result<()> {
if buf.is_empty() {
return Ok(());
}
if start_line.is_none() {
*start_line = Some(renderer.buffer_len());
}
renderer.stream(buf, base_color, true);
renderer.render_viewport()?;
Ok(())
}
pub(crate) const RENDER_FRAME: std::time::Duration = std::time::Duration::from_millis(16);
pub(crate) fn should_render_token(
pending: usize,
since_last_render: std::time::Duration,
frame: std::time::Duration,
) -> bool {
pending == 0 || since_last_render >= frame
}
pub(crate) fn capture_partial_on_abort(
response_buf: &mut String,
session: &mut Session,
why: &str,
tool_calls_in_turn: u32,
tool_calls_buf: &mut Vec<ToolCallEntry>,
) -> bool {
let trimmed = response_buf.trim_end();
if trimmed.is_empty() && tool_calls_buf.is_empty() {
response_buf.clear();
return false;
}
let trailer = if tool_calls_in_turn > 0 {
let noun = if tool_calls_in_turn == 1 {
"tool call ran"
} else {
"tool calls ran"
};
format!(
"[interrupted by user ({}); {} {} in this turn — results not preserved]",
why, tool_calls_in_turn, noun,
)
} else {
format!("[interrupted by user ({})]", why)
};
let stashed = if trimmed.is_empty() {
trailer
} else {
format!("{}\n\n{}", trimmed, trailer)
};
let calls = std::mem::take(tool_calls_buf);
let est = session::Session::estimate_tokens(&stashed);
session.add_message_with_tool_calls(MessageRole::Assistant, &stashed, calls);
session.total_tokens = session.total_tokens.saturating_add(est);
response_buf.clear();
true
}
pub(crate) fn persist_turn_to_db(
session: &Session,
user_prompt: &str,
assistant_text: &str,
tool_calls: &[ToolCallEntry],
) {
let cwd = std::env::current_dir().unwrap_or_else(|_| ".".into());
let paths = crate::extras::dirge_paths::ProjectPaths::new(&cwd);
let db = match crate::extras::session_db::SessionDb::open(&paths.session_db_path()) {
Ok(db) => db,
Err(e) => {
tracing::debug!(
target: "dirge::ui",
error = %e,
"Session DB unavailable — turn not persisted"
);
return;
}
};
let now = chrono::Utc::now().to_rfc3339();
let sid = format!("dirge-{}", crate::text::short_id(session.id.as_str()));
let _ = db.insert_session(&sid, "cli", &session.model, &session.provider, &now);
if !user_prompt.is_empty() {
let _ = db.insert_message(&sid, "user", user_prompt, None, None, None, &now);
}
if !assistant_text.is_empty() {
let tool_names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
let tool_name_str = if tool_names.is_empty() {
None
} else {
Some(tool_names.join(" "))
};
let tool_calls_str = if tool_calls.is_empty() {
None
} else {
serde_json::to_string(tool_calls).ok()
};
let _ = db.insert_message(
&sid,
"assistant",
assistant_text,
tool_name_str.as_deref(),
tool_calls_str.as_deref(),
None,
&now,
);
}
for tc in tool_calls {
let result_text = match &tc.state {
ToolCallState::Completed { result } => result.clone(),
ToolCallState::Interrupted => "[interrupted]".to_string(),
ToolCallState::Failed { error } => format!("[failed: {}]", error),
};
let _ = db.insert_message(
&sid,
"tool",
&result_text,
Some(&tc.name),
None,
Some(&tc.id),
&now,
);
}
}
#[cfg(feature = "plugin")]
pub(crate) fn render_plugin_entry(
pm_arc: &std::sync::Arc<std::sync::Mutex<PluginManager>>,
renderer: &mut Renderer,
entry: &crate::session::PluginEntry,
) -> std::io::Result<()> {
let handler_name = {
let mut mgr = pm_arc.lock_ignore_poison();
mgr.list_renderers()
.into_iter()
.find(|(t, _)| t == &entry.custom_type)
.map(|(_, h)| h)
};
if let Some(handler) = handler_name {
let lines = {
let mut mgr = pm_arc.lock_ignore_poison();
mgr.invoke_renderer(&handler, &entry.data)
.unwrap_or_default()
};
if !lines.is_empty() {
for (color_name, text) in lines {
let color = parse_plugin_color(&color_name);
renderer.write_line(&sanitize_output(&text), color)?;
}
return Ok(());
}
}
renderer.write_line(&format!("[entry: {}]", entry.custom_type), theme::dim())?;
if !entry.data.is_empty() {
renderer.write_line(&format!(" {}", sanitize_output(&entry.data)), theme::dim())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn renders_when_caught_up_even_within_frame() {
assert!(should_render_token(0, Duration::ZERO, RENDER_FRAME));
assert!(should_render_token(
0,
Duration::from_millis(1),
RENDER_FRAME
));
}
#[test]
fn coalesces_mid_burst_within_frame() {
assert!(!should_render_token(
5,
Duration::from_millis(4),
RENDER_FRAME
));
assert!(!should_render_token(1, Duration::ZERO, RENDER_FRAME));
}
#[test]
fn renders_mid_burst_after_frame_elapses() {
assert!(should_render_token(
5,
Duration::from_millis(20),
RENDER_FRAME
));
}
#[test]
fn renders_at_exact_frame_boundary() {
assert!(should_render_token(3, RENDER_FRAME, RENDER_FRAME));
}
}