Skip to content

Commit 37a8a99

Browse files
committed
✨ Add real-time tool activity tracking to chat interface
Display Iris's tool calls as they happen during streaming responses. Users now see which tools are being invoked in real-time, providing transparency into the AI's reasoning process. Tool activity display: - Show current tool with animated spinner during execution - Maintain visual history of completed tools above the response - Capture tool call events from multi-turn streaming conversations - Clear tool state appropriately on response completion and chat reset Markdown rendering improvements: - Add spacing around inline code blocks for readability - Insert padding after code blocks and lists - Fix text indentation for wrapped lines in nested lists - Add bottom padding to ensure last messages scroll into view
1 parent e623393 commit 37a8a99

File tree

5 files changed

+159
-20
lines changed

5 files changed

+159
-20
lines changed

src/agents/iris.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,22 @@ Guidelines:
875875
aggregated_text.push_str(&text.text);
876876
on_chunk(&text.text, &aggregated_text);
877877
}
878+
Ok(MultiTurnStreamItem::StreamAssistantItem(
879+
StreamedAssistantContent::ToolCall(tool_call),
880+
)) => {
881+
// Update status to show tool execution
882+
let tool_name = &tool_call.function.name;
883+
let reason = format!("Calling {}", tool_name);
884+
crate::iris_status_dynamic!(
885+
IrisPhase::ToolExecution {
886+
tool_name: tool_name.clone(),
887+
reason: reason.clone()
888+
},
889+
format!("🔧 {}", reason),
890+
3,
891+
4
892+
);
893+
}
878894
Ok(MultiTurnStreamItem::FinalResponse(_)) => {
879895
// Stream complete
880896
break;
@@ -883,7 +899,7 @@ Guidelines:
883899
return Err(anyhow::anyhow!("Streaming error: {}", e));
884900
}
885901
_ => {
886-
// Tool calls, reasoning, etc. - continue
902+
// Reasoning, etc. - continue
887903
}
888904
}
889905
}

src/studio/app.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ use super::render::{
4141
render_changelog_panel, render_commit_panel, render_explore_panel, render_modal,
4242
render_pr_panel, render_release_notes_panel, render_review_panel,
4343
};
44-
use super::state::{ChatMessage, GitStatus, IrisStatus, Mode, Notification, PanelId, StudioState};
44+
use super::state::{GitStatus, IrisStatus, Mode, Notification, PanelId, StudioState};
4545
use super::theme;
4646

4747
// ═══════════════════════════════════════════════════════════════════════════════
@@ -640,13 +640,19 @@ impl StudioApp {
640640
},
641641

642642
IrisTaskResult::ToolStatus { tool_name, message } => {
643-
// Tool status updates go to chat progress
644-
// For now, handle directly since it's UI-only (chat bubble updates)
643+
// Tool status updates - move current tool to history, set new current
644+
let tool_desc = format!("{} - {}", tool_name, message);
645645
if let Some(Modal::Chat(chat)) = &mut self.state.modal {
646-
let tool_msg = format!("🔧 {} - {}", tool_name, message);
647-
chat.messages.push(ChatMessage::iris(tool_msg));
648-
chat.auto_scroll = true;
646+
// Move previous tool to history
647+
if let Some(prev) = chat.current_tool.take() {
648+
chat.tool_history.push(prev);
649+
}
650+
chat.current_tool = Some(tool_desc.clone());
651+
}
652+
if let Some(prev) = self.state.chat_state.current_tool.take() {
653+
self.state.chat_state.tool_history.push(prev);
649654
}
655+
self.state.chat_state.current_tool = Some(tool_desc);
650656
self.state.mark_dirty();
651657
continue; // Already handled, skip event push
652658
}

src/studio/reducer.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,8 +424,15 @@ pub fn reduce(
424424
TaskType::Chat => {
425425
if let Some(Modal::Chat(ref mut chat)) = state.modal {
426426
chat.streaming_response = None;
427+
// Move final current_tool to history before clearing
428+
if let Some(tool) = chat.current_tool.take() {
429+
chat.tool_history.push(tool);
430+
}
427431
}
428432
state.chat_state.streaming_response = None;
433+
if let Some(tool) = state.chat_state.current_tool.take() {
434+
state.chat_state.tool_history.push(tool);
435+
}
429436
}
430437
TaskType::SemanticBlame => {
431438
state.modes.explore.streaming_blame = None;

src/studio/render/chat.rs

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ pub fn render_messages(
7878
let formatted_lines = format_markdown(streaming, content_width, content_style);
7979
lines.extend(formatted_lines);
8080

81+
// Show tool activity history
82+
for tool in &chat_state.tool_history {
83+
lines.push(Line::from(vec![
84+
Span::styled(" ⚙ ", Style::default().fg(theme::NEON_CYAN)),
85+
Span::styled(
86+
tool.clone(),
87+
Style::default()
88+
.fg(theme::TEXT_MUTED)
89+
.add_modifier(Modifier::ITALIC),
90+
),
91+
]));
92+
}
93+
8194
// Add streaming cursor
8295
let spinner =
8396
theme::SPINNER_BRAILLE[last_render_ms as usize / 80 % theme::SPINNER_BRAILLE.len()];
@@ -89,19 +102,55 @@ pub fn render_messages(
89102
// Just show thinking indicator when no streaming content yet
90103
let spinner =
91104
theme::SPINNER_BRAILLE[last_render_ms as usize / 80 % theme::SPINNER_BRAILLE.len()];
92-
lines.push(Line::from(vec![
93-
Span::styled(
94-
format!("{} ", spinner),
105+
106+
// Show tool history when thinking
107+
for tool in &chat_state.tool_history {
108+
lines.push(Line::from(vec![
109+
Span::styled(" ⚙ ", Style::default().fg(theme::NEON_CYAN)),
110+
Span::styled(
111+
tool.clone(),
112+
Style::default()
113+
.fg(theme::TEXT_MUTED)
114+
.add_modifier(Modifier::ITALIC),
115+
),
116+
]));
117+
}
118+
119+
// Show current tool activity prominently
120+
if let Some(ref tool) = chat_state.current_tool {
121+
lines.push(Line::from(vec![
122+
Span::styled(
123+
format!("{} ⚙ ", spinner),
124+
Style::default().fg(theme::NEON_CYAN),
125+
),
126+
Span::styled(
127+
tool.clone(),
128+
Style::default()
129+
.fg(theme::TEXT_SECONDARY)
130+
.add_modifier(Modifier::ITALIC),
131+
),
132+
]));
133+
} else if chat_state.tool_history.is_empty() {
134+
lines.push(Line::from(vec![
135+
Span::styled(
136+
format!("{} ", spinner),
137+
Style::default().fg(theme::ELECTRIC_PURPLE),
138+
),
139+
Span::styled(
140+
"Iris is thinking",
141+
Style::default()
142+
.fg(theme::ELECTRIC_PURPLE)
143+
.add_modifier(Modifier::ITALIC),
144+
),
145+
Span::styled("...", Style::default().fg(theme::TEXT_DIM)),
146+
]));
147+
} else {
148+
// Show spinner after tool history
149+
lines.push(Line::from(Span::styled(
150+
format!(" {}", spinner),
95151
Style::default().fg(theme::ELECTRIC_PURPLE),
96-
),
97-
Span::styled(
98-
"Iris is thinking",
99-
Style::default()
100-
.fg(theme::ELECTRIC_PURPLE)
101-
.add_modifier(Modifier::ITALIC),
102-
),
103-
Span::styled("...", Style::default().fg(theme::TEXT_DIM)),
104-
]));
152+
)));
153+
}
105154
}
106155
}
107156

@@ -129,6 +178,13 @@ pub fn render_messages(
129178
]));
130179
}
131180

181+
// Add bottom padding for scrolling - ensures last message can scroll into view
182+
if !chat_state.messages.is_empty() || chat_state.is_responding {
183+
lines.push(Line::from(""));
184+
lines.push(Line::from(""));
185+
lines.push(Line::from(""));
186+
}
187+
132188
lines
133189
}
134190

@@ -234,11 +290,13 @@ pub fn format_markdown(content: &str, max_width: usize, base_style: Style) -> Ve
234290
code_block_lines.clear();
235291
code_lang.clear();
236292
in_code_block = false;
293+
lines.push(Line::from("")); // Add spacing after code blocks
237294
}
238295
TagEnd::List(_) => {
239296
list_depth = list_depth.saturating_sub(1);
240297
if list_depth == 0 {
241298
ordered_list_num = None;
299+
lines.push(Line::from("")); // Add spacing after top-level lists
242300
}
243301
}
244302
TagEnd::Item => {
@@ -260,20 +318,60 @@ pub fn format_markdown(content: &str, max_width: usize, base_style: Style) -> Ve
260318
code_block_lines.extend(text.lines().map(String::from));
261319
} else {
262320
let style = style_stack.last().copied().unwrap_or(base_style);
321+
322+
// Add space after inline code if text doesn't start with punctuation/space
323+
if let Some(last_span) = current_spans.last() {
324+
let last_content = last_span.content.as_ref();
325+
if last_content.ends_with('`') {
326+
let first_char = text.chars().next().unwrap_or(' ');
327+
if !first_char.is_whitespace()
328+
&& !matches!(
329+
first_char,
330+
'.' | ',' | ':' | ';' | ')' | ']' | '-' | '!' | '?'
331+
)
332+
{
333+
current_spans.push(Span::styled(" ", style));
334+
}
335+
}
336+
}
337+
263338
// Word wrap the text
264339
let effective_width = max_width.saturating_sub(4 + list_depth * 2);
265340
for chunk in wrap_text(&text, effective_width) {
266341
if !current_spans.is_empty() && !chunk.is_empty() {
267342
current_spans.push(Span::styled(chunk, style));
268343
} else if !chunk.is_empty() {
269-
let indent = if list_depth > 0 { "" } else { " " };
344+
// Base indent + list depth indent (aligns with bullet text)
345+
let indent = if list_depth > 0 {
346+
// 2 spaces per depth level + 2 for bullet alignment
347+
" ".repeat(list_depth) + " "
348+
} else {
349+
" ".to_string()
350+
};
270351
current_spans.push(Span::styled(format!("{}{}", indent, chunk), style));
271352
}
272353
}
273354
}
274355
}
275356
Event::Code(code) => {
357+
// Add space before inline code if needed (prevents "text`code`" from merging)
358+
if let Some(last_span) = current_spans.last() {
359+
let last_content = last_span.content.as_ref();
360+
if !last_content.is_empty()
361+
&& !last_content.ends_with(' ')
362+
&& !last_content.ends_with('\n')
363+
&& !last_content.ends_with('(')
364+
&& !last_content.ends_with('[')
365+
{
366+
let style = style_stack.last().copied().unwrap_or(base_style);
367+
current_spans.push(Span::styled(" ", style));
368+
}
369+
}
370+
// Render without backticks - just styled text
276371
current_spans.push(Span::styled(code.to_string(), theme::inline_code()));
372+
// Add trailing space after inline code
373+
let style = style_stack.last().copied().unwrap_or(base_style);
374+
current_spans.push(Span::styled(" ", style));
277375
}
278376
Event::SoftBreak => {
279377
// Treat as space

src/studio/state.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,10 @@ pub struct ChatState {
274274
pub streaming_response: Option<String>,
275275
/// Whether to auto-scroll to bottom on new content
276276
pub auto_scroll: bool,
277+
/// Current tool being executed (shown with spinner)
278+
pub current_tool: Option<String>,
279+
/// History of tools called during this response
280+
pub tool_history: Vec<String>,
277281
}
278282

279283
impl Default for ChatState {
@@ -285,6 +289,8 @@ impl Default for ChatState {
285289
is_responding: false,
286290
streaming_response: None,
287291
auto_scroll: true,
292+
current_tool: None,
293+
tool_history: Vec::new(),
288294
}
289295
}
290296
}
@@ -331,6 +337,9 @@ impl ChatState {
331337
pub fn add_iris_response(&mut self, content: &str) {
332338
self.messages.push(ChatMessage::iris(content));
333339
self.is_responding = false;
340+
self.streaming_response = None;
341+
self.current_tool = None;
342+
self.tool_history.clear();
334343
self.auto_scroll = true; // Re-enable auto-scroll on new messages
335344
}
336345

@@ -355,6 +364,9 @@ impl ChatState {
355364
self.input.clear();
356365
self.scroll_offset = 0;
357366
self.is_responding = false;
367+
self.streaming_response = None;
368+
self.current_tool = None;
369+
self.tool_history.clear();
358370
self.auto_scroll = true;
359371
}
360372
}

0 commit comments

Comments
 (0)