1use std::collections::HashMap;
7use std::sync::Arc;
8
9use tokio::sync::mpsc;
10use tokio_util::sync::CancellationToken;
11
12use abk::cli::ResumeInfo;
13use abk::context::RunContext;
14
15use std::sync::atomic::{AtomicU8, Ordering};
16
17use crate::types::{
18 AutoHandoffConfig, BuildInfo, CapturedText, HandoffCaptureSink, McpServerInfo, McpServerStatus,
19 TuiMessage, WorkflowState,
20};
21
22pub struct Session {
27 pub input: String,
29 pub output_lines: Vec<String>,
31 pub workflow_tx: mpsc::UnboundedSender<TuiMessage>,
33 pub workflow_state: WorkflowState,
35 pub config_toml: Option<String>,
37 pub secrets: Option<HashMap<String, String>>,
39 pub build_info: Option<BuildInfo>,
41 pub resume_info: Option<ResumeInfo>,
43 pub backup_resume_info: Option<ResumeInfo>,
46 pub todo_lines: Vec<String>,
48 pub cancel_token: CancellationToken,
50 pub pending_command: Option<String>,
52 pub handoff_pending: bool,
54 pub pending_tool_lines: Vec<(String, usize, Option<String>)>,
56 pub current_context_tokens: usize,
58 pub auto_handoff: AutoHandoffConfig,
60 pub mcp_servers: Vec<McpServerInfo>,
62 pub should_quit: bool,
64 pub auto_scroll: bool,
66
67 pub agent_name: String,
71 pub token_store: Option<Arc<dyn pep::token_store::TokenStore>>,
74
75 pub project_id: Option<String>,
78 pub project_name: Option<String>,
80 pub session_id: Option<String>,
82 pub session_name: Option<String>,
84 pub home_dir: Option<std::path::PathBuf>,
87
88 pub workflow_permit: Option<tokio::sync::OwnedSemaphorePermit>,
93}
94
95impl Session {
96 pub fn new() -> (Self, mpsc::UnboundedReceiver<TuiMessage>) {
101 let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
102 let session = Self {
103 input: String::new(),
104 output_lines: Vec::new(),
105 workflow_tx,
106 workflow_state: WorkflowState::Idle,
107 config_toml: None,
108 secrets: None,
109 build_info: None,
110 resume_info: None,
111 backup_resume_info: None,
112 todo_lines: Vec::new(),
113 cancel_token: CancellationToken::new(),
114 pending_command: None,
115 handoff_pending: false,
116 pending_tool_lines: Vec::new(),
117 current_context_tokens: 0,
118 auto_handoff: AutoHandoffConfig::default(),
119 mcp_servers: Vec::new(),
120 should_quit: false,
121 auto_scroll: true,
122 agent_name: "trustee".to_string(),
123 token_store: None,
124 project_id: None,
125 project_name: None,
126 session_id: None,
127 session_name: None,
128 home_dir: None,
129 workflow_permit: None,
130 };
131 (session, workflow_rx)
132 }
133
134 pub fn parse_auto_handoff_config(&mut self) {
136 if let Some(ref config_toml) = self.config_toml {
137 self.auto_handoff = crate::config::parse_auto_handoff_config(config_toml);
138 }
139 }
140
141 pub fn handle_workflow_message(&mut self, msg: TuiMessage) {
146 match msg {
147 TuiMessage::WorkflowCancelled => {
148 self.output_lines.push("⏹ Workflow cancelled".to_string());
149 self.output_lines.push("".to_string());
150 self.workflow_state = WorkflowState::Cancelling;
151 }
152 TuiMessage::OutputLine(line) => {
153 self.output_lines.push(line);
154 }
155 TuiMessage::StreamDelta(delta) => {
156 if let Some(last) = self.output_lines.last_mut() {
157 last.push_str(&delta);
158 } else {
159 self.output_lines.push(delta);
160 }
161 }
162 TuiMessage::ReasoningDelta(delta) => {
163 if let Some(last) = self.output_lines.last_mut() {
164 if !last.starts_with('\x01') {
165 last.insert(0, '\x01');
166 }
167 last.push_str(&delta);
168 } else {
169 self.output_lines.push(format!("\x01{}", delta));
170 }
171 }
172 TuiMessage::WorkflowCompleted => {
173 self.output_lines.push("✓ Workflow completed".to_string());
174 self.output_lines.push("".to_string());
175 if self.workflow_state == WorkflowState::Running {
176 self.workflow_state = WorkflowState::Cancelling;
177 }
178 }
179 TuiMessage::WorkflowError(err) => {
180 self.output_lines.push(format!("✗ Error: {}", err));
181 self.output_lines.push("".to_string());
182 if self.workflow_state == WorkflowState::Running {
183 self.workflow_state = WorkflowState::Cancelling;
184 }
185 }
186 TuiMessage::TodoUpdate(content) => {
187 self.todo_lines = content.lines().map(|l| l.to_string()).collect();
188 }
189 TuiMessage::ToolPending { tool_name, hint } => {
190 let label = match &hint {
191 Some(h) => format!("⠋ {} {}", tool_name, h),
192 None => format!("⠋ {}", tool_name),
193 };
194 let idx = self.output_lines.len();
195 self.output_lines.push(label);
196 self.pending_tool_lines.push((tool_name, idx, hint));
197 }
198 TuiMessage::ToolDone { tool_name, success, hint } => {
199 let status = if success { "✓" } else { "✗" };
200 if let Some(pos) = self.pending_tool_lines.iter().position(|(n, _, _)| *n == tool_name) {
201 let (_, idx, pending_hint) = self.pending_tool_lines.remove(pos);
202 let h = hint.or(pending_hint);
203 let label = match &h {
204 Some(h) => format!("{} {} {}", status, tool_name, h),
205 None => format!("{} {}", status, tool_name),
206 };
207 if idx < self.output_lines.len() {
208 self.output_lines[idx] = label;
209 return;
210 }
211 self.output_lines.push(label);
212 } else {
213 let label = match &hint {
214 Some(h) => format!("{} {} {}", status, tool_name, h),
215 None => format!("{} {}", status, tool_name),
216 };
217 self.output_lines.push(label);
218 }
219 }
220 TuiMessage::ResumeInfo(info) => {
221 if self.workflow_state == WorkflowState::Cancelling && info.is_none() {
222 self.resume_info = self.backup_resume_info.take();
223 } else if info.is_some() {
224 self.resume_info = info;
230 self.backup_resume_info = None;
231 }
232 if let Some(ref ri) = self.resume_info {
238 if self.session_id.is_none() {
239 self.session_id = Some(ri.session_id.clone());
240 }
241 }
242
243 if self.workflow_state == WorkflowState::Cancelling {
244 self.workflow_state = WorkflowState::Idle;
245 self.workflow_permit = None;
247 }
248 if self.resume_info.is_some() {
249 if std::env::var("RUST_LOG")
250 .map(|v| v.to_lowercase().contains("debug"))
251 .unwrap_or(false)
252 {
253 self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
254 }
255 }
256 if self.workflow_state == WorkflowState::Idle && self.handoff_pending {
257 self.handoff_pending = false;
258 self.trigger_handoff(String::new());
259 } else if let Some(cmd) = self.pending_command.take() {
260 self.input = cmd;
261 self.execute_command();
262 }
263 }
264 TuiMessage::ContextTokensUpdated(count) => {
265 self.current_context_tokens = count;
266 if self.auto_handoff.enabled
267 && count >= self.auto_handoff.context_threshold
268 && self.workflow_state == WorkflowState::Running
269 && !self.handoff_pending
270 && self.resume_info.is_some()
271 {
272 self.handoff_pending = true;
273 self.cancel_token.cancel();
274 self.workflow_state = WorkflowState::Cancelling;
275 self.output_lines.push(format!(
276 "🔄 Auto-handoff: cancelling workflow, context tokens ({}) ≥ threshold ({})",
277 count, self.auto_handoff.context_threshold
278 ));
279 }
280 }
281 TuiMessage::McpServerStatus { name, connected, tool_count, error } => {
282 let status = if connected { McpServerStatus::Connected } else { McpServerStatus::Failed };
283 if let Some(existing) = self.mcp_servers.iter_mut().find(|s| s.name == name) {
284 existing.status = status;
285 existing.tool_count = tool_count;
286 existing.error = error;
287 } else {
288 self.mcp_servers.push(McpServerInfo { name, status, tool_count, error });
289 }
290 }
291 TuiMessage::HandoffReady(briefing) => {
292 self.workflow_state = WorkflowState::Idle;
293 self.resume_info = None;
294 self.session_id = None;
297 self.session_name = None;
298 self.input = briefing;
299 self.execute_command();
300 }
301 TuiMessage::HandoffFailed => {
302 self.workflow_state = WorkflowState::Idle;
306 self.output_lines.push(
307 "✗ Handoff briefing unavailable — session preserved, try again.".to_string(),
308 );
309 self.output_lines.push("".to_string());
310 }
311 TuiMessage::SessionTitleUpdated(title) => {
312 self.session_name = Some(title);
314 }
315 }
316 if self.auto_scroll {
317 }
320 }
321
322 pub fn execute_command(&mut self) {
327 let command = self.input.trim().to_string();
328
329 if self.workflow_state != WorkflowState::Idle {
330 self.pending_command = Some(command);
331 self.output_lines.push("⏳ Previous workflow finishing — command queued".to_string());
332 self.input.clear();
333 return;
334 }
335
336 let is_continuation = self.resume_info.is_some();
337
338 if !is_continuation {
339 self.output_lines.clear();
340 if self.session_id.is_none() {
346 let timestamp = chrono::Utc::now().format("%Y_%m_%d_%H_%M");
347 let uuid_suffix = uuid::Uuid::new_v4().simple().to_string();
348 let uuid8 = &uuid_suffix[..8];
349 self.session_id = Some(format!("session_{}_{}", timestamp, uuid8));
350 }
351 if self.session_name.is_none() {
352 let derived = if command.len() > 80 {
353 format!("{}...", &command[..77])
354 } else {
355 command.clone()
356 };
357 self.session_name = Some(derived);
358 }
359 }
360
361 self.output_lines.push(format!("> {}", command));
362
363 let config_toml = match &self.config_toml {
364 Some(c) => c.clone(),
365 None => {
366 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
367 self.output_lines.push("".to_string());
368 return;
369 }
370 };
371
372 let secrets = self.secrets.clone().unwrap_or_default();
373 let title_secrets = secrets.clone();
375 let build_info = self.build_info.clone();
376 let tx = self.workflow_tx.clone();
377
378 let agent_name = self.agent_name.clone();
379 let token_store = self.token_store.clone();
380 let project_id = self.project_id.clone();
381 let project_name = self.project_name.clone();
382 let session_id = self.session_id.clone();
383 let session_name = self.session_name.clone();
384 let home_dir = self.home_dir.clone();
385
386 self.backup_resume_info = self.resume_info.clone();
387 let resume_info = self.resume_info.take();
388
389 self.workflow_state = WorkflowState::Running;
390 self.auto_scroll = true;
391
392 self.cancel_token = CancellationToken::new();
393 let child_token = self.cancel_token.clone();
394
395 let (resume_tx, mut resume_rx) = mpsc::unbounded_channel();
396
397 let resume_forward_tx = tx.clone();
398 tokio::spawn(async move {
399 while let Some(info) = resume_rx.recv().await {
400 resume_forward_tx.send(TuiMessage::ResumeInfo(info)).ok();
401 }
402 });
403
404 tokio::spawn(async move {
405 let tui_sink: abk::orchestration::output::SharedSink =
406 Arc::new(crate::session::TuiForwardSink::new(tx.clone()));
407
408 let mut run_ctx = RunContext::new()
410 .with_agent_name(agent_name.clone());
411
412 if let Some(ref dir) = home_dir {
414 run_ctx = run_ctx.with_home_dir(dir.clone());
415 }
416
417 if project_id.is_some() || project_name.is_some() {
419 run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
420 id: project_id.unwrap_or_else(|| "default".to_string()),
421 name: project_name,
422 });
423 }
424
425 if session_id.is_some() || session_name.is_some() {
427 run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
428 id: session_id.unwrap_or_else(|| "default".to_string()),
429 name: session_name,
430 });
431 }
432
433 #[cfg(feature = "registry-mcp-token")]
434 {
435 if let Some(ref ts) = token_store {
436 run_ctx = run_ctx.with_token_store(ts.clone());
437 }
438 }
439
440 let scope_logger = {
445 abk::observability::Logger::with_agent_name(
446 None::<&std::path::Path>,
447 Some("INFO"),
448 Some(&agent_name),
449 ).unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap())
450 };
451
452 let result = abk::observability::with_logger(scope_logger, async {
453 abk::observability::with_tui_mode(true, async {
454 abk::cli::run_task_from_raw_config(
455 &config_toml,
456 secrets,
457 build_info,
458 &command,
459 Some(tui_sink),
460 resume_info,
461 Some(resume_tx),
462 Some(child_token),
463 Some(&run_ctx),
464 )
465 .await
466 })
467 .await
468 })
469 .await;
470
471 let task_result = result.unwrap_or_else(|e| abk::cli::TaskResult {
472 success: false,
473 error: Some(e.to_string()),
474 resume_info: None,
479 });
480
481 let msg = if task_result.success {
482 TuiMessage::WorkflowCompleted
483 } else {
484 TuiMessage::WorkflowError(task_result.error.unwrap_or_default())
485 };
486
487 let title_session_id = task_result.resume_info
489 .as_ref()
490 .map(|ri| ri.session_id.clone());
491
492 tx.send(msg).ok();
493 tx.send(TuiMessage::ResumeInfo(task_result.resume_info)).ok();
494
495 if task_result.success {
502 let title_tx = tx.clone();
503 let title_config = config_toml.clone();
504 let title_command = command.clone();
505 let title_ctx = run_ctx.clone();
506
507 tokio::spawn(async move {
508 if let Some(ref sid) = title_session_id {
512 if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
513 return;
514 }
515 } else {
516 return; }
518
519 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
521
522 if let Some(ref sid) = title_session_id {
524 if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
525 return;
526 }
527 }
528
529 match abk::cli::generate_session_title(
530 &title_config,
531 title_secrets,
532 &title_command,
533 )
534 .await
535 {
536 Ok(Some(title)) => {
537 if let Some(ref sid) = title_session_id {
539 if let Err(e) = abk::cli::persist_session_title(
540 &title_ctx,
541 &title_config,
542 sid,
543 &title,
544 ).await {
545 let _ = e; }
547 }
548 title_tx.send(TuiMessage::SessionTitleUpdated(title)).ok();
550 }
551 Ok(None) => {}
552 Err(e) => { let _ = e; }
553 }
554 });
555 }
556 });
557
558 self.input.clear();
559 }
560
561 pub fn request_handoff(&mut self, hint: String) {
572 match self.workflow_state {
573 WorkflowState::Idle => self.trigger_handoff(hint),
574 WorkflowState::Running => {
575 self.cancel_token.cancel();
576 self.workflow_state = WorkflowState::Cancelling;
577 self.handoff_pending = true;
578 self.output_lines
579 .push("⏹ Cancelling before handoff...".to_string());
580 }
581 WorkflowState::Cancelling => {
582 self.handoff_pending = true;
583 }
584 }
585 }
586
587 pub fn trigger_handoff(&mut self, hint: String) {
595 if self.workflow_state != WorkflowState::Idle {
599 self.output_lines
600 .push("⏳ Workflow still running — handoff will fire after it stops".to_string());
601 return;
602 }
603 if self.resume_info.is_none() {
604 self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
605 return;
606 }
607
608 let config_toml = match &self.config_toml {
609 Some(c) => c.clone(),
610 None => {
611 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
612 return;
613 }
614 };
615
616 let briefing_config = {
621 let mut value: toml::Value = config_toml
622 .parse()
623 .unwrap_or_else(|_| toml::Value::Table(toml::Table::new()));
624 if let Some(ckpt) = value.get_mut("checkpointing").and_then(|c| c.as_table_mut()) {
625 ckpt.insert("enabled".to_string(), toml::Value::Boolean(false));
626 }
627 toml::to_string(&value).unwrap_or_else(|_| config_toml.clone())
628 };
629
630 let secrets = self.secrets.clone().unwrap_or_default();
631 let build_info = self.build_info.clone();
632 let tx = self.workflow_tx.clone();
633
634 let agent_name = self.agent_name.clone();
635 let token_store = self.token_store.clone();
636 let project_id = self.project_id.clone();
637 let project_name = self.project_name.clone();
638 let session_id = self.session_id.clone();
639 let session_name = self.session_name.clone();
640 let home_dir = self.home_dir.clone();
641
642 let backup_resume_info = self.resume_info.clone();
645 let resume_info = self.resume_info.take();
646
647 self.workflow_state = WorkflowState::Running;
648 self.auto_scroll = true;
649 self.cancel_token = CancellationToken::new();
650 let child_token = self.cancel_token.clone();
651
652 self.output_lines.push("🔀 Generating session handoff briefing...".to_string());
653
654 tokio::spawn(async move {
655 let (cap_tx, mut cap_rx) = mpsc::unbounded_channel::<CapturedText>();
656 let cap_sink: abk::orchestration::output::SharedSink =
657 Arc::new(HandoffCaptureSink::new(cap_tx, child_token.clone()));
658
659 let base = "Output a session handoff briefing in at most 300 lines. \
660 Do NOT use any tools. Include: the FULL ABSOLUTE PATH of every \
661 project/repository being worked on (e.g. /Projects/Foo/bar — never \
662 omit the leading path), all project/task/workstream UUIDs referenced, \
663 every file created or modified with its full absolute path, all \
664 commands run and their outcomes, the current state of the work, any \
665 blockers, and the exact next action to take. \
666 Output ONLY the briefing text — no preamble, headers, or closing remarks.";
667 let prompt = if hint.is_empty() {
668 base.to_string()
669 } else {
670 format!("{base}\n\nIn the briefing also consider: {hint}")
671 };
672
673 let (dummy_tx, _dummy_rx) = mpsc::unbounded_channel();
674
675 let mut run_ctx = RunContext::new()
677 .with_agent_name(agent_name.clone());
678
679 if let Some(ref dir) = home_dir {
681 run_ctx = run_ctx.with_home_dir(dir.clone());
682 }
683
684 if project_id.is_some() || project_name.is_some() {
686 run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
687 id: project_id.unwrap_or_else(|| "default".to_string()),
688 name: project_name,
689 });
690 }
691
692 if session_id.is_some() || session_name.is_some() {
694 run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
695 id: session_id.unwrap_or_else(|| "default".to_string()),
696 name: session_name,
697 });
698 }
699 #[cfg(feature = "registry-mcp-token")]
700 {
701 }
704
705 let scope_logger = abk::observability::Logger::with_agent_name(
707 None,
708 Some("INFO"),
709 Some(&agent_name),
710 ).unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap());
711
712 let _res = abk::observability::with_logger(scope_logger, async {
713 abk::observability::with_tui_mode(true, async {
714 abk::cli::run_task_from_raw_config(
715 &briefing_config,
716 secrets,
717 build_info,
718 &prompt,
719 Some(cap_sink),
720 resume_info,
721 Some(dummy_tx),
722 Some(child_token),
723 Some(&run_ctx),
724 )
725 .await
726 })
727 .await
728 })
729 .await;
730
731 let mut text_parts = String::new();
732 let mut reasoning_parts = String::new();
733 while let Ok(captured) = cap_rx.try_recv() {
734 match captured {
735 CapturedText::Text(s) => text_parts.push_str(&s),
736 CapturedText::Reasoning(s) => reasoning_parts.push_str(&s),
737 }
738 }
739
740 let briefing = if !text_parts.trim().is_empty() {
741 Some(text_parts.trim().to_string())
742 } else if !reasoning_parts.trim().is_empty() {
743 Some(reasoning_parts.trim().to_string())
744 } else {
745 None
746 };
747
748 match briefing {
749 Some(briefing) => {
750 tx.send(TuiMessage::HandoffReady(briefing)).ok();
751 }
752 None => {
753 tx.send(TuiMessage::ResumeInfo(backup_resume_info)).ok();
758 tx.send(TuiMessage::HandoffFailed).ok();
759 }
760 }
761 });
762 }
763}
764
765impl Default for Session {
766 fn default() -> Self {
767 Self::new().0
768 }
769}
770
771pub struct TuiForwardSink {
777 tx: mpsc::UnboundedSender<TuiMessage>,
778 stream_state: AtomicU8,
779}
780
781const STREAM_IDLE: u8 = 0;
783const STREAM_REASONING: u8 = 1;
784const STREAM_CONTENT: u8 = 2;
785
786impl TuiForwardSink {
787 pub fn new(tx: mpsc::UnboundedSender<TuiMessage>) -> Self {
788 Self {
789 tx,
790 stream_state: AtomicU8::new(STREAM_IDLE),
791 }
792 }
793}
794
795impl abk::orchestration::output::OutputSink for TuiForwardSink {
796 fn emit(&self, event: abk::orchestration::output::OutputEvent) {
797 use abk::orchestration::output::OutputEvent;
798
799 let msg = match event {
800 OutputEvent::StreamingChunk { delta } => {
801 if delta.is_empty() {
802 return;
803 }
804 let prev = self.stream_state.swap(STREAM_CONTENT, Ordering::Relaxed);
805 if prev != STREAM_CONTENT {
806 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
807 }
808 let _ = self.tx.send(TuiMessage::StreamDelta(delta));
809 return;
810 }
811
812 OutputEvent::LlmResponse { text, model } => {
813 TuiMessage::OutputLine(format!("[{}] {}", model, text))
814 }
815
816 OutputEvent::Info { message } => {
817 if message.contains("API call completed successfully") {
819 return;
820 }
821 TuiMessage::OutputLine(message)
822 }
823
824 OutputEvent::WorkflowStarted { task_description } => {
825 TuiMessage::OutputLine(format!("🚀 Workflow started: {}", task_description))
826 }
827
828 OutputEvent::WorkflowCompleted { reason, iterations } => {
829 TuiMessage::OutputLine(format!(
830 "✅ Workflow completed after {} iterations: {}",
831 iterations, reason
832 ))
833 }
834
835 OutputEvent::IterationStarted { iteration, context_tokens } => {
836 let _ = self.tx.send(TuiMessage::ContextTokensUpdated(context_tokens));
837 TuiMessage::OutputLine(format!(
838 "📡 Iteration {} | Context = {} tokens",
839 iteration, context_tokens
840 ))
841 }
842
843 OutputEvent::ApiCallStarted {
844 call_number,
845 model,
846 tool_count,
847 streaming,
848 context_tokens,
849 tool_tokens,
850 } => {
851 let mode = if streaming { "Streaming" } else { "Non-streaming" };
852 let total = context_tokens + tool_tokens;
853 let _ = self.tx.send(TuiMessage::ContextTokensUpdated(total));
854 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
856 TuiMessage::OutputLine(format!(
857 "🔥 API Call {} | Ctx={}({}+{}) | {} | Model: {} | Tools: {}",
858 call_number, total, context_tokens, tool_tokens, mode, model, tool_count
859 ))
860 }
861
862 OutputEvent::ToolsExecuting { tool_names, hints } => {
863 for (name, hint) in tool_names.into_iter().zip(hints.into_iter()) {
864 let _ = self.tx.send(TuiMessage::ToolPending { tool_name: name, hint });
865 }
866 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
867 return;
868 }
869
870 OutputEvent::ToolCompleted {
871 tool_name,
872 success,
873 content,
874 description,
875 } => {
876 if tool_name == "todowrite" && success {
877 let _ = self.tx.send(TuiMessage::TodoUpdate(content.clone()));
878 }
879 let hint = description;
880 let _ = self.tx.send(TuiMessage::ToolDone { tool_name, success, hint });
881 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
882 return;
883 }
884
885 OutputEvent::Error { message, context } => {
886 if let Some(ctx) = context {
887 TuiMessage::OutputLine(format!("❌ Error: {} — {}", message, ctx))
888 } else {
889 TuiMessage::OutputLine(format!("❌ Error: {}", message))
890 }
891 }
892
893 OutputEvent::ReasoningChunk { delta } => {
894 if delta.is_empty() {
895 return;
896 }
897 let prev = self.stream_state.swap(STREAM_REASONING, Ordering::Relaxed);
898 if prev != STREAM_REASONING {
899 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
900 }
901 let _ = self.tx.send(TuiMessage::ReasoningDelta(delta));
902 return;
903 }
904
905 OutputEvent::McpServerStatus { name, connected, tool_count, error } => {
906 let _ = self.tx.send(TuiMessage::McpServerStatus {
907 name,
908 connected,
909 tool_count,
910 error,
911 });
912 return;
913 }
914 };
915
916 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
917 let _ = self.tx.send(msg);
918 }
919}