Skip to content

Commit e623393

Browse files
committed
✨ Add streaming response display for TUI chat modal
Show real-time LLM output in the chat modal instead of only a thinking indicator. The chat now displays markdown-formatted streaming content as it arrives, with a spinner cursor at the end. Also adds mouse scroll support for the chat modal, allowing users to scroll through conversation history while Iris is responding.
1 parent 443ed16 commit e623393

File tree

4 files changed

+106
-16
lines changed

4 files changed

+106
-16
lines changed

src/agents/setup.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,24 @@ impl IrisAgentService {
372372
agent.execute_task("chat", task_prompt).await
373373
}
374374

375+
/// Execute a chat task with streaming and content update capabilities
376+
///
377+
/// Combines streaming output with tool-based content updates for the TUI chat.
378+
pub async fn execute_chat_streaming<F>(
379+
&self,
380+
task_prompt: &str,
381+
content_update_sender: crate::agents::tools::ContentUpdateSender,
382+
on_chunk: F,
383+
) -> Result<StructuredResponse>
384+
where
385+
F: FnMut(&str, &str) + Send,
386+
{
387+
let mut agent = self.create_agent_with_content_updates(content_update_sender)?;
388+
agent
389+
.execute_task_streaming("chat", task_prompt, on_chunk)
390+
.await
391+
}
392+
375393
/// Execute an agent task with streaming
376394
///
377395
/// This method streams LLM output in real-time, calling the callback with each

src/studio/app.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -852,9 +852,26 @@ Simply call the appropriate tool with the new content. Do NOT echo back the full
852852
mode_context, content_section, history_str, update_instructions, message
853853
);
854854

855-
// Execute with content update tools enabled
856-
match agent.execute_chat_with_updates(&prompt, content_tx).await {
855+
// Execute with streaming and content update tools
856+
let streaming_tx = tx.clone();
857+
let on_chunk = move |chunk: &str, aggregated: &str| {
858+
let _ = streaming_tx.send(IrisTaskResult::StreamingChunk {
859+
task_type: TaskType::Chat,
860+
chunk: chunk.to_string(),
861+
aggregated: aggregated.to_string(),
862+
});
863+
};
864+
865+
match agent
866+
.execute_chat_streaming(&prompt, content_tx, on_chunk)
867+
.await
868+
{
857869
Ok(response) => {
870+
// Signal streaming complete
871+
let _ = tx.send(IrisTaskResult::StreamingComplete {
872+
task_type: TaskType::Chat,
873+
});
874+
858875
let text = match response {
859876
StructuredResponse::PlainText(text) => text,
860877
other => other.to_string(),

src/studio/reducer.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,30 @@ fn reduce_mouse_event(
10631063
) -> Vec<SideEffect> {
10641064
let effects = Vec::new();
10651065

1066+
// Check if chat modal is open - scroll it instead of the main view
1067+
if let Some(Modal::Chat(ref mut chat)) = state.modal {
1068+
match mouse.kind {
1069+
MouseEventKind::ScrollUp => {
1070+
chat.scroll_up(3);
1071+
state.mark_dirty();
1072+
}
1073+
MouseEventKind::ScrollDown => {
1074+
// For scroll_down we need max_scroll, but we don't have render info here.
1075+
// Just increment and let render clamp it.
1076+
chat.scroll_offset = chat.scroll_offset.saturating_add(3);
1077+
// Don't auto-scroll since user is manually scrolling
1078+
chat.auto_scroll = false;
1079+
state.mark_dirty();
1080+
}
1081+
MouseEventKind::Down(_) => {
1082+
state.mark_dirty();
1083+
}
1084+
_ => {}
1085+
}
1086+
return effects;
1087+
}
1088+
1089+
// Normal scroll handling for main views
10661090
match mouse.kind {
10671091
MouseEventKind::ScrollUp => {
10681092
apply_scroll(state, ScrollDirection::Up, 3);

src/studio/render/chat.rs

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,26 +52,57 @@ pub fn render_messages(
5252
}
5353
}
5454

55-
// Show typing indicator if Iris is responding
55+
// Show streaming response or typing indicator if Iris is responding
5656
if chat_state.is_responding {
5757
if !chat_state.messages.is_empty() {
5858
lines.push(Line::from(""));
59+
lines.push(Line::from(Span::styled(
60+
"─".repeat(content_width.min(40)),
61+
Style::default().fg(theme::TEXT_DIM),
62+
)));
63+
lines.push(Line::from(""));
5964
}
60-
let spinner =
61-
theme::SPINNER_BRAILLE[last_render_ms as usize / 80 % theme::SPINNER_BRAILLE.len()];
62-
lines.push(Line::from(vec![
63-
Span::styled(
64-
format!("{} ", spinner),
65-
Style::default().fg(theme::ELECTRIC_PURPLE),
66-
),
67-
Span::styled(
68-
"Iris is thinking",
65+
66+
// Show streaming content if available
67+
if let Some(ref streaming) = chat_state.streaming_response {
68+
// Iris header for streaming response
69+
lines.push(Line::from(Span::styled(
70+
"◇ Iris",
6971
Style::default()
7072
.fg(theme::ELECTRIC_PURPLE)
71-
.add_modifier(Modifier::ITALIC),
72-
),
73-
Span::styled("...", Style::default().fg(theme::TEXT_DIM)),
74-
]));
73+
.add_modifier(Modifier::BOLD),
74+
)));
75+
76+
// Render the streaming content with markdown formatting
77+
let content_style = Style::default().fg(theme::TEXT_SECONDARY);
78+
let formatted_lines = format_markdown(streaming, content_width, content_style);
79+
lines.extend(formatted_lines);
80+
81+
// Add streaming cursor
82+
let spinner =
83+
theme::SPINNER_BRAILLE[last_render_ms as usize / 80 % theme::SPINNER_BRAILLE.len()];
84+
lines.push(Line::from(Span::styled(
85+
format!(" {}", spinner),
86+
Style::default().fg(theme::ELECTRIC_PURPLE),
87+
)));
88+
} else {
89+
// Just show thinking indicator when no streaming content yet
90+
let spinner =
91+
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),
95+
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+
]));
105+
}
75106
}
76107

77108
// Empty state with helpful message

0 commit comments

Comments
 (0)