1use crate::tui::events::UiEvent;
4use crate::tui::ui::transcript::render_block;
5
6use super::render::extract_write_file_path_from_result;
7use super::{
8 parse_apply_patch_input, preview_args, verb_for_tool, App, ToolResultData, TranscriptBlock,
9};
10
11impl App {
12 pub fn handle_ui_event(&mut self, event: UiEvent) {
14 match event {
15 UiEvent::AssistantPartial { text } => {
16 self.append_streaming_assistant(&text);
17 }
18 UiEvent::Reasoning { content } => {
19 self.blocks
24 .push(TranscriptBlock::Reasoning { text: content });
25 }
26 UiEvent::AssistantMessage { content } => {
27 self.finalise_streaming_assistant(content);
33 }
34 UiEvent::ToolCall {
35 id,
36 name,
37 arguments,
38 } => {
39 let preview = preview_args(&arguments);
40 self.blocks.push(TranscriptBlock::ToolCall {
45 id: id.clone(),
46 name: name.clone(),
47 args_preview: preview,
48 result: None,
49 });
50 if name == "apply_patch" {
51 if let Some((path, hunks)) = parse_apply_patch_input(&arguments) {
52 self.blocks.push(TranscriptBlock::Diff { path, hunks });
53 }
54 }
55 self.turn.spinner_verb = verb_for_tool(&name);
57 }
58 UiEvent::ToolResult {
59 id,
60 name,
61 output,
62 success,
63 } => {
64 if name == "write_file" && success {
71 if let Some(path) = extract_write_file_path_from_result(&output) {
72 self.blocks.push(TranscriptBlock::Diff {
73 path,
74 hunks: vec![],
75 });
76 if let Some(TranscriptBlock::ToolCall { result, .. }) = self
77 .blocks
78 .iter_mut()
79 .rev()
80 .find(|b| {
81 matches!(b, TranscriptBlock::ToolCall { id: cid, .. } if cid == &id)
82 }) {
83 *result = Some(ToolResultData {
84 success: true,
85 output: String::new(),
86 expanded: false,
87 });
88 }
89 return;
90 }
91 }
92 let mut filled = false;
98 for block in self.blocks.iter_mut().rev() {
99 if let TranscriptBlock::ToolCall {
100 id: cid,
101 result,
102 name: cname,
103 args_preview,
104 } = block
105 {
106 if cid == &id {
107 *result = Some(ToolResultData {
108 success,
109 output: output.clone(),
110 expanded: false,
111 });
112 if cname.is_empty() {
116 *cname = name.clone();
117 }
118 if args_preview.is_empty() {
119 *args_preview = String::new();
120 }
121 filled = true;
122 break;
123 }
124 }
125 }
126 if !filled {
127 self.blocks.push(TranscriptBlock::ToolCall {
128 id,
129 name,
130 args_preview: String::new(),
131 result: Some(ToolResultData {
132 success,
133 output,
134 expanded: false,
135 }),
136 });
137 }
138 }
139 UiEvent::Usage {
140 input_tokens,
141 output_tokens,
142 } => {
143 self.usage.record(input_tokens, output_tokens);
144 }
145 UiEvent::Latency { llm_ms } => {
146 self.usage.last_latency_ms = llm_ms;
147 self.pending_latency_ms = Some(llm_ms);
148 if let Some(TranscriptBlock::Assistant {
150 streaming: true,
151 latency_ms,
152 ..
153 }) = self.blocks.last_mut()
154 {
155 *latency_ms = Some(llm_ms);
156 }
157 }
158 UiEvent::Compacted { removed, kept } => {
159 self.blocks
160 .push(TranscriptBlock::Compacted { removed, kept });
161 }
162 UiEvent::TurnFinished => {
163 if let Some(TranscriptBlock::Assistant { streaming, .. }) = self.blocks.last_mut() {
168 *streaming = false;
169 }
170 self.turn.finish();
171 self.pending_latency_ms = None;
172 }
173 UiEvent::Error { message } => {
174 self.blocks.push(TranscriptBlock::Error {
175 text: format!("Error: {message}"),
176 });
177 }
178 UiEvent::PlanProposed {
179 plan_text,
180 tool_calls,
181 } => {
182 self.blocks.push(TranscriptBlock::PlanProposal {
189 plan_text,
190 tool_calls,
191 });
192 self.plan_awaiting_approval = true;
193 }
194 UiEvent::PlanConfirmed => {
195 self.close_plan_review_modal();
196 self.blocks.push(TranscriptBlock::System {
197 text: "Plan approved".into(),
198 });
199 self.plan_awaiting_approval = false;
200 }
201 UiEvent::PlanRejected { reason } => {
202 self.close_plan_review_modal();
203 self.blocks.push(TranscriptBlock::System {
204 text: format!("Plan rejected: {reason}"),
205 });
206 self.plan_awaiting_approval = false;
207 }
208
209 UiEvent::PlanModeRequested { reason } => {
211 self.blocks.push(TranscriptBlock::PlanModeRequest {
214 reason,
215 approved: None,
216 });
217 self.plan_mode_request_pending = true;
218 }
219 UiEvent::PlanModeApproved => {
220 for block in self.blocks.iter_mut().rev() {
222 if let TranscriptBlock::PlanModeRequest { approved, .. } = block {
223 *approved = Some(true);
224 break;
225 }
226 }
227 self.plan_mode_request_pending = false;
228 }
229 UiEvent::PlanModeRejected { reason: _ } => {
230 for block in self.blocks.iter_mut().rev() {
232 if let TranscriptBlock::PlanModeRequest { approved, .. } = block {
233 *approved = Some(false);
234 break;
235 }
236 }
237 self.plan_mode_request_pending = false;
238 }
239
240 UiEvent::TodoUpdated { todos } => {
242 self.current_todos = todos;
243 }
244
245 UiEvent::GoalContinuing { reason, turns } => {
247 self.blocks.push(TranscriptBlock::System {
248 text: format!("Goal continuing (turn {turns}): {reason}"),
249 });
250 if let Some(ref mut gs) = self.active_goal {
252 gs.turns = turns;
253 gs.last_reason = Some(reason);
254 }
255 }
256 UiEvent::GoalAchieved { condition, turns } => {
257 self.blocks.push(TranscriptBlock::System {
258 text: format!("Goal achieved after {turns} turns: \"{condition}\""),
259 });
260 self.active_goal = None;
261 }
262 UiEvent::GoalCleared => {
263 self.blocks.push(TranscriptBlock::System {
264 text: "Goal cleared.".into(),
265 });
266 self.active_goal = None;
267 }
268 UiEvent::Interrupted => {
270 self.blocks.push(TranscriptBlock::System {
271 text: "[Interrupted]".into(),
272 });
273 self.turn.finish();
274 self.pending_latency_ms = None;
275 self.plan_awaiting_approval = false;
276 }
277 UiEvent::McpServersLoaded { entries } => {
278 self.push_modal(crate::tui::ui::modal::Modal::McpServers {
279 entries,
280 selected: 0,
281 });
282 }
283 UiEvent::SessionResumed {
284 session_id,
285 turn_count,
286 } => {
287 self.blocks.push(TranscriptBlock::System {
288 text: format!("▶ Resumed session {session_id} ({turn_count} messages)"),
289 });
290 self.turn.finish();
291 self.scroll_to_bottom();
292 }
293
294 UiEvent::HookStarted {
296 hook_event,
297 hook_name,
298 ..
299 } => {
300 self.blocks.push(TranscriptBlock::System {
301 text: format!("⚡ hook [{hook_event}] {hook_name} started"),
302 });
303 self.scroll_to_bottom();
304 }
305 UiEvent::HookProgress {
306 hook_name,
307 last_line,
308 ..
309 } => {
310 let hook_prefix = "⚡ hook".to_string();
312 let should_update = self
313 .blocks
314 .last()
315 .map(|b| matches!(b, TranscriptBlock::System { text } if text.starts_with(&hook_prefix)))
316 .unwrap_or(false);
317 let text = format!("⚡ hook {hook_name}: {last_line}");
318 if should_update {
319 if let Some(TranscriptBlock::System { text: t }) = self.blocks.last_mut() {
320 *t = text;
321 }
322 } else {
323 self.blocks.push(TranscriptBlock::System { text });
324 }
325 self.scroll_to_bottom();
326 }
327 UiEvent::HookFinished {
328 hook_event,
329 hook_name,
330 outcome,
331 duration_ms,
332 } => {
333 self.blocks.push(TranscriptBlock::System {
334 text: format!(
335 "✓ hook [{hook_event}] {hook_name} → {outcome} ({duration_ms}ms)"
336 ),
337 });
338 self.scroll_to_bottom();
339 }
340 UiEvent::HookSystemMessage { text } => {
341 self.blocks.push(TranscriptBlock::System { text });
342 self.scroll_to_bottom();
343 }
344
345 #[cfg(feature = "weixin")]
346 UiEvent::WeixinMessage { user_id, text } => {
347 self.blocks
348 .push(TranscriptBlock::WeixinMessage { user_id, text });
349 self.scroll_to_bottom();
350 }
351 }
352 if self.scroll_offset == 0 {
358 self.scroll_to_bottom();
359 }
360 }
361
362 fn close_plan_review_modal(&mut self) {
367 if matches!(
368 self.modals.last(),
369 Some(crate::tui::ui::modal::Modal::PlanReview { .. })
370 ) {
371 self.modals.pop();
372 }
373 }
374
375 pub(crate) fn start_turn(&mut self) {
376 self.turn.start();
377 self.turn_count = self.turn_count.saturating_add(1);
378 }
379
380 fn append_streaming_assistant(&mut self, chunk: &str) {
381 if let Some(TranscriptBlock::Assistant {
382 text,
383 streaming: true,
384 ..
385 }) = self.blocks.last_mut()
386 {
387 text.push_str(chunk);
388 } else {
389 self.blocks.push(TranscriptBlock::Assistant {
390 text: chunk.to_string(),
391 streaming: true,
392 latency_ms: self.pending_latency_ms,
393 });
394 }
395 }
396
397 fn finalise_streaming_assistant(&mut self, content: String) {
398 if let Some(TranscriptBlock::Assistant {
399 text,
400 streaming,
401 latency_ms,
402 }) = self.blocks.last_mut()
403 {
404 if *streaming {
405 *text = content;
406 *streaming = false;
407 if latency_ms.is_none() {
408 *latency_ms = self.pending_latency_ms;
409 }
410 return;
411 }
412 }
413 self.blocks.push(TranscriptBlock::Assistant {
414 text: content,
415 streaming: false,
416 latency_ms: self.pending_latency_ms,
417 });
418 }
419
420 pub fn flush_ready_blocks(&mut self) {
435 loop {
436 let i = self.last_printed_idx;
437 if i >= self.blocks.len() {
438 break;
439 }
440 let ready = match &self.blocks[i] {
441 TranscriptBlock::User { .. } => true,
442 TranscriptBlock::Assistant { streaming, .. } => !streaming,
443 TranscriptBlock::ToolCall { result, .. } => result.is_some(),
444 TranscriptBlock::Reasoning { .. } => {
445 !matches!(
448 self.blocks.get(i + 1),
449 Some(TranscriptBlock::Assistant {
450 streaming: true,
451 ..
452 })
453 )
454 }
455 _ => true,
456 };
457 if !ready {
458 break;
459 }
460 let is_user = matches!(&self.blocks[i], TranscriptBlock::User { .. });
461 let is_first = i == 0;
462
463 if !is_first {
467 let mut pre: Vec<ratatui::text::Line<'static>> = vec![ratatui::text::Line::raw("")];
468 if is_user {
469 pre.push(ratatui::text::Line::raw(""));
471 }
472 self.print_queue.push(pre);
473 }
474
475 let lines = render_block(&self.blocks[i], self.theme);
476 self.print_queue.push(lines);
477
478 if is_user {
480 self.print_queue.push(vec![ratatui::text::Line::raw("")]);
481 }
482
483 self.last_printed_idx += 1;
484 }
485 }
486
487 pub(crate) fn toggle_last_expandable(&mut self) {
492 for block in self.blocks.iter_mut().rev() {
493 if let TranscriptBlock::ToolCall {
494 result: Some(ToolResultData { expanded, .. }),
495 ..
496 } = block
497 {
498 *expanded = !*expanded;
499 return;
500 }
501 }
502 }
503}
504
505#[cfg(test)]
510mod tests {
511 use crate::tui::app::{App, AppScreen, TranscriptBlock};
512 use crate::tui::events::UiEvent;
513
514 #[test]
517 fn transcript_apply_partial_token_appends_to_streaming_assistant() {
518 let mut app = App::new();
519 app.screen = AppScreen::Chat;
520 app.handle_ui_event(UiEvent::AssistantPartial { text: "hel".into() });
521 app.handle_ui_event(UiEvent::AssistantPartial { text: "lo".into() });
522
523 match app.blocks.last() {
524 Some(TranscriptBlock::Assistant {
525 text, streaming, ..
526 }) => {
527 assert_eq!(text, "hello");
528 assert!(*streaming);
529 }
530 other => panic!("expected streaming Assistant, got {other:?}"),
531 }
532 }
533
534 #[test]
535 fn transcript_apply_assistant_text_finalizes_streaming() {
536 let mut app = App::new();
537 app.screen = AppScreen::Chat;
538 app.handle_ui_event(UiEvent::AssistantPartial { text: "hel".into() });
539 app.handle_ui_event(UiEvent::AssistantMessage {
540 content: "hello world".into(),
541 });
542
543 match app.blocks.last() {
544 Some(TranscriptBlock::Assistant {
545 text, streaming, ..
546 }) => {
547 assert_eq!(text, "hello world");
548 assert!(!*streaming);
549 }
550 other => panic!("expected finalised Assistant, got {other:?}"),
551 }
552 }
553
554 #[test]
555 fn transcript_assistant_text_without_prior_stream_creates_block() {
556 let mut app = App::new();
557 app.screen = AppScreen::Chat;
558 app.handle_ui_event(UiEvent::AssistantMessage {
559 content: "single shot".into(),
560 });
561 match app.blocks.last() {
562 Some(TranscriptBlock::Assistant {
563 text, streaming, ..
564 }) => {
565 assert_eq!(text, "single shot");
566 assert!(!*streaming);
567 }
568 other => panic!("expected non-streaming Assistant, got {other:?}"),
569 }
570 }
571
572 #[test]
575 fn tool_call_and_result_pair_by_id() {
576 let mut app = App::new();
577 app.screen = AppScreen::Chat;
578 app.handle_ui_event(UiEvent::ToolCall {
579 id: "abc".into(),
580 name: "read_file".into(),
581 arguments: r#"{"path":"src/agent.rs"}"#.into(),
582 });
583 app.handle_ui_event(UiEvent::ToolResult {
584 id: "abc".into(),
585 name: "read_file".into(),
586 output: "ok".into(),
587 success: true,
588 });
589
590 let tool_calls: Vec<_> = app
595 .blocks
596 .iter()
597 .filter(|b| matches!(b, TranscriptBlock::ToolCall { id, .. } if id == "abc"))
598 .collect();
599 assert_eq!(tool_calls.len(), 1, "ToolResult must merge into ToolCall");
600 let block = tool_calls[0];
601 match block {
602 TranscriptBlock::ToolCall {
603 id,
604 result: Some(r),
605 ..
606 } => {
607 assert_eq!(id, "abc");
608 assert!(r.success);
609 assert_eq!(r.output, "ok");
610 }
611 other => panic!("expected ToolCall with Some(result), got {other:?}"),
612 }
613 }
614
615 #[test]
616 fn apply_patch_call_emits_diff_block() {
617 let mut app = App::new();
618 app.screen = AppScreen::Chat;
619 let patch = "*** Begin Patch\n*** Update File: src/foo.rs\n@@ pub fn bar()\n pub fn bar() {\n- let x = 1;\n+ let x = 2;\n }\n*** End Patch";
620 let arguments = serde_json::json!({"input": patch}).to_string();
621 app.handle_ui_event(UiEvent::ToolCall {
622 id: "1".into(),
623 name: "apply_patch".into(),
624 arguments,
625 });
626 let has_diff = app
627 .blocks
628 .iter()
629 .any(|b| matches!(b, TranscriptBlock::Diff { path, .. } if path == "src/foo.rs"));
630 assert!(has_diff, "expected Diff block, got {:?}", app.blocks);
631 }
632
633 #[test]
634 fn write_file_result_renders_diff_block() {
635 let mut app = App::new();
636 app.screen = AppScreen::Chat;
637 app.handle_ui_event(UiEvent::ToolCall {
638 id: "1".into(),
639 name: "write_file".into(),
640 arguments: r#"{"path":"src/new.rs","contents":"x"}"#.into(),
641 });
642 app.handle_ui_event(UiEvent::ToolResult {
643 id: "1".into(),
644 name: "write_file".into(),
645 output: "Wrote 42 bytes to src/new.rs".into(),
646 success: true,
647 });
648 let has_diff = app.blocks.iter().any(
649 |b| matches!(b, TranscriptBlock::Diff { path, .. } if path.contains("src/new.rs")),
650 );
651 assert!(has_diff, "expected Diff block from write_file");
652 }
653
654 #[test]
657 fn compacted_event_creates_compacted_block() {
658 let mut app = App::new();
659 app.screen = AppScreen::Chat;
660 app.handle_ui_event(UiEvent::Compacted {
661 removed: 12,
662 kept: 1,
663 });
664 assert!(matches!(
665 app.blocks.last(),
666 Some(TranscriptBlock::Compacted {
667 removed: 12,
668 kept: 1
669 })
670 ));
671 }
672
673 #[test]
676 fn usage_stats_accumulate_across_turns() {
677 let mut app = App::new();
678 app.screen = AppScreen::Chat;
679 app.handle_ui_event(UiEvent::Usage {
680 input_tokens: 100,
681 output_tokens: 50,
682 });
683 app.handle_ui_event(UiEvent::Usage {
684 input_tokens: 30,
685 output_tokens: 20,
686 });
687 assert_eq!(app.usage.total_input, 130);
688 assert_eq!(app.usage.total_output, 70);
689 assert_eq!(app.usage.input_tokens, 30);
690 assert_eq!(app.usage.output_tokens, 20);
691 }
692
693 #[test]
696 fn error_event_pushes_error_block() {
697 let mut app = App::new();
698 app.screen = AppScreen::Chat;
699 app.handle_ui_event(UiEvent::Error {
700 message: "boom".into(),
701 });
702 assert!(matches!(
703 app.blocks.last(),
704 Some(TranscriptBlock::Error { text }) if text.contains("boom")
705 ));
706 }
707
708 #[test]
711 fn plan_proposed_event_opens_plan_review_modal() {
712 let mut app = App::new();
715 app.screen = AppScreen::Chat;
716 app.handle_ui_event(UiEvent::PlanProposed {
717 plan_text: "1. read_file\n2. apply_patch".into(),
718 tool_calls: vec![serde_json::json!({
719 "name": "read_file",
720 "id": "1",
721 "arguments": { "path": "src/foo.rs" }
722 })],
723 });
724 assert!(app.modals.is_empty());
726 assert!(app.blocks.iter().any(|b| matches!(b,
728 TranscriptBlock::PlanProposal { plan_text, .. }
729 if plan_text.contains("read_file"))));
730 assert!(app.plan_awaiting_approval);
731 }
732
733 #[test]
734 fn plan_confirmed_closes_modal_and_pushes_system_block() {
735 use crate::tui::ui::modal::Modal;
736 let mut app = App::new();
737 app.screen = AppScreen::Chat;
738 app.modals.push(Modal::PlanReview {
739 plan_text: "do".into(),
740 tool_calls: vec![],
741 edited_text: None,
742 });
743 app.handle_ui_event(UiEvent::PlanConfirmed);
744 assert!(app.modals.is_empty());
745 assert!(app
746 .blocks
747 .iter()
748 .any(|b| matches!(b, TranscriptBlock::System { text } if text == "Plan approved")));
749 }
750
751 #[test]
752 fn plan_rejected_pushes_system_block_with_reason() {
753 use crate::tui::ui::modal::Modal;
754 let mut app = App::new();
755 app.screen = AppScreen::Chat;
756 app.modals.push(Modal::PlanReview {
757 plan_text: "do".into(),
758 tool_calls: vec![],
759 edited_text: None,
760 });
761 app.handle_ui_event(UiEvent::PlanRejected {
762 reason: "user rejected".into(),
763 });
764 assert!(app.modals.is_empty());
765 assert!(app.blocks.iter().any(|b| matches!(b,
766 TranscriptBlock::System { text } if text == "Plan rejected: user rejected")));
767 }
768
769 #[test]
774 fn new_event_keeps_scroll_offset_when_user_scrolled_up() {
775 let mut app = App::new();
776 app.screen = AppScreen::Chat;
777 app.scroll_offset = 5; app.handle_ui_event(UiEvent::AssistantMessage {
779 content: "hello".into(),
780 });
781 assert_eq!(app.scroll_offset, 5);
782 }
783
784 #[test]
787 fn new_event_at_bottom_keeps_user_at_bottom() {
788 let mut app = App::new();
789 app.screen = AppScreen::Chat;
790 app.scroll_offset = 0;
791 app.handle_ui_event(UiEvent::AssistantMessage {
792 content: "hello".into(),
793 });
794 assert_eq!(app.scroll_offset, 0);
795 }
796
797 #[test]
800 fn turn_finished_stops_turn() {
801 let mut app = App::new();
802 app.screen = AppScreen::Chat;
803 app.set_input("hi");
804 app.handle_ui_event(UiEvent::TurnFinished);
805 assert!(!app.turn.running);
806 }
807}