Skip to main content

trustee_core/
session.rs

1//! Session state — core agent session without any UI concerns.
2//!
3//! This struct holds all the state shared between frontends (TUI, API, Web):
4//! output lines, input, workflow state, config, resume info, MCP servers, etc.
5
6use 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
22/// Core session state for the Trustee agent.
23///
24/// Holds all state that is independent of the presentation layer (TUI, API, Web).
25/// Frontend crates compose this struct and add their own UI-specific fields.
26pub struct Session {
27    /// Input buffer for user commands
28    pub input: String,
29    /// Output log lines
30    pub output_lines: Vec<String>,
31    /// Sender for messages from async workflows (clone and pass to workflow runners)
32    pub workflow_tx: mpsc::UnboundedSender<TuiMessage>,
33    /// Current workflow lifecycle state
34    pub workflow_state: WorkflowState,
35    /// Configuration TOML for ABK workflows
36    pub config_toml: Option<String>,
37    /// Secrets for ABK workflows
38    pub secrets: Option<HashMap<String, String>>,
39    /// Build info for ABK workflows
40    pub build_info: Option<BuildInfo>,
41    /// Resume info from the last completed task for session continuity
42    pub resume_info: Option<ResumeInfo>,
43    /// Saved resume_info before execute_command consumes it; restored if task
44    /// is cancelled before producing a real checkpoint (mistake-ENTER recovery).
45    pub backup_resume_info: Option<ResumeInfo>,
46    /// Latest todo list from LLM todowrite tool
47    pub todo_lines: Vec<String>,
48    /// Cancellation token for aborting the current workflow
49    pub cancel_token: CancellationToken,
50    /// Command buffered by user during cancellation wind-down.
51    pub pending_command: Option<String>,
52    /// Whether a session handoff (Ctrl+H) should fire once the current workflow cancels.
53    pub handoff_pending: bool,
54    /// In-flight spinner entries: (tool_name, output_lines_index, hint).
55    pub pending_tool_lines: Vec<(String, usize, Option<String>)>,
56    /// Current context token count (updated from ApiCallStarted events).
57    pub current_context_tokens: usize,
58    /// Auto-handoff configuration parsed from [tui.auto_handoff].
59    pub auto_handoff: AutoHandoffConfig,
60    /// MCP server statuses received from agent init
61    pub mcp_servers: Vec<McpServerInfo>,
62    /// Whether the session should quit
63    pub should_quit: bool,
64    /// Whether auto-scroll is enabled (follows new output)
65    pub auto_scroll: bool,
66
67    // --- TMU Phase 1: Stateless Core ---
68    /// Agent name for checkpoint/token paths (replaces ABK_AGENT_NAME env var).
69    /// Defaults to "trustee". Set from config at startup.
70    pub agent_name: String,
71    /// Per-session token store (None = FileTokenStore fallback).
72    /// When set, MCP credential flows use this instead of file-based storage.
73    pub token_store: Option<Arc<dyn pep::token_store::TokenStore>>,
74
75    // --- Project/Session Identity (backward compatible, all None = old behavior) ---
76    /// Storage partition key (replaces path hash). None = hash(working_dir)
77    pub project_id: Option<String>,
78    /// Human-readable project name. None = directory name
79    pub project_name: Option<String>,
80    /// Storage directory name (replaces timestamp slug). None = auto-generate
81    pub session_id: Option<String>,
82    /// Human-readable session name. None = no description
83    pub session_name: Option<String>,
84
85    /// Concurrency permit — held while a workflow is running.
86    /// When the workflow completes (state → Idle), this is dropped,
87    /// releasing the permit back to the global semaphore.
88    /// None when no workflow is running or when concurrency limiting is disabled.
89    pub workflow_permit: Option<tokio::sync::OwnedSemaphorePermit>,
90}
91
92impl Session {
93    /// Create a new Session with default state and a fresh message channel.
94    ///
95    /// Returns `(Session, Receiver)` so the caller can own the receiver
96    /// without locking the session (prevents deadlock in async drain loops).
97    pub fn new() -> (Self, mpsc::UnboundedReceiver<TuiMessage>) {
98        let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
99        let session = Self {
100            input: String::new(),
101            output_lines: Vec::new(),
102            workflow_tx,
103            workflow_state: WorkflowState::Idle,
104            config_toml: None,
105            secrets: None,
106            build_info: None,
107            resume_info: None,
108            backup_resume_info: None,
109            todo_lines: Vec::new(),
110            cancel_token: CancellationToken::new(),
111            pending_command: None,
112            handoff_pending: false,
113            pending_tool_lines: Vec::new(),
114            current_context_tokens: 0,
115            auto_handoff: AutoHandoffConfig::default(),
116            mcp_servers: Vec::new(),
117            should_quit: false,
118            auto_scroll: true,
119            agent_name: "trustee".to_string(),
120            token_store: None,
121            project_id: None,
122            project_name: None,
123            session_id: None,
124            session_name: None,
125            workflow_permit: None,
126        };
127        (session, workflow_rx)
128    }
129
130    /// Parse auto-handoff configuration from the stored config TOML.
131    pub fn parse_auto_handoff_config(&mut self) {
132        if let Some(ref config_toml) = self.config_toml {
133            self.auto_handoff = crate::config::parse_auto_handoff_config(config_toml);
134        }
135    }
136
137    /// Handle messages from async workflows.
138    ///
139    /// This processes all workflow lifecycle events, output updates, and state transitions.
140    /// Returns `true` if the caller should check for pending commands/handoffs after.
141    pub fn handle_workflow_message(&mut self, msg: TuiMessage) {
142        match msg {
143            TuiMessage::WorkflowCancelled => {
144                self.output_lines.push("⏹ Workflow cancelled".to_string());
145                self.output_lines.push("".to_string());
146                self.workflow_state = WorkflowState::Cancelling;
147            }
148            TuiMessage::OutputLine(line) => {
149                self.output_lines.push(line);
150            }
151            TuiMessage::StreamDelta(delta) => {
152                if let Some(last) = self.output_lines.last_mut() {
153                    last.push_str(&delta);
154                } else {
155                    self.output_lines.push(delta);
156                }
157            }
158            TuiMessage::ReasoningDelta(delta) => {
159                if let Some(last) = self.output_lines.last_mut() {
160                    if !last.starts_with('\x01') {
161                        last.insert(0, '\x01');
162                    }
163                    last.push_str(&delta);
164                } else {
165                    self.output_lines.push(format!("\x01{}", delta));
166                }
167            }
168            TuiMessage::WorkflowCompleted => {
169                self.output_lines.push("✓ Workflow completed".to_string());
170                self.output_lines.push("".to_string());
171                if self.workflow_state == WorkflowState::Running {
172                    self.workflow_state = WorkflowState::Cancelling;
173                }
174            }
175            TuiMessage::WorkflowError(err) => {
176                self.output_lines.push(format!("✗ Error: {}", err));
177                self.output_lines.push("".to_string());
178                if self.workflow_state == WorkflowState::Running {
179                    self.workflow_state = WorkflowState::Cancelling;
180                }
181            }
182            TuiMessage::TodoUpdate(content) => {
183                self.todo_lines = content.lines().map(|l| l.to_string()).collect();
184            }
185            TuiMessage::ToolPending { tool_name, hint } => {
186                let label = match &hint {
187                    Some(h) => format!("⠋ {} {}", tool_name, h),
188                    None => format!("⠋ {}", tool_name),
189                };
190                let idx = self.output_lines.len();
191                self.output_lines.push(label);
192                self.pending_tool_lines.push((tool_name, idx, hint));
193            }
194            TuiMessage::ToolDone { tool_name, success, hint } => {
195                let status = if success { "✓" } else { "✗" };
196                if let Some(pos) = self.pending_tool_lines.iter().position(|(n, _, _)| *n == tool_name) {
197                    let (_, idx, pending_hint) = self.pending_tool_lines.remove(pos);
198                    let h = hint.or(pending_hint);
199                    let label = match &h {
200                        Some(h) => format!("{} {} {}", status, tool_name, h),
201                        None => format!("{} {}", status, tool_name),
202                    };
203                    if idx < self.output_lines.len() {
204                        self.output_lines[idx] = label;
205                        return;
206                    }
207                    self.output_lines.push(label);
208                } else {
209                    let label = match &hint {
210                        Some(h) => format!("{} {} {}", status, tool_name, h),
211                        None => format!("{} {}", status, tool_name),
212                    };
213                    self.output_lines.push(label);
214                }
215            }
216            TuiMessage::ResumeInfo(info) => {
217                if self.workflow_state == WorkflowState::Cancelling && info.is_none() {
218                    self.resume_info = self.backup_resume_info.take();
219                } else if info.is_some() {
220                    // Only overwrite with a valid resume_info.
221                    // Don't let ResumeInfo(None) clobber a valid Some that was
222                    // set by an earlier incremental checkpoint message — this
223                    // happens when the error/cancel path fails to create a
224                    // final checkpoint but earlier checkpoints exist.
225                    self.resume_info = info;
226                    self.backup_resume_info = None;
227                }
228                // If info is None and we're not cancelling, keep existing resume_info.
229
230                // Capture session_id from resume_info so it persists for the
231                // lifetime of this conversation. This is immutable — once ABK
232                // assigns it, we never change it.
233                if let Some(ref ri) = self.resume_info {
234                    if self.session_id.is_none() {
235                        self.session_id = Some(ri.session_id.clone());
236                    }
237                }
238
239                if self.workflow_state == WorkflowState::Cancelling {
240                    self.workflow_state = WorkflowState::Idle;
241                    // Release the concurrency permit when the workflow finishes.
242                    self.workflow_permit = None;
243                }
244                if self.resume_info.is_some() {
245                    if std::env::var("RUST_LOG")
246                        .map(|v| v.to_lowercase().contains("debug"))
247                        .unwrap_or(false)
248                    {
249                        self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
250                    }
251                }
252                if self.workflow_state == WorkflowState::Idle && self.handoff_pending {
253                    self.handoff_pending = false;
254                    self.trigger_handoff(String::new());
255                } else if let Some(cmd) = self.pending_command.take() {
256                    self.input = cmd;
257                    self.execute_command();
258                }
259            }
260            TuiMessage::ContextTokensUpdated(count) => {
261                self.current_context_tokens = count;
262                if self.auto_handoff.enabled
263                    && count >= self.auto_handoff.context_threshold
264                    && self.workflow_state == WorkflowState::Running
265                    && !self.handoff_pending
266                    && self.resume_info.is_some()
267                {
268                    self.handoff_pending = true;
269                    self.cancel_token.cancel();
270                    self.workflow_state = WorkflowState::Cancelling;
271                    self.output_lines.push(format!(
272                        "🔄 Auto-handoff: cancelling workflow, context tokens ({}) ≥ threshold ({})",
273                        count, self.auto_handoff.context_threshold
274                    ));
275                }
276            }
277            TuiMessage::McpServerStatus { name, connected, tool_count, error } => {
278                let status = if connected { McpServerStatus::Connected } else { McpServerStatus::Failed };
279                if let Some(existing) = self.mcp_servers.iter_mut().find(|s| s.name == name) {
280                    existing.status = status;
281                    existing.tool_count = tool_count;
282                    existing.error = error;
283                } else {
284                    self.mcp_servers.push(McpServerInfo { name, status, tool_count, error });
285                }
286            }
287            TuiMessage::HandoffReady(briefing) => {
288                self.workflow_state = WorkflowState::Idle;
289                self.resume_info = None;
290                self.input = briefing;
291                self.execute_command();
292            }
293        }
294        if self.auto_scroll {
295            // Signal to frontend that it should scroll to bottom.
296            // Frontend reads auto_scroll flag directly.
297        }
298    }
299
300    /// Execute the current command in the input buffer.
301    ///
302    /// Spawns an async ABK workflow task, clears the input buffer, and sets
303    /// workflow_state to Running.
304    pub fn execute_command(&mut self) {
305        let command = self.input.trim().to_string();
306
307        if self.workflow_state != WorkflowState::Idle {
308            self.pending_command = Some(command);
309            self.output_lines.push("⏳ Previous workflow finishing — command queued".to_string());
310            self.input.clear();
311            return;
312        }
313
314        let is_continuation = self.resume_info.is_some();
315
316        if !is_continuation {
317            self.output_lines.clear();
318            // Auto-derive session_name from the first command if not explicitly set.
319            // Truncate to 80 chars for a reasonable display name.
320            if self.session_name.is_none() {
321                let derived = if command.len() > 80 {
322                    format!("{}...", &command[..77])
323                } else {
324                    command.clone()
325                };
326                self.session_name = Some(derived);
327            }
328        }
329
330        self.output_lines.push(format!("> {}", command));
331
332        let config_toml = match &self.config_toml {
333            Some(c) => c.clone(),
334            None => {
335                self.output_lines.push("✗ Error: Configuration not loaded".to_string());
336                self.output_lines.push("".to_string());
337                return;
338            }
339        };
340
341        let secrets = self.secrets.clone().unwrap_or_default();
342        let build_info = self.build_info.clone();
343        let tx = self.workflow_tx.clone();
344
345        let agent_name = self.agent_name.clone();
346        let token_store = self.token_store.clone();
347        let project_id = self.project_id.clone();
348        let project_name = self.project_name.clone();
349        let session_id = self.session_id.clone();
350        let session_name = self.session_name.clone();
351
352        self.backup_resume_info = self.resume_info.clone();
353        let resume_info = self.resume_info.take();
354
355        self.workflow_state = WorkflowState::Running;
356        self.auto_scroll = true;
357
358        self.cancel_token = CancellationToken::new();
359        let child_token = self.cancel_token.clone();
360
361        let (resume_tx, mut resume_rx) = mpsc::unbounded_channel();
362
363        let resume_forward_tx = tx.clone();
364        tokio::spawn(async move {
365            while let Some(info) = resume_rx.recv().await {
366                resume_forward_tx.send(TuiMessage::ResumeInfo(info)).ok();
367            }
368        });
369
370        tokio::spawn(async move {
371            let tui_sink: abk::orchestration::output::SharedSink =
372                Arc::new(crate::session::TuiForwardSink::new(tx.clone()));
373
374            // Build RunContext from session fields for stateless operation
375            let mut run_ctx = RunContext::new()
376                .with_agent_name(agent_name.clone());
377
378            // Set project identity if any field is provided
379            if project_id.is_some() || project_name.is_some() {
380                run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
381                    id: project_id.unwrap_or_else(|| "default".to_string()),
382                    name: project_name,
383                });
384            }
385
386            // Set session identity if any field is provided
387            if session_id.is_some() || session_name.is_some() {
388                run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
389                    id: session_id.unwrap_or_else(|| "default".to_string()),
390                    name: session_name,
391                });
392            }
393
394            #[cfg(feature = "registry-mcp-token")]
395            {
396                if let Some(ref ts) = token_store {
397                    run_ctx = run_ctx.with_token_store(ts.clone());
398                }
399            }
400
401            // Run the entire workflow inside a TUI-mode scope.
402            // This replaces the old set_tui_mode(true)/set_tui_mode(false)
403            // process-global mutations with a task-local scope.
404            let result = abk::observability::with_tui_mode(true, async {
405                abk::cli::run_task_from_raw_config(
406                    &config_toml,
407                    secrets,
408                    build_info,
409                    &command,
410                    Some(tui_sink),
411                    resume_info,
412                    Some(resume_tx),
413                    Some(child_token),
414                    Some(&run_ctx),
415                )
416                .await
417            })
418            .await;
419
420            let task_result = result.unwrap_or_else(|e| abk::cli::TaskResult {
421                success: false,
422                error: Some(e.to_string()),
423                // resume_info will be None here, but the on_checkpoint channel
424                // may have already delivered a valid ResumeInfo via TuiMessage.
425                // The ResumeInfo handler now ignores None when a valid Some exists,
426                // so this None won't clobber the earlier incremental checkpoint.
427                resume_info: None,
428            });
429
430            let msg = if task_result.success {
431                TuiMessage::WorkflowCompleted
432            } else {
433                TuiMessage::WorkflowError(task_result.error.unwrap_or_default())
434            };
435            tx.send(msg).ok();
436            tx.send(TuiMessage::ResumeInfo(task_result.resume_info)).ok();
437        });
438
439        self.input.clear();
440    }
441
442    /// Trigger a session handoff.
443    ///
444    /// Runs a single LLM call using the current session's resume_info to generate
445    /// a briefing. On completion, sends `TuiMessage::HandoffReady(briefing)`.
446    pub fn trigger_handoff(&mut self, hint: String) {
447        if self.resume_info.is_none() {
448            self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
449            return;
450        }
451
452        let config_toml = match &self.config_toml {
453            Some(c) => c.clone(),
454            None => {
455                self.output_lines.push("✗ Error: Configuration not loaded".to_string());
456                return;
457            }
458        };
459
460        let secrets = self.secrets.clone().unwrap_or_default();
461        let build_info = self.build_info.clone();
462        let tx = self.workflow_tx.clone();
463
464        let agent_name = self.agent_name.clone();
465        let token_store = self.token_store.clone();
466
467        let resume_info = self.resume_info.take();
468
469        self.workflow_state = WorkflowState::Running;
470        self.auto_scroll = true;
471        self.cancel_token = CancellationToken::new();
472        let child_token = self.cancel_token.clone();
473
474        self.output_lines.push("🔀 Generating session handoff briefing...".to_string());
475
476        tokio::spawn(async move {
477            let (cap_tx, mut cap_rx) = mpsc::unbounded_channel::<CapturedText>();
478            let cap_sink: abk::orchestration::output::SharedSink =
479                Arc::new(HandoffCaptureSink::new(cap_tx, child_token.clone()));
480
481            let base = "Output a session handoff briefing in at most 300 lines. \
482                 Do NOT use any tools. Include: the FULL ABSOLUTE PATH of every \
483                 project/repository being worked on (e.g. /Projects/Foo/bar — never \
484                 omit the leading path), all project/task/workstream UUIDs referenced, \
485                 every file created or modified with its full absolute path, all \
486                 commands run and their outcomes, the current state of the work, any \
487                 blockers, and the exact next action to take. \
488                 Output ONLY the briefing text — no preamble, headers, or closing remarks.";
489            let prompt = if hint.is_empty() {
490                base.to_string()
491            } else {
492                format!("{base}\n\nIn the briefing also consider: {hint}")
493            };
494
495            let (dummy_tx, _dummy_rx) = mpsc::unbounded_channel();
496
497            // Build RunContext for stateless operation
498            let run_ctx = RunContext::new()
499                .with_agent_name(agent_name.clone());
500            #[cfg(feature = "registry-mcp-token")]
501            {
502                // Note: token_store not available in handoff — handoffs don't
503                // need MCP credentials, so we skip it here.
504            }
505
506            // Run inside TUI-mode scope (task-local, not process-global)
507            let _res = abk::observability::with_tui_mode(true, async {
508                abk::cli::run_task_from_raw_config(
509                    &config_toml,
510                    secrets,
511                    build_info,
512                    &prompt,
513                    Some(cap_sink),
514                    resume_info,
515                    Some(dummy_tx),
516                    Some(child_token),
517                    Some(&run_ctx),
518                )
519                .await
520            })
521            .await;
522
523            let mut text_parts = String::new();
524            let mut reasoning_parts = String::new();
525            while let Ok(captured) = cap_rx.try_recv() {
526                match captured {
527                    CapturedText::Text(s) => text_parts.push_str(&s),
528                    CapturedText::Reasoning(s) => reasoning_parts.push_str(&s),
529                }
530            }
531
532            let briefing = if !text_parts.trim().is_empty() {
533                text_parts.trim().to_string()
534            } else if !reasoning_parts.trim().is_empty() {
535                reasoning_parts.trim().to_string()
536            } else {
537                "Session handoff: briefing unavailable — continue from previous context.".to_string()
538            };
539
540            tx.send(TuiMessage::HandoffReady(briefing)).ok();
541        });
542    }
543}
544
545impl Default for Session {
546    fn default() -> Self {
547        Self::new().0
548    }
549}
550
551/// A sink that forwards ABK `OutputEvent`s to the message channel.
552///
553/// Includes a 3-state atomic state machine (IDLE/REASONING/CONTENT) that
554/// inserts blank separator lines when transitioning between reasoning and
555/// content streams, so the frontend can distinguish them visually.
556pub struct TuiForwardSink {
557    tx: mpsc::UnboundedSender<TuiMessage>,
558    stream_state: AtomicU8,
559}
560
561/// Stream state machine constants.
562const STREAM_IDLE: u8 = 0;
563const STREAM_REASONING: u8 = 1;
564const STREAM_CONTENT: u8 = 2;
565
566impl TuiForwardSink {
567    pub fn new(tx: mpsc::UnboundedSender<TuiMessage>) -> Self {
568        Self {
569            tx,
570            stream_state: AtomicU8::new(STREAM_IDLE),
571        }
572    }
573}
574
575impl abk::orchestration::output::OutputSink for TuiForwardSink {
576    fn emit(&self, event: abk::orchestration::output::OutputEvent) {
577        use abk::orchestration::output::OutputEvent;
578
579        let msg = match event {
580            OutputEvent::StreamingChunk { delta } => {
581                if delta.is_empty() {
582                    return;
583                }
584                let prev = self.stream_state.swap(STREAM_CONTENT, Ordering::Relaxed);
585                if prev != STREAM_CONTENT {
586                    let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
587                }
588                let _ = self.tx.send(TuiMessage::StreamDelta(delta));
589                return;
590            }
591
592            OutputEvent::LlmResponse { text, model } => {
593                TuiMessage::OutputLine(format!("[{}] {}", model, text))
594            }
595
596            OutputEvent::Info { message } => {
597                // Suppress noisy/no-value messages from ABK
598                if message.contains("API call completed successfully") {
599                    return;
600                }
601                TuiMessage::OutputLine(message)
602            }
603
604            OutputEvent::WorkflowStarted { task_description } => {
605                TuiMessage::OutputLine(format!("🚀 Workflow started: {}", task_description))
606            }
607
608            OutputEvent::WorkflowCompleted { reason, iterations } => {
609                TuiMessage::OutputLine(format!(
610                    "✅ Workflow completed after {} iterations: {}",
611                    iterations, reason
612                ))
613            }
614
615            OutputEvent::IterationStarted { iteration, context_tokens } => {
616                let _ = self.tx.send(TuiMessage::ContextTokensUpdated(context_tokens));
617                TuiMessage::OutputLine(format!(
618                    "📡 Iteration {} | Context = {} tokens",
619                    iteration, context_tokens
620                ))
621            }
622
623            OutputEvent::ApiCallStarted {
624                call_number,
625                model,
626                tool_count,
627                streaming,
628                context_tokens,
629                tool_tokens,
630            } => {
631                let mode = if streaming { "Streaming" } else { "Non-streaming" };
632                let total = context_tokens + tool_tokens;
633                let _ = self.tx.send(TuiMessage::ContextTokensUpdated(total));
634                // Blank line separator before each API call for readability
635                let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
636                TuiMessage::OutputLine(format!(
637                    "🔥 API Call {} | Ctx={}({}+{}) | {} | Model: {} | Tools: {}",
638                    call_number, total, context_tokens, tool_tokens, mode, model, tool_count
639                ))
640            }
641
642            OutputEvent::ToolsExecuting { tool_names, hints } => {
643                for (name, hint) in tool_names.into_iter().zip(hints.into_iter()) {
644                    let _ = self.tx.send(TuiMessage::ToolPending { tool_name: name, hint });
645                }
646                self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
647                return;
648            }
649
650            OutputEvent::ToolCompleted {
651                tool_name,
652                success,
653                content,
654                description,
655            } => {
656                if tool_name == "todowrite" && success {
657                    let _ = self.tx.send(TuiMessage::TodoUpdate(content.clone()));
658                }
659                let hint = description;
660                let _ = self.tx.send(TuiMessage::ToolDone { tool_name, success, hint });
661                self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
662                return;
663            }
664
665            OutputEvent::Error { message, context } => {
666                if let Some(ctx) = context {
667                    TuiMessage::OutputLine(format!("❌ Error: {} — {}", message, ctx))
668                } else {
669                    TuiMessage::OutputLine(format!("❌ Error: {}", message))
670                }
671            }
672
673            OutputEvent::ReasoningChunk { delta } => {
674                if delta.is_empty() {
675                    return;
676                }
677                let prev = self.stream_state.swap(STREAM_REASONING, Ordering::Relaxed);
678                if prev != STREAM_REASONING {
679                    let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
680                }
681                let _ = self.tx.send(TuiMessage::ReasoningDelta(delta));
682                return;
683            }
684
685            OutputEvent::McpServerStatus { name, connected, tool_count, error } => {
686                let _ = self.tx.send(TuiMessage::McpServerStatus {
687                    name,
688                    connected,
689                    tool_count,
690                    error,
691                });
692                return;
693            }
694        };
695
696        self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
697        let _ = self.tx.send(msg);
698    }
699}