mod chat_view;
mod message_render;
mod panel_body;
mod snapshot_view;
pub mod types;
pub use types::{InspectorAction, ViewMode};
use egui::{Color32, Context, CursorIcon, Id, Key, Order, Pos2, Stroke};
use crate::ai_inspector::chat::ChatState;
use crate::ai_inspector::snapshot::{SnapshotData, SnapshotScope};
use crate::config::Config;
use crate::ui_constants::{AI_PANEL_MAX_WIDTH_RATIO, AI_PANEL_MIN_WIDTH};
use par_term_acp::{AgentConfig, AgentStatus};
use par_term_config::AssistantInputHistoryMode;
use std::path::Path;
use types::RESIZE_HANDLE_WIDTH;
type AssistantPromptState = (Vec<par_term_config::AssistantPrompt>, Option<String>);
type AssistantPromptLoader = fn() -> AssistantPromptState;
pub struct AIInspectorPanel {
pub open: bool,
pub width: f32,
min_width: f32,
max_width_ratio: f32,
resizing: bool,
pub scope: SnapshotScope,
pub view_mode: ViewMode,
pub live_update: bool,
pub show_zones: bool,
pub snapshot: Option<SnapshotData>,
pub needs_refresh: bool,
pub last_command_count: usize,
pub agent_status: AgentStatus,
pub chat: ChatState,
pub assistant_prompts: Vec<par_term_config::AssistantPrompt>,
pub assistant_prompts_error: Option<String>,
prompt_library_loader: AssistantPromptLoader,
pub agent_terminal_access: bool,
pub auto_approve: bool,
rendered_width: f32,
hover_resize_handle: bool,
max_width: f32,
selected_agent_index: usize,
pub connected_agent_name: Option<String>,
pub connected_agent_identity: Option<String>,
pub connected_agent_project_root: Option<String>,
pub connected_agent_cwd: Option<String>,
pub chat_font_size: f32,
chat_input_id: Option<Id>,
}
impl AIInspectorPanel {
pub fn new(config: &Config) -> Self {
let (assistant_prompts, assistant_prompts_error) = Self::load_assistant_prompts();
Self::new_with_prompt_library_state(
config,
assistant_prompts,
assistant_prompts_error,
Self::load_assistant_prompts,
)
}
#[cfg(test)]
pub(super) fn new_for_tests(config: &Config) -> Self {
Self::new_for_tests_with_prompt_library(config, Vec::new(), None)
}
#[cfg(test)]
pub(super) fn new_for_tests_with_prompt_library(
config: &Config,
assistant_prompts: Vec<par_term_config::AssistantPrompt>,
assistant_prompts_error: Option<String>,
) -> Self {
Self::new_for_tests_with_prompt_refresh(
config,
assistant_prompts,
assistant_prompts_error,
Self::empty_assistant_prompts,
)
}
#[cfg(test)]
pub(super) fn new_for_tests_with_prompt_refresh(
config: &Config,
assistant_prompts: Vec<par_term_config::AssistantPrompt>,
assistant_prompts_error: Option<String>,
prompt_library_loader: AssistantPromptLoader,
) -> Self {
Self::new_with_prompt_library_state(
config,
assistant_prompts,
assistant_prompts_error,
prompt_library_loader,
)
}
fn new_with_prompt_library_state(
config: &Config,
assistant_prompts: Vec<par_term_config::AssistantPrompt>,
assistant_prompts_error: Option<String>,
prompt_library_loader: AssistantPromptLoader,
) -> Self {
let chat = ChatState::new();
let mut panel = Self {
open: config.ai_inspector.ai_inspector_open_on_startup,
width: config.ai_inspector.ai_inspector_width,
min_width: AI_PANEL_MIN_WIDTH,
max_width_ratio: AI_PANEL_MAX_WIDTH_RATIO,
resizing: false,
scope: SnapshotScope::from_config_str(&config.ai_inspector.ai_inspector_default_scope),
view_mode: ViewMode::from_config_str(&config.ai_inspector.ai_inspector_view_mode),
live_update: config.ai_inspector.ai_inspector_live_update,
show_zones: config.ai_inspector.ai_inspector_show_zones,
snapshot: None,
needs_refresh: true,
last_command_count: 0,
agent_status: AgentStatus::Disconnected,
chat,
assistant_prompts,
assistant_prompts_error,
prompt_library_loader,
agent_terminal_access: config.ai_inspector.ai_inspector_agent_terminal_access,
auto_approve: config.ai_inspector.ai_inspector_auto_approve,
rendered_width: 0.0,
hover_resize_handle: false,
max_width: 0.0,
selected_agent_index: 0,
connected_agent_name: None,
connected_agent_identity: None,
connected_agent_project_root: None,
connected_agent_cwd: None,
chat_font_size: config.ai_inspector.ai_inspector_chat_font_size,
chat_input_id: None,
};
if config.ai_inspector.ai_inspector_input_history_mode == AssistantInputHistoryMode::Persist
{
panel.merge_persisted_input_history();
}
panel
}
pub(crate) fn merge_persisted_input_history(&mut self) {
match par_term_config::load_assistant_input_history() {
Ok(persisted_entries) => {
let merged_entries = par_term_config::merge_assistant_input_history(
self.chat.input_history_entries(),
&persisted_entries,
);
self.chat.set_input_history(merged_entries);
}
Err(error) => log::warn!("Assistant input history: failed to load: {error}"),
}
}
fn load_assistant_prompts() -> AssistantPromptState {
match par_term_config::list_prompts() {
Ok(prompts) => (prompts, None),
Err(error) => (Vec::new(), Some(error)),
}
}
#[cfg(test)]
fn empty_assistant_prompts() -> AssistantPromptState {
(Vec::new(), None)
}
pub(crate) fn refresh_assistant_prompts(&mut self) {
let (assistant_prompts, assistant_prompts_error) = (self.prompt_library_loader)();
self.assistant_prompts = assistant_prompts;
self.assistant_prompts_error = assistant_prompts_error;
}
pub fn toggle(&mut self) -> bool {
self.open = !self.open;
if self.open {
self.needs_refresh = true;
self.refresh_assistant_prompts();
}
self.open
}
pub fn consumed_width(&self) -> f32 {
if self.open {
let raw_width = self.rendered_width.max(self.width);
if self.max_width > 0.0 {
raw_width.min(self.max_width)
} else {
raw_width
}
} else {
0.0
}
}
pub(super) fn agent_project_label(&self) -> Option<String> {
let root = self.connected_agent_project_root.as_deref()?;
let name = Path::new(root)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or(root);
Some(format!("Project: {name}"))
}
pub fn is_resizing(&self) -> bool {
self.resizing
}
pub fn wants_pointer(&self) -> bool {
self.resizing || self.hover_resize_handle
}
pub fn show(&mut self, ctx: &Context, available_agents: &[AgentConfig]) -> InspectorAction {
if !self.open {
return InspectorAction::None;
}
let chat_input_focused = self
.chat_input_id
.is_some_and(|id| ctx.memory(|m| m.has_focus(id)));
if ctx.input(|i| i.key_pressed(Key::Escape)) && !chat_input_focused {
self.open = false;
return InspectorAction::Close;
}
let viewport = ctx.input(|i| i.viewport_rect());
let max_width = viewport.width() * self.max_width_ratio;
self.max_width = max_width;
self.width = self.width.clamp(self.min_width, max_width);
let prev_panel_x = viewport.max.x - self.consumed_width();
let handle_left = prev_panel_x - RESIZE_HANDLE_WIDTH / 2.0;
let handle_right = prev_panel_x + RESIZE_HANDLE_WIDTH / 2.0;
let pointer_pos = ctx.input(|i| i.pointer.hover_pos());
let hover = pointer_pos.is_some_and(|pos| {
pos.x >= handle_left
&& pos.x <= handle_right
&& pos.y >= viewport.min.y
&& pos.y <= viewport.max.y
});
let primary_pressed = ctx.input(|i| i.pointer.primary_pressed());
let primary_down = ctx.input(|i| i.pointer.primary_down());
let delta = ctx.input(|i| i.pointer.delta());
if hover && primary_pressed {
self.resizing = true;
}
if self.resizing {
if primary_down {
let old_width = self.width;
self.width = (self.width - delta.x).clamp(self.min_width, max_width);
let clamped_delta = self.width - old_width;
self.rendered_width = (self.rendered_width + clamped_delta).max(self.width);
} else {
self.resizing = false;
}
}
self.hover_resize_handle = hover;
if hover || self.resizing {
ctx.set_cursor_icon(CursorIcon::ResizeHorizontal);
}
let panel_x = viewport.max.x - self.consumed_width();
let area_response = egui::Area::new(Id::new("ai_inspector_panel"))
.fixed_pos(Pos2::new(panel_x, viewport.min.y))
.order(Order::Middle)
.interactable(true)
.show(ctx, |ui| {
let (action, close_requested) = self.render_panel_body(ui, available_agents);
if close_requested {
InspectorAction::Close
} else {
action
}
});
if !self.resizing {
self.rendered_width = area_response.response.rect.width();
}
let line_color = if hover || self.resizing {
Color32::from_gray(120)
} else {
Color32::from_gray(60)
};
let painter = ctx.layer_painter(egui::LayerId::new(
Order::Background,
Id::new("ai_inspector_resize_line"),
));
painter.line_segment(
[
Pos2::new(panel_x, viewport.min.y),
Pos2::new(panel_x, viewport.max.y),
],
Stroke::new(2.0, line_color),
);
let action = area_response.inner;
if matches!(action, InspectorAction::Close) {
self.open = false;
}
action
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai_inspector::panel_helpers::{format_duration, truncate_chars, truncate_output};
use crate::ai_inspector::snapshot::SnapshotScope;
#[test]
fn test_view_mode_label() {
assert_eq!(ViewMode::Cards.label(), "Cards");
assert_eq!(ViewMode::Timeline.label(), "Timeline");
assert_eq!(ViewMode::Tree.label(), "Tree");
assert_eq!(ViewMode::ListDetail.label(), "List Detail");
}
#[test]
fn test_view_mode_all() {
let all = ViewMode::all();
assert_eq!(all.len(), 4);
}
#[test]
fn test_view_mode_from_config_str() {
assert_eq!(ViewMode::from_config_str("cards"), ViewMode::Cards);
assert_eq!(ViewMode::from_config_str("timeline"), ViewMode::Timeline);
assert_eq!(ViewMode::from_config_str("tree"), ViewMode::Tree);
assert_eq!(
ViewMode::from_config_str("list_detail"),
ViewMode::ListDetail
);
assert_eq!(ViewMode::from_config_str("unknown"), ViewMode::Cards);
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(0), "0ms");
assert_eq!(format_duration(500), "500ms");
assert_eq!(format_duration(1000), "1.0s");
assert_eq!(format_duration(1500), "1.5s");
assert_eq!(format_duration(60000), "1m 0s");
assert_eq!(format_duration(90000), "1m 30s");
}
#[test]
fn test_truncate_output() {
let short = "line1\nline2\nline3";
assert_eq!(truncate_output(short, 5), short);
let long = (0..30)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let truncated = truncate_output(&long, 5);
assert!(truncated.ends_with("... (truncated)"));
assert_eq!(truncated.lines().count(), 6);
}
#[test]
fn test_truncate_chars_ascii() {
assert_eq!(truncate_chars("hello", 10), "hello");
assert_eq!(truncate_chars("hello world", 5), "hello");
assert_eq!(truncate_chars("", 5), "");
}
#[test]
fn test_truncate_chars_multibyte() {
let emoji = "🚀🎉🌍";
let result = truncate_chars(emoji, 1); assert_eq!(result, "🚀");
let result = truncate_chars(emoji, 2);
assert_eq!(result, "🚀🎉");
let result = truncate_chars(emoji, 3);
assert_eq!(result, "🚀🎉🌍");
let result = truncate_chars(emoji, 10);
assert_eq!(result, "🚀🎉🌍");
}
#[test]
fn test_truncate_chars_cjk() {
let cjk = "你好世界";
let result = truncate_chars(cjk, 1);
assert_eq!(result, "你");
let result = truncate_chars(cjk, 2);
assert_eq!(result, "你好");
let result = truncate_chars(cjk, 4);
assert_eq!(result, "你好世界");
let result = truncate_chars(cjk, 10);
assert_eq!(result, "你好世界");
}
#[test]
fn test_inspector_panel_toggle() {
let config = Config::default();
let mut panel = AIInspectorPanel::new_for_tests(&config);
assert!(!panel.open);
assert_eq!(panel.consumed_width(), 0.0);
let opened = panel.toggle();
assert!(opened);
assert!(panel.open);
assert!(panel.needs_refresh);
assert!(panel.consumed_width() > 0.0);
let opened = panel.toggle();
assert!(!opened);
assert!(!panel.open);
assert_eq!(panel.consumed_width(), 0.0);
}
#[test]
fn test_inspector_panel_new_from_config() {
let config = Config::default();
let panel = AIInspectorPanel::new_for_tests(&config);
assert!(!panel.open);
assert_eq!(panel.width, 300.0);
assert_eq!(panel.scope, SnapshotScope::Visible);
assert_eq!(panel.view_mode, ViewMode::Tree);
assert!(!panel.live_update);
assert!(panel.show_zones);
assert_eq!(panel.connected_agent_project_root, None);
assert_eq!(panel.connected_agent_cwd, None);
}
#[test]
fn inspector_panel_prompt_library_state_can_be_injected() {
let config = Config::default();
let prompt = par_term_config::AssistantPrompt {
path: std::path::PathBuf::from("debug.md"),
title: "Debug build".to_string(),
auto_submit: false,
prompt: "Fix the build.".to_string(),
};
let panel = AIInspectorPanel::new_for_tests_with_prompt_library(
&config,
vec![prompt.clone()],
Some("load failed".to_string()),
);
assert_eq!(panel.assistant_prompts, vec![prompt]);
assert_eq!(
panel.assistant_prompts_error.as_deref(),
Some("load failed")
);
}
#[test]
fn inspector_panel_toggle_uses_injected_prompt_refresh_for_tests() {
fn refreshed_prompts() -> AssistantPromptState {
(
vec![par_term_config::AssistantPrompt {
path: std::path::PathBuf::from("refreshed.md"),
title: "Refreshed".to_string(),
auto_submit: false,
prompt: "Use refreshed prompt.".to_string(),
}],
None,
)
}
let config = Config::default();
let mut panel = AIInspectorPanel::new_for_tests_with_prompt_refresh(
&config,
Vec::new(),
Some("initial error".to_string()),
refreshed_prompts,
);
let opened = panel.toggle();
assert!(opened);
assert_eq!(panel.assistant_prompts, refreshed_prompts().0);
assert_eq!(panel.assistant_prompts_error, None);
}
#[test]
fn load_prompt_action_carries_prompt_body() {
let action = InspectorAction::LoadPrompt("hello".to_string());
assert!(matches!(action, InspectorAction::LoadPrompt(text) if text == "hello"));
}
#[test]
fn prompt_selection_loads_non_auto_submit_prompt() {
let prompt = par_term_config::AssistantPrompt {
path: std::path::PathBuf::from("load.md"),
title: "Load only".to_string(),
auto_submit: false,
prompt: "Review this first.".to_string(),
};
let action = AIInspectorPanel::action_for_assistant_prompt(&prompt);
assert!(
matches!(action, InspectorAction::LoadPrompt(text) if text == "Review this first.")
);
}
#[test]
fn prompt_selection_sends_auto_submit_prompt() {
let prompt = par_term_config::AssistantPrompt {
path: std::path::PathBuf::from("send.md"),
title: "Send now".to_string(),
auto_submit: true,
prompt: "Run diagnostics.".to_string(),
};
let action = AIInspectorPanel::action_for_assistant_prompt(&prompt);
assert!(matches!(action, InspectorAction::SendPrompt(text) if text == "Run diagnostics."));
}
#[test]
fn test_agent_project_label_uses_project_directory_name() {
let config = Config::default();
let mut panel = AIInspectorPanel::new_for_tests(&config);
panel.connected_agent_project_root = Some("/Users/example/Repos/par-term".to_string());
assert_eq!(
panel.agent_project_label(),
Some("Project: par-term".to_string())
);
}
#[test]
fn test_agent_project_label_falls_back_to_full_path_for_root() {
let config = Config::default();
let mut panel = AIInspectorPanel::new_for_tests(&config);
panel.connected_agent_project_root = Some("/".to_string());
assert_eq!(panel.agent_project_label(), Some("Project: /".to_string()));
}
}