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, 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 pub identity: Option<String>,
99}
100
101impl Session {
102 pub fn new() -> (Self, mpsc::UnboundedReceiver<TuiMessage>) {
107 let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
108 let session = Self {
109 input: String::new(),
110 output_lines: Vec::new(),
111 workflow_tx,
112 workflow_state: WorkflowState::Idle,
113 config_toml: None,
114 secrets: None,
115 build_info: None,
116 resume_info: None,
117 backup_resume_info: None,
118 todo_lines: Vec::new(),
119 cancel_token: CancellationToken::new(),
120 pending_command: None,
121 handoff_pending: false,
122 pending_tool_lines: Vec::new(),
123 current_context_tokens: 0,
124 auto_handoff: AutoHandoffConfig::default(),
125 mcp_servers: Vec::new(),
126 should_quit: false,
127 auto_scroll: true,
128 agent_name: "trustee".to_string(),
129 token_store: None,
130 project_id: None,
131 project_name: None,
132 session_id: None,
133 session_name: None,
134 home_dir: None,
135 workflow_permit: None,
136 identity: None,
137 };
138 (session, workflow_rx)
139 }
140
141 pub fn parse_auto_handoff_config(&mut self) {
143 if let Some(ref config_toml) = self.config_toml {
144 self.auto_handoff = crate::config::parse_auto_handoff_config(config_toml);
145 }
146 }
147
148 pub fn handle_workflow_message(&mut self, msg: TuiMessage) {
153 match msg {
154 TuiMessage::WorkflowCancelled => {
155 self.output_lines.push("⏹ Workflow cancelled".to_string());
156 self.output_lines.push("".to_string());
157 self.workflow_state = WorkflowState::Cancelling;
158 }
159 TuiMessage::OutputLine(line) => {
160 self.output_lines.push(line);
161 }
162 TuiMessage::StreamDelta(delta) => {
163 if let Some(last) = self.output_lines.last_mut() {
164 last.push_str(&delta);
165 } else {
166 self.output_lines.push(delta);
167 }
168 }
169 TuiMessage::ReasoningDelta(delta) => {
170 if let Some(last) = self.output_lines.last_mut() {
171 if !last.starts_with('\x01') {
172 last.insert(0, '\x01');
173 }
174 last.push_str(&delta);
175 } else {
176 self.output_lines.push(format!("\x01{}", delta));
177 }
178 }
179 TuiMessage::WorkflowCompleted => {
180 self.output_lines.push("✓ Workflow completed".to_string());
181 self.output_lines.push("".to_string());
182 if self.workflow_state == WorkflowState::Running {
183 self.workflow_state = WorkflowState::Cancelling;
184 }
185 }
186 TuiMessage::WorkflowError(err) => {
187 self.output_lines.push(format!("✗ Error: {}", err));
188 self.output_lines.push("".to_string());
189 if self.workflow_state == WorkflowState::Running {
190 self.workflow_state = WorkflowState::Cancelling;
191 }
192 }
193 TuiMessage::TodoUpdate(content) => {
194 self.todo_lines = content.lines().map(|l| l.to_string()).collect();
195 }
196 TuiMessage::ToolPending { tool_name, hint } => {
197 let label = match &hint {
198 Some(h) => format!("⠋ {} {}", tool_name, h),
199 None => format!("⠋ {}", tool_name),
200 };
201 let idx = self.output_lines.len();
202 self.output_lines.push(label);
203 self.pending_tool_lines.push((tool_name, idx, hint));
204 }
205 TuiMessage::ToolDone { tool_name, success, hint } => {
206 let status = if success { "✓" } else { "✗" };
207 if let Some(pos) = self.pending_tool_lines.iter().position(|(n, _, _)| *n == tool_name) {
208 let (_, idx, pending_hint) = self.pending_tool_lines.remove(pos);
209 let h = hint.or(pending_hint);
210 let label = match &h {
211 Some(h) => format!("{} {} {}", status, tool_name, h),
212 None => format!("{} {}", status, tool_name),
213 };
214 if idx < self.output_lines.len() {
215 self.output_lines[idx] = label;
216 return;
217 }
218 self.output_lines.push(label);
219 } else {
220 let label = match &hint {
221 Some(h) => format!("{} {} {}", status, tool_name, h),
222 None => format!("{} {}", status, tool_name),
223 };
224 self.output_lines.push(label);
225 }
226 }
227 TuiMessage::ResumeInfo(info) => {
228 if self.workflow_state == WorkflowState::Cancelling && info.is_none() {
229 self.resume_info = self.backup_resume_info.take();
230 } else if info.is_some() {
231 self.resume_info = info;
237 self.backup_resume_info = None;
238 }
239 if let Some(ref ri) = self.resume_info {
245 if self.session_id.is_none() {
246 self.session_id = Some(ri.session_id.clone());
247 }
248 }
249
250 if self.workflow_state == WorkflowState::Cancelling {
251 self.workflow_state = WorkflowState::Idle;
252 self.workflow_permit = None;
254 }
255 if self.resume_info.is_some() {
256 if std::env::var("RUST_LOG")
257 .map(|v| v.to_lowercase().contains("debug"))
258 .unwrap_or(false)
259 {
260 self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
261 }
262 }
263 if self.workflow_state == WorkflowState::Idle && self.handoff_pending {
264 self.handoff_pending = false;
265 self.trigger_handoff(String::new());
266 } else if let Some(cmd) = self.pending_command.take() {
267 self.input = cmd;
268 self.execute_command();
269 }
270 }
271 TuiMessage::ContextTokensUpdated(count) => {
272 self.current_context_tokens = count;
273 if self.auto_handoff.enabled
274 && count >= self.auto_handoff.context_threshold
275 && self.workflow_state == WorkflowState::Running
276 && !self.handoff_pending
277 && self.resume_info.is_some()
278 {
279 self.handoff_pending = true;
280 self.cancel_token.cancel();
281 self.workflow_state = WorkflowState::Cancelling;
282 self.output_lines.push(format!(
283 "🔄 Auto-handoff: cancelling workflow, context tokens ({}) ≥ threshold ({})",
284 count, self.auto_handoff.context_threshold
285 ));
286 }
287 }
288 TuiMessage::McpServerStatus { name, connected, tool_count, error } => {
289 let status = if connected { McpServerStatus::Connected } else { McpServerStatus::Failed };
290 if let Some(existing) = self.mcp_servers.iter_mut().find(|s| s.name == name) {
291 existing.status = status;
292 existing.tool_count = tool_count;
293 existing.error = error;
294 } else {
295 self.mcp_servers.push(McpServerInfo { name, status, tool_count, error });
296 }
297 }
298 TuiMessage::HandoffReady(briefing) => {
299 self.workflow_state = WorkflowState::Idle;
300 self.resume_info = None;
301 self.session_id = None;
304 self.session_name = None;
305 self.input = briefing;
306 self.execute_command();
307 }
308 TuiMessage::HandoffFailed => {
309 self.workflow_state = WorkflowState::Idle;
313 self.output_lines.push(
314 "✗ Handoff briefing unavailable — session preserved, try again.".to_string(),
315 );
316 self.output_lines.push("".to_string());
317 }
318 TuiMessage::SessionTitleUpdated(title) => {
319 self.session_name = Some(title);
321 }
322 }
323 if self.auto_scroll {
324 }
327 }
328
329 pub fn execute_command(&mut self) {
334 let command = self.input.trim().to_string();
335
336 if self.workflow_state != WorkflowState::Idle {
337 self.pending_command = Some(command);
338 self.output_lines.push("⏳ Previous workflow finishing — command queued".to_string());
339 self.input.clear();
340 return;
341 }
342
343 let is_continuation = self.resume_info.is_some();
344
345 if !is_continuation {
346 self.output_lines.clear();
347 if self.session_id.is_none() {
353 let timestamp = chrono::Utc::now().format("%Y_%m_%d_%H_%M");
354 let uuid_suffix = uuid::Uuid::new_v4().simple().to_string();
355 let uuid8 = &uuid_suffix[..8];
356 self.session_id = Some(format!("session_{}_{}", timestamp, uuid8));
357 }
358 if self.session_name.is_none() {
359 let derived = if command.len() > 80 {
360 format!("{}...", &command[..77])
361 } else {
362 command.clone()
363 };
364 self.session_name = Some(derived);
365 }
366 }
367
368 self.output_lines.push(format!("> {}", command));
369
370 let config_toml = match &self.config_toml {
371 Some(c) => c.clone(),
372 None => {
373 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
374 self.output_lines.push("".to_string());
375 return;
376 }
377 };
378
379 let config_toml = inject_identity(config_toml, &self.identity);
381
382 let secrets = self.secrets.clone().unwrap_or_default();
383 let title_secrets = secrets.clone();
385 let build_info = self.build_info.clone();
386 let tx = self.workflow_tx.clone();
387
388 let agent_name = self.agent_name.clone();
389 let token_store = self.token_store.clone();
390 let project_id = self.project_id.clone();
391 let project_name = self.project_name.clone();
392 let session_id = self.session_id.clone();
393 let session_name = self.session_name.clone();
394 let home_dir = self.home_dir.clone();
395
396 self.backup_resume_info = self.resume_info.clone();
397 let resume_info = self.resume_info.take();
398
399 self.workflow_state = WorkflowState::Running;
400 self.auto_scroll = true;
401
402 self.cancel_token = CancellationToken::new();
403 let child_token = self.cancel_token.clone();
404
405 let (resume_tx, mut resume_rx) = mpsc::unbounded_channel();
406
407 let resume_forward_tx = tx.clone();
408 tokio::spawn(async move {
409 while let Some(info) = resume_rx.recv().await {
410 resume_forward_tx.send(TuiMessage::ResumeInfo(info)).ok();
411 }
412 });
413
414 tokio::spawn(async move {
415 let tui_sink: abk::orchestration::output::SharedSink =
416 Arc::new(crate::session::TuiForwardSink::new(tx.clone()));
417
418 let mut run_ctx = RunContext::new()
420 .with_agent_name(agent_name.clone());
421
422 if let Some(ref dir) = home_dir {
424 run_ctx = run_ctx.with_home_dir(dir.clone());
425 }
426
427 if project_id.is_some() || project_name.is_some() {
429 run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
430 id: project_id.unwrap_or_else(|| "default".to_string()),
431 name: project_name,
432 });
433 }
434
435 if session_id.is_some() || session_name.is_some() {
437 run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
438 id: session_id.unwrap_or_else(|| "default".to_string()),
439 name: session_name,
440 });
441 }
442
443 #[cfg(feature = "registry-mcp-token")]
444 {
445 if let Some(ref ts) = token_store {
446 run_ctx = run_ctx.with_token_store(ts.clone());
447 }
448 }
449
450 let scope_logger = {
455 abk::observability::Logger::with_agent_name(
456 None::<&std::path::Path>,
457 Some("INFO"),
458 Some(&agent_name),
459 ).unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap())
460 };
461
462 let result = abk::observability::with_logger(scope_logger, async {
463 abk::observability::with_tui_mode(true, async {
464 abk::cli::run_task_from_raw_config(
465 &config_toml,
466 secrets,
467 build_info,
468 &command,
469 Some(tui_sink),
470 resume_info,
471 Some(resume_tx),
472 Some(child_token),
473 Some(&run_ctx),
474 )
475 .await
476 })
477 .await
478 })
479 .await;
480
481 let task_result = result.unwrap_or_else(|e| abk::cli::TaskResult {
482 success: false,
483 error: Some(e.to_string()),
484 resume_info: None,
489 });
490
491 let msg = if task_result.success {
492 TuiMessage::WorkflowCompleted
493 } else {
494 TuiMessage::WorkflowError(task_result.error.unwrap_or_default())
495 };
496
497 let title_session_id = task_result.resume_info
499 .as_ref()
500 .map(|ri| ri.session_id.clone());
501
502 tx.send(msg).ok();
503 tx.send(TuiMessage::ResumeInfo(task_result.resume_info)).ok();
504
505 if task_result.success {
512 let title_tx = tx.clone();
513 let title_config = config_toml.clone();
514 let title_command = command.clone();
515 let title_ctx = run_ctx.clone();
516
517 tokio::spawn(async move {
518 if let Some(ref sid) = title_session_id {
522 if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
523 return;
524 }
525 } else {
526 return; }
528
529 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
531
532 if let Some(ref sid) = title_session_id {
534 if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
535 return;
536 }
537 }
538
539 match abk::cli::generate_session_title(
540 &title_config,
541 title_secrets,
542 &title_command,
543 )
544 .await
545 {
546 Ok(Some(title)) => {
547 if let Some(ref sid) = title_session_id {
549 if let Err(e) = abk::cli::persist_session_title(
550 &title_ctx,
551 &title_config,
552 sid,
553 &title,
554 ).await {
555 let _ = e; }
557 }
558 title_tx.send(TuiMessage::SessionTitleUpdated(title)).ok();
560 }
561 Ok(None) => {}
562 Err(e) => { let _ = e; }
563 }
564 });
565 }
566 });
567
568 self.input.clear();
569 }
570
571 pub fn request_handoff(&mut self, hint: String) {
582 match self.workflow_state {
583 WorkflowState::Idle => self.trigger_handoff(hint),
584 WorkflowState::Running => {
585 self.cancel_token.cancel();
586 self.workflow_state = WorkflowState::Cancelling;
587 self.handoff_pending = true;
588 self.workflow_tx
593 .send(TuiMessage::OutputLine("⏹ Cancelling before handoff...".to_string()))
594 .ok();
595 }
596 WorkflowState::Cancelling => {
597 self.handoff_pending = true;
598 }
599 }
600 }
601
602 pub fn trigger_handoff(&mut self, hint: String) {
610 if self.workflow_state != WorkflowState::Idle {
614 self.output_lines
615 .push("⏳ Workflow still running — handoff will fire after it stops".to_string());
616 return;
617 }
618 if self.resume_info.is_none() {
619 self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
620 return;
621 }
622
623 let config_toml = match &self.config_toml {
624 Some(c) => c.clone(),
625 None => {
626 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
627 return;
628 }
629 };
630
631 let config_toml = inject_identity(config_toml, &self.identity);
633
634 let tx = self.workflow_tx.clone();
635
636 let agent_name = self.agent_name.clone();
637 let project_id = self.project_id.clone();
638 let project_name = self.project_name.clone();
639 let session_id = self.session_id.clone();
640 let session_name = self.session_name.clone();
641 let home_dir = self.home_dir.clone();
642
643 let backup_resume_info = self.resume_info.clone();
646 let resume_info = match self.resume_info.take() {
647 Some(ri) => ri,
648 None => {
649 self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
651 return;
652 }
653 };
654
655 self.workflow_state = WorkflowState::Running;
656 self.auto_scroll = true;
657 self.cancel_token = CancellationToken::new();
658
659 self.workflow_tx
666 .send(TuiMessage::OutputLine("🔀 Generating session handoff briefing...".to_string()))
667 .ok();
668
669 tokio::spawn(async move {
670 let base = "Output a session handoff briefing in at most 300 lines. \
671 Do NOT use any tools. Include: the FULL ABSOLUTE PATH of every \
672 project/repository being worked on (e.g. /Projects/Foo/bar — never \
673 omit the leading path), all project/task/workstream UUIDs referenced, \
674 every file created or modified with its full absolute path, all \
675 commands run and their outcomes, the current state of the work, any \
676 blockers, and the exact next action to take. \
677 Output ONLY the briefing text — no preamble, headers, or closing remarks.";
678 let prompt = if hint.is_empty() {
679 base.to_string()
680 } else {
681 format!("{base}\n\nIn the briefing also consider: {hint}")
682 };
683
684 let mut run_ctx = RunContext::new()
686 .with_agent_name(agent_name.clone());
687
688 if let Some(ref dir) = home_dir {
690 run_ctx = run_ctx.with_home_dir(dir.clone());
691 }
692
693 if project_id.is_some() || project_name.is_some() {
695 run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
696 id: project_id.unwrap_or_else(|| "default".to_string()),
697 name: project_name,
698 });
699 }
700
701 if session_id.is_some() || session_name.is_some() {
703 run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
704 id: session_id.unwrap_or_else(|| "default".to_string()),
705 name: session_name,
706 });
707 }
708
709 let scope_logger = abk::observability::Logger::with_agent_name(
711 None,
712 Some("INFO"),
713 Some(&agent_name),
714 ).unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap());
715
716 let result = abk::observability::with_logger(scope_logger, async {
720 abk::observability::with_tui_mode(true, async {
721 abk::cli::generate_handoff_briefing(
722 &config_toml,
723 &run_ctx,
724 &resume_info,
725 &prompt,
726 )
727 .await
728 })
729 .await
730 })
731 .await;
732
733 match result {
734 Ok(briefing) if !briefing.trim().is_empty() => {
735 tx.send(TuiMessage::HandoffReady(briefing)).ok();
736 }
737 _ => {
738 tx.send(TuiMessage::ResumeInfo(backup_resume_info)).ok();
742 tx.send(TuiMessage::HandoffFailed).ok();
743 }
744 }
745 });
746 }
747}
748
749impl Default for Session {
750 fn default() -> Self {
751 Self::new().0
752 }
753}
754
755fn inject_identity(config_toml: String, identity: &Option<String>) -> String {
760 let Some(identity) = identity.as_ref().filter(|s| !s.is_empty()) else {
761 return config_toml;
762 };
763
764 let Ok(mut table) = config_toml.parse::<toml::Value>() else {
765 return config_toml;
766 };
767
768 let lifecycle = table
769 .get_mut("lifecycle")
770 .and_then(|v| v.as_table_mut());
771
772 if let Some(lifecycle) = lifecycle {
773 let existing = lifecycle
774 .get("system_template")
775 .and_then(|v| v.as_str())
776 .unwrap_or("");
777 let combined = format!("{}\n\n{}", identity, existing);
778 lifecycle.insert(
779 "system_template".to_string(),
780 toml::Value::String(combined),
781 );
782 } else {
783 let mut ltable = toml::value::Table::new();
785 ltable.insert(
786 "system_template".to_string(),
787 toml::Value::String(identity.clone()),
788 );
789 if let Some(table) = table.as_table_mut() {
790 table.insert("lifecycle".to_string(), toml::Value::Table(ltable));
791 }
792 }
793
794 toml::to_string(&table).unwrap_or(config_toml)
795}
796
797pub struct TuiForwardSink {
803 tx: mpsc::UnboundedSender<TuiMessage>,
804 stream_state: AtomicU8,
805}
806
807const STREAM_IDLE: u8 = 0;
809const STREAM_REASONING: u8 = 1;
810const STREAM_CONTENT: u8 = 2;
811
812impl TuiForwardSink {
813 pub fn new(tx: mpsc::UnboundedSender<TuiMessage>) -> Self {
814 Self {
815 tx,
816 stream_state: AtomicU8::new(STREAM_IDLE),
817 }
818 }
819}
820
821impl abk::orchestration::output::OutputSink for TuiForwardSink {
822 fn emit(&self, event: abk::orchestration::output::OutputEvent) {
823 use abk::orchestration::output::OutputEvent;
824
825 let msg = match event {
826 OutputEvent::StreamingChunk { delta } => {
827 if delta.is_empty() {
828 return;
829 }
830 let prev = self.stream_state.swap(STREAM_CONTENT, Ordering::Relaxed);
831 if prev != STREAM_CONTENT {
832 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
833 }
834 let _ = self.tx.send(TuiMessage::StreamDelta(delta));
835 return;
836 }
837
838 OutputEvent::LlmResponse { text, model } => {
839 TuiMessage::OutputLine(format!("[{}] {}", model, text))
840 }
841
842 OutputEvent::Info { message } => {
843 if message.contains("API call completed successfully") {
845 return;
846 }
847 TuiMessage::OutputLine(message)
848 }
849
850 OutputEvent::WorkflowStarted { task_description } => {
851 TuiMessage::OutputLine(format!("🚀 Workflow started: {}", task_description))
852 }
853
854 OutputEvent::WorkflowCompleted { reason, iterations } => {
855 TuiMessage::OutputLine(format!(
856 "✅ Workflow completed after {} iterations: {}",
857 iterations, reason
858 ))
859 }
860
861 OutputEvent::IterationStarted { iteration, context_tokens } => {
862 let _ = self.tx.send(TuiMessage::ContextTokensUpdated(context_tokens));
863 TuiMessage::OutputLine(format!(
864 "📡 Iteration {} | Context = {} tokens",
865 iteration, context_tokens
866 ))
867 }
868
869 OutputEvent::ApiCallStarted {
870 call_number,
871 model,
872 tool_count,
873 streaming,
874 context_tokens,
875 tool_tokens,
876 } => {
877 let mode = if streaming { "Streaming" } else { "Non-streaming" };
878 let total = context_tokens + tool_tokens;
879 let _ = self.tx.send(TuiMessage::ContextTokensUpdated(total));
880 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
882 TuiMessage::OutputLine(format!(
883 "🔥 API Call {} | Ctx={}({}+{}) | {} | Model: {} | Tools: {}",
884 call_number, total, context_tokens, tool_tokens, mode, model, tool_count
885 ))
886 }
887
888 OutputEvent::ToolsExecuting { tool_names, hints } => {
889 for (name, hint) in tool_names.into_iter().zip(hints.into_iter()) {
890 let _ = self.tx.send(TuiMessage::ToolPending { tool_name: name, hint });
891 }
892 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
893 return;
894 }
895
896 OutputEvent::ToolCompleted {
897 tool_name,
898 success,
899 content,
900 description,
901 } => {
902 if tool_name == "todowrite" && success {
903 let _ = self.tx.send(TuiMessage::TodoUpdate(content.clone()));
904 }
905 let hint = description;
906 let _ = self.tx.send(TuiMessage::ToolDone { tool_name, success, hint });
907 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
908 return;
909 }
910
911 OutputEvent::Error { message, context } => {
912 if let Some(ctx) = context {
913 TuiMessage::OutputLine(format!("❌ Error: {} — {}", message, ctx))
914 } else {
915 TuiMessage::OutputLine(format!("❌ Error: {}", message))
916 }
917 }
918
919 OutputEvent::ReasoningChunk { delta } => {
920 if delta.is_empty() {
921 return;
922 }
923 let prev = self.stream_state.swap(STREAM_REASONING, Ordering::Relaxed);
924 if prev != STREAM_REASONING {
925 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
926 }
927 let _ = self.tx.send(TuiMessage::ReasoningDelta(delta));
928 return;
929 }
930
931 OutputEvent::McpServerStatus { name, connected, tool_count, error } => {
932 let _ = self.tx.send(TuiMessage::McpServerStatus {
933 name,
934 connected,
935 tool_count,
936 error,
937 });
938 return;
939 }
940 };
941
942 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
943 let _ = self.tx.send(msg);
944 }
945}