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 pub model: Option<String>,
106}
107
108impl Session {
109 pub fn new() -> (Self, mpsc::UnboundedReceiver<TuiMessage>) {
114 let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
115 let session = Self {
116 input: String::new(),
117 output_lines: Vec::new(),
118 workflow_tx,
119 workflow_state: WorkflowState::Idle,
120 config_toml: None,
121 secrets: None,
122 build_info: None,
123 resume_info: None,
124 backup_resume_info: None,
125 todo_lines: Vec::new(),
126 cancel_token: CancellationToken::new(),
127 pending_command: None,
128 handoff_pending: false,
129 pending_tool_lines: Vec::new(),
130 current_context_tokens: 0,
131 auto_handoff: AutoHandoffConfig::default(),
132 mcp_servers: Vec::new(),
133 should_quit: false,
134 auto_scroll: true,
135 agent_name: "trustee".to_string(),
136 token_store: None,
137 project_id: None,
138 project_name: None,
139 session_id: None,
140 session_name: None,
141 home_dir: None,
142 workflow_permit: None,
143 identity: None,
144 model: None,
145 };
146 (session, workflow_rx)
147 }
148
149 pub fn parse_auto_handoff_config(&mut self) {
151 if let Some(ref config_toml) = self.config_toml {
152 self.auto_handoff = crate::config::parse_auto_handoff_config(config_toml);
153 }
154 }
155
156 pub fn handle_workflow_message(&mut self, msg: TuiMessage) {
161 match msg {
162 TuiMessage::WorkflowCancelled => {
163 self.output_lines.push("⏹ Workflow cancelled".to_string());
164 self.output_lines.push("".to_string());
165 self.workflow_state = WorkflowState::Cancelling;
166 }
167 TuiMessage::OutputLine(line) => {
168 self.output_lines.push(line);
169 }
170 TuiMessage::StreamDelta(delta) => {
171 if let Some(last) = self.output_lines.last_mut() {
172 last.push_str(&delta);
173 } else {
174 self.output_lines.push(delta);
175 }
176 }
177 TuiMessage::ReasoningDelta(delta) => {
178 if let Some(last) = self.output_lines.last_mut() {
179 if !last.starts_with('\x01') {
180 last.insert(0, '\x01');
181 }
182 last.push_str(&delta);
183 } else {
184 self.output_lines.push(format!("\x01{}", delta));
185 }
186 }
187 TuiMessage::WorkflowCompleted => {
188 self.output_lines.push("✓ Workflow completed".to_string());
189 self.output_lines.push("".to_string());
190 if self.workflow_state == WorkflowState::Running {
191 self.workflow_state = WorkflowState::Cancelling;
192 }
193 }
194 TuiMessage::WorkflowError(err) => {
195 self.output_lines.push(format!("✗ Error: {}", err));
196 self.output_lines.push("".to_string());
197 if self.workflow_state == WorkflowState::Running {
198 self.workflow_state = WorkflowState::Cancelling;
199 }
200 }
201 TuiMessage::TodoUpdate(content) => {
202 self.todo_lines = content.lines().map(|l| l.to_string()).collect();
203 }
204 TuiMessage::ToolPending { tool_name, hint } => {
205 let label = match &hint {
206 Some(h) => format!("⠋ {} {}", tool_name, h),
207 None => format!("⠋ {}", tool_name),
208 };
209 let idx = self.output_lines.len();
210 self.output_lines.push(label);
211 self.pending_tool_lines.push((tool_name, idx, hint));
212 }
213 TuiMessage::ToolDone { tool_name, success, hint } => {
214 let status = if success { "✓" } else { "✗" };
215 if let Some(pos) = self.pending_tool_lines.iter().position(|(n, _, _)| *n == tool_name) {
216 let (_, idx, pending_hint) = self.pending_tool_lines.remove(pos);
217 let h = hint.or(pending_hint);
218 let label = match &h {
219 Some(h) => format!("{} {} {}", status, tool_name, h),
220 None => format!("{} {}", status, tool_name),
221 };
222 if idx < self.output_lines.len() {
223 self.output_lines[idx] = label;
224 return;
225 }
226 self.output_lines.push(label);
227 } else {
228 let label = match &hint {
229 Some(h) => format!("{} {} {}", status, tool_name, h),
230 None => format!("{} {}", status, tool_name),
231 };
232 self.output_lines.push(label);
233 }
234 }
235 TuiMessage::ResumeInfo(info) => {
236 if self.workflow_state == WorkflowState::Cancelling && info.is_none() {
237 self.resume_info = self.backup_resume_info.take();
238 } else if info.is_some() {
239 self.resume_info = info;
245 self.backup_resume_info = None;
246 }
247 if let Some(ref ri) = self.resume_info {
253 if self.session_id.is_none() {
254 self.session_id = Some(ri.session_id.clone());
255 }
256 }
257
258 if self.workflow_state == WorkflowState::Cancelling {
259 self.workflow_state = WorkflowState::Idle;
260 self.workflow_permit = None;
262 }
263 if self.resume_info.is_some() {
264 if std::env::var("RUST_LOG")
265 .map(|v| v.to_lowercase().contains("debug"))
266 .unwrap_or(false)
267 {
268 self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
269 }
270 }
271 if self.workflow_state == WorkflowState::Idle && self.handoff_pending {
272 self.handoff_pending = false;
273 self.trigger_handoff(String::new());
274 } else if let Some(cmd) = self.pending_command.take() {
275 self.input = cmd;
276 self.execute_command();
277 }
278 }
279 TuiMessage::ContextTokensUpdated(count) => {
280 self.current_context_tokens = count;
281 if self.auto_handoff.enabled
282 && count >= self.auto_handoff.context_threshold
283 && self.workflow_state == WorkflowState::Running
284 && !self.handoff_pending
285 && self.resume_info.is_some()
286 {
287 self.handoff_pending = true;
288 self.cancel_token.cancel();
289 self.workflow_state = WorkflowState::Cancelling;
290 self.output_lines.push(format!(
291 "🔄 Auto-handoff: cancelling workflow, context tokens ({}) ≥ threshold ({})",
292 count, self.auto_handoff.context_threshold
293 ));
294 }
295 }
296 TuiMessage::McpServerStatus { name, connected, tool_count, error } => {
297 let status = if connected { McpServerStatus::Connected } else { McpServerStatus::Failed };
298 if let Some(existing) = self.mcp_servers.iter_mut().find(|s| s.name == name) {
299 existing.status = status;
300 existing.tool_count = tool_count;
301 existing.error = error;
302 } else {
303 self.mcp_servers.push(McpServerInfo { name, status, tool_count, error });
304 }
305 }
306 TuiMessage::HandoffReady(briefing) => {
307 self.workflow_state = WorkflowState::Idle;
308 self.resume_info = None;
309 self.session_id = None;
312 self.session_name = None;
313 self.input = briefing;
314 self.execute_command();
315 }
316 TuiMessage::HandoffFailed => {
317 self.workflow_state = WorkflowState::Idle;
321 self.output_lines.push(
322 "✗ Handoff briefing unavailable — session preserved, try again.".to_string(),
323 );
324 self.output_lines.push("".to_string());
325 }
326 TuiMessage::SessionTitleUpdated(title) => {
327 self.session_name = Some(title);
329 }
330 }
331 if self.auto_scroll {
332 }
335 }
336
337 pub fn execute_command(&mut self) {
342 let command = self.input.trim().to_string();
343
344 if self.workflow_state != WorkflowState::Idle {
345 self.pending_command = Some(command);
346 self.output_lines.push("⏳ Previous workflow finishing — command queued".to_string());
347 self.input.clear();
348 return;
349 }
350
351 let is_continuation = self.resume_info.is_some();
352
353 if !is_continuation {
354 self.output_lines.clear();
355 if self.session_id.is_none() {
361 let timestamp = chrono::Utc::now().format("%Y_%m_%d_%H_%M");
362 let uuid_suffix = uuid::Uuid::new_v4().simple().to_string();
363 let uuid8 = &uuid_suffix[..8];
364 self.session_id = Some(format!("session_{}_{}", timestamp, uuid8));
365 }
366 if self.session_name.is_none() {
367 let derived = if command.len() > 80 {
368 format!("{}...", &command[..77])
369 } else {
370 command.clone()
371 };
372 self.session_name = Some(derived);
373 }
374 }
375
376 self.output_lines.push(format!("> {}", command));
377
378 let config_toml = match &self.config_toml {
379 Some(c) => c.clone(),
380 None => {
381 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
382 self.output_lines.push("".to_string());
383 return;
384 }
385 };
386
387 let config_toml = inject_identity(config_toml, &self.identity);
389
390 let model_override = self.model.take();
392 let config_toml = inject_model(config_toml, model_override);
393
394 let secrets = self.secrets.clone().unwrap_or_default();
395 let title_secrets = secrets.clone();
397 let build_info = self.build_info.clone();
398 let tx = self.workflow_tx.clone();
399
400 let agent_name = self.agent_name.clone();
401 let token_store = self.token_store.clone();
402 let project_id = self.project_id.clone();
403 let project_name = self.project_name.clone();
404 let session_id = self.session_id.clone();
405 let session_name = self.session_name.clone();
406 let home_dir = self.home_dir.clone();
407
408 self.backup_resume_info = self.resume_info.clone();
409 let resume_info = self.resume_info.take();
410
411 self.workflow_state = WorkflowState::Running;
412 self.auto_scroll = true;
413
414 self.cancel_token = CancellationToken::new();
415 let child_token = self.cancel_token.clone();
416
417 let (resume_tx, mut resume_rx) = mpsc::unbounded_channel();
418
419 let resume_forward_tx = tx.clone();
420 tokio::spawn(async move {
421 while let Some(info) = resume_rx.recv().await {
422 resume_forward_tx.send(TuiMessage::ResumeInfo(info)).ok();
423 }
424 });
425
426 tokio::spawn(async move {
427 let tui_sink: abk::orchestration::output::SharedSink =
428 Arc::new(crate::session::TuiForwardSink::new(tx.clone()));
429
430 let mut run_ctx = RunContext::new()
432 .with_agent_name(agent_name.clone());
433
434 if let Some(ref dir) = home_dir {
436 run_ctx = run_ctx.with_home_dir(dir.clone());
437 }
438
439 if project_id.is_some() || project_name.is_some() {
441 run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
442 id: project_id.unwrap_or_else(|| "default".to_string()),
443 name: project_name,
444 });
445 }
446
447 if session_id.is_some() || session_name.is_some() {
449 run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
450 id: session_id.unwrap_or_else(|| "default".to_string()),
451 name: session_name,
452 });
453 }
454
455 #[cfg(feature = "registry-mcp-token")]
456 {
457 if let Some(ref ts) = token_store {
458 run_ctx = run_ctx.with_token_store(ts.clone());
459 }
460 }
461
462 let scope_logger = {
467 abk::observability::Logger::with_agent_name(
468 None::<&std::path::Path>,
469 Some("INFO"),
470 Some(&agent_name),
471 ).unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap())
472 };
473
474 let result = abk::observability::with_logger(scope_logger, async {
475 abk::observability::with_tui_mode(true, async {
476 abk::cli::run_task_from_raw_config(
477 &config_toml,
478 secrets,
479 build_info,
480 &command,
481 Some(tui_sink),
482 resume_info,
483 Some(resume_tx),
484 Some(child_token),
485 Some(&run_ctx),
486 )
487 .await
488 })
489 .await
490 })
491 .await;
492
493 let task_result = result.unwrap_or_else(|e| abk::cli::TaskResult {
494 success: false,
495 error: Some(e.to_string()),
496 resume_info: None,
501 });
502
503 let msg = if task_result.success {
504 TuiMessage::WorkflowCompleted
505 } else {
506 TuiMessage::WorkflowError(task_result.error.unwrap_or_default())
507 };
508
509 let title_session_id = task_result.resume_info
511 .as_ref()
512 .map(|ri| ri.session_id.clone());
513
514 tx.send(msg).ok();
515 tx.send(TuiMessage::ResumeInfo(task_result.resume_info)).ok();
516
517 if task_result.success {
524 let title_tx = tx.clone();
525 let title_config = config_toml.clone();
526 let title_command = command.clone();
527 let title_ctx = run_ctx.clone();
528
529 tokio::spawn(async move {
530 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 } else {
538 return; }
540
541 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
543
544 if let Some(ref sid) = title_session_id {
546 if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
547 return;
548 }
549 }
550
551 match abk::cli::generate_session_title(
552 &title_config,
553 title_secrets,
554 &title_command,
555 )
556 .await
557 {
558 Ok(Some(title)) => {
559 if let Some(ref sid) = title_session_id {
561 if let Err(e) = abk::cli::persist_session_title(
562 &title_ctx,
563 &title_config,
564 sid,
565 &title,
566 ).await {
567 let _ = e; }
569 }
570 title_tx.send(TuiMessage::SessionTitleUpdated(title)).ok();
572 }
573 Ok(None) => {}
574 Err(e) => { let _ = e; }
575 }
576 });
577 }
578 });
579
580 self.input.clear();
581 }
582
583 pub fn request_handoff(&mut self, hint: String) {
594 match self.workflow_state {
595 WorkflowState::Idle => self.trigger_handoff(hint),
596 WorkflowState::Running => {
597 self.cancel_token.cancel();
598 self.workflow_state = WorkflowState::Cancelling;
599 self.handoff_pending = true;
600 self.workflow_tx
605 .send(TuiMessage::OutputLine("⏹ Cancelling before handoff...".to_string()))
606 .ok();
607 }
608 WorkflowState::Cancelling => {
609 self.handoff_pending = true;
610 }
611 }
612 }
613
614 pub fn trigger_handoff(&mut self, hint: String) {
622 if self.workflow_state != WorkflowState::Idle {
626 self.output_lines
627 .push("⏳ Workflow still running — handoff will fire after it stops".to_string());
628 return;
629 }
630 if self.resume_info.is_none() {
631 self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
632 return;
633 }
634
635 let config_toml = match &self.config_toml {
636 Some(c) => c.clone(),
637 None => {
638 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
639 return;
640 }
641 };
642
643 let config_toml = inject_identity(config_toml, &self.identity);
645
646 let model_override = self.model.clone();
648 let config_toml = inject_model(config_toml, model_override);
649
650 let tx = self.workflow_tx.clone();
651 let agent_name = self.agent_name.clone();
652 let project_id = self.project_id.clone();
653 let project_name = self.project_name.clone();
654 let session_id = self.session_id.clone();
655 let session_name = self.session_name.clone();
656 let home_dir = self.home_dir.clone();
657
658 let backup_resume_info = self.resume_info.clone();
661 let resume_info = match self.resume_info.take() {
662 Some(ri) => ri,
663 None => {
664 self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
666 return;
667 }
668 };
669
670 self.workflow_state = WorkflowState::Running;
671 self.auto_scroll = true;
672 self.cancel_token = CancellationToken::new();
673
674 self.workflow_tx
681 .send(TuiMessage::OutputLine("🔀 Generating session handoff briefing...".to_string()))
682 .ok();
683
684 tokio::spawn(async move {
685 let base = "Output a session handoff briefing in at most 300 lines. \
686 Do NOT use any tools. Include: the FULL ABSOLUTE PATH of every \
687 project/repository being worked on (e.g. /Projects/Foo/bar — never \
688 omit the leading path), all project/task/workstream UUIDs referenced, \
689 every file created or modified with its full absolute path, all \
690 commands run and their outcomes, the current state of the work, any \
691 blockers, and the exact next action to take. \
692 Output ONLY the briefing text — no preamble, headers, or closing remarks.";
693 let prompt = if hint.is_empty() {
694 base.to_string()
695 } else {
696 format!("{base}\n\nIn the briefing also consider: {hint}")
697 };
698
699 let mut run_ctx = RunContext::new()
701 .with_agent_name(agent_name.clone());
702
703 if let Some(ref dir) = home_dir {
705 run_ctx = run_ctx.with_home_dir(dir.clone());
706 }
707
708 if project_id.is_some() || project_name.is_some() {
710 run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
711 id: project_id.unwrap_or_else(|| "default".to_string()),
712 name: project_name,
713 });
714 }
715
716 if session_id.is_some() || session_name.is_some() {
718 run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
719 id: session_id.unwrap_or_else(|| "default".to_string()),
720 name: session_name,
721 });
722 }
723
724 let scope_logger = abk::observability::Logger::with_agent_name(
726 None,
727 Some("INFO"),
728 Some(&agent_name),
729 ).unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap());
730
731 let result = abk::observability::with_logger(scope_logger, async {
735 abk::observability::with_tui_mode(true, async {
736 abk::cli::generate_handoff_briefing(
737 &config_toml,
738 &run_ctx,
739 &resume_info,
740 &prompt,
741 )
742 .await
743 })
744 .await
745 })
746 .await;
747
748 match result {
749 Ok(briefing) if !briefing.trim().is_empty() => {
750 tx.send(TuiMessage::HandoffReady(briefing)).ok();
751 }
752 _ => {
753 tx.send(TuiMessage::ResumeInfo(backup_resume_info)).ok();
757 tx.send(TuiMessage::HandoffFailed).ok();
758 }
759 }
760 });
761 }
762}
763
764impl Default for Session {
765 fn default() -> Self {
766 Self::new().0
767 }
768}
769
770fn inject_identity(config_toml: String, identity: &Option<String>) -> String {
775 let Some(identity) = identity.as_ref().filter(|s| !s.is_empty()) else {
776 return config_toml;
777 };
778
779 let Ok(mut table) = config_toml.parse::<toml::Value>() else {
780 return config_toml;
781 };
782
783 let lifecycle = table
784 .get_mut("lifecycle")
785 .and_then(|v| v.as_table_mut());
786
787 if let Some(lifecycle) = lifecycle {
788 let existing = lifecycle
789 .get("system_template")
790 .and_then(|v| v.as_str())
791 .unwrap_or("");
792 let combined = format!("{}\n\n{}", identity, existing);
793 lifecycle.insert(
794 "system_template".to_string(),
795 toml::Value::String(combined),
796 );
797 } else {
798 let mut ltable = toml::value::Table::new();
800 ltable.insert(
801 "system_template".to_string(),
802 toml::Value::String(identity.clone()),
803 );
804 if let Some(table) = table.as_table_mut() {
805 table.insert("lifecycle".to_string(), toml::Value::Table(ltable));
806 }
807 }
808
809 toml::to_string(&table).unwrap_or(config_toml)
810}
811
812fn inject_model(config_toml: String, model: Option<String>) -> String {
826 let Some(model_name) = model.as_ref().filter(|s| !s.is_empty()) else {
827 return config_toml;
828 };
829
830 let Ok(mut table) = config_toml.parse::<toml::Value>() else {
831 return config_toml;
832 };
833
834 let llm = match table.get_mut("llm").and_then(|v| v.as_table_mut()) {
835 Some(l) => l,
836 None => return config_toml,
837 };
838
839 let looks_like_provider = |v: &toml::Value| -> bool {
841 v.as_table()
842 .map(|t| {
843 t.contains_key("base_url")
844 || t.contains_key("api_key")
845 || t.contains_key("models")
846 })
847 .unwrap_or(false)
848 };
849 let offers_model = |v: &toml::Value| -> bool {
851 v.get("models")
852 .and_then(|m| m.as_array())
853 .map(|arr| arr.iter().any(|m| m.as_str() == Some(model_name)))
854 .unwrap_or(false)
855 || v.get("model").and_then(|m| m.as_str()) == Some(model_name.as_str())
856 };
857
858 let providers = llm.get("providers").cloned();
859
860 if let Some(ref ps) = providers {
862 if let Some(selected) = ps.get(model_name.as_str()) {
863 if looks_like_provider(selected) {
864 let mut selected = selected.clone();
865 if let Some(st) = selected.as_table_mut() {
866 st.entry("provider_type".to_string())
867 .or_insert_with(|| toml::Value::String("openai".to_string()));
868 }
869 llm.insert("provider".to_string(), selected);
870 return toml::to_string(&table).unwrap_or(config_toml);
871 }
872 }
873 }
874
875 if let Some(ref ps) = providers {
877 if let Some(ptable) = ps.as_table() {
878 for (_key, entry) in ptable {
879 if looks_like_provider(entry) && offers_model(entry) {
880 let mut selected = entry.clone();
881 if let Some(st) = selected.as_table_mut() {
882 st.insert(
883 "model".to_string(),
884 toml::Value::String(model_name.clone()),
885 );
886 st.entry("provider_type".to_string())
887 .or_insert_with(|| toml::Value::String("openai".to_string()));
888 }
889 llm.insert("provider".to_string(), selected);
890 return toml::to_string(&table).unwrap_or(config_toml);
891 }
892 }
893 }
894 }
895
896 if let Some(provider) = llm.get_mut("provider").and_then(|p| p.as_table_mut()) {
898 provider.insert(
899 "model".to_string(),
900 toml::Value::String(model_name.clone()),
901 );
902 }
903
904 toml::to_string(&table).unwrap_or(config_toml)
905}
906pub struct TuiForwardSink {
911 tx: mpsc::UnboundedSender<TuiMessage>,
912 stream_state: AtomicU8,
913}
914
915const STREAM_IDLE: u8 = 0;
917const STREAM_REASONING: u8 = 1;
918const STREAM_CONTENT: u8 = 2;
919
920impl TuiForwardSink {
921 pub fn new(tx: mpsc::UnboundedSender<TuiMessage>) -> Self {
922 Self {
923 tx,
924 stream_state: AtomicU8::new(STREAM_IDLE),
925 }
926 }
927}
928
929impl abk::orchestration::output::OutputSink for TuiForwardSink {
930 fn emit(&self, event: abk::orchestration::output::OutputEvent) {
931 use abk::orchestration::output::OutputEvent;
932
933 let msg = match event {
934 OutputEvent::StreamingChunk { delta } => {
935 if delta.is_empty() {
936 return;
937 }
938 let prev = self.stream_state.swap(STREAM_CONTENT, Ordering::Relaxed);
939 if prev != STREAM_CONTENT {
940 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
941 }
942 let _ = self.tx.send(TuiMessage::StreamDelta(delta));
943 return;
944 }
945
946 OutputEvent::LlmResponse { text, model } => {
947 TuiMessage::OutputLine(format!("[{}] {}", model, text))
948 }
949
950 OutputEvent::Info { message } => {
951 if message.contains("API call completed successfully") {
953 return;
954 }
955 TuiMessage::OutputLine(message)
956 }
957
958 OutputEvent::WorkflowStarted { task_description } => {
959 TuiMessage::OutputLine(format!("🚀 Workflow started: {}", task_description))
960 }
961
962 OutputEvent::WorkflowCompleted { reason, iterations } => {
963 TuiMessage::OutputLine(format!(
964 "✅ Workflow completed after {} iterations: {}",
965 iterations, reason
966 ))
967 }
968
969 OutputEvent::IterationStarted { iteration, context_tokens } => {
970 let _ = self.tx.send(TuiMessage::ContextTokensUpdated(context_tokens));
971 TuiMessage::OutputLine(format!(
972 "📡 Iteration {} | Context = {} tokens",
973 iteration, context_tokens
974 ))
975 }
976
977 OutputEvent::ApiCallStarted {
978 call_number,
979 model,
980 tool_count,
981 streaming,
982 context_tokens,
983 tool_tokens,
984 } => {
985 let mode = if streaming { "Streaming" } else { "Non-streaming" };
986 let total = context_tokens + tool_tokens;
987 let _ = self.tx.send(TuiMessage::ContextTokensUpdated(total));
988 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
990 TuiMessage::OutputLine(format!(
991 "🔥 API Call {} | Ctx={}({}+{}) | {} | Model: {} | Tools: {}",
992 call_number, total, context_tokens, tool_tokens, mode, model, tool_count
993 ))
994 }
995
996 OutputEvent::ToolsExecuting { tool_names, hints } => {
997 for (name, hint) in tool_names.into_iter().zip(hints.into_iter()) {
998 let _ = self.tx.send(TuiMessage::ToolPending { tool_name: name, hint });
999 }
1000 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
1001 return;
1002 }
1003
1004 OutputEvent::ToolCompleted {
1005 tool_name,
1006 success,
1007 content,
1008 description,
1009 } => {
1010 if tool_name == "todowrite" && success {
1011 let _ = self.tx.send(TuiMessage::TodoUpdate(content.clone()));
1012 }
1013 let hint = description;
1014 let _ = self.tx.send(TuiMessage::ToolDone { tool_name, success, hint });
1015 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
1016 return;
1017 }
1018
1019 OutputEvent::Error { message, context } => {
1020 if let Some(ctx) = context {
1021 TuiMessage::OutputLine(format!("❌ Error: {} — {}", message, ctx))
1022 } else {
1023 TuiMessage::OutputLine(format!("❌ Error: {}", message))
1024 }
1025 }
1026
1027 OutputEvent::ReasoningChunk { delta } => {
1028 if delta.is_empty() {
1029 return;
1030 }
1031 let prev = self.stream_state.swap(STREAM_REASONING, Ordering::Relaxed);
1032 if prev != STREAM_REASONING {
1033 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
1034 }
1035 let _ = self.tx.send(TuiMessage::ReasoningDelta(delta));
1036 return;
1037 }
1038
1039 OutputEvent::McpServerStatus { name, connected, tool_count, error } => {
1040 let _ = self.tx.send(TuiMessage::McpServerStatus {
1041 name,
1042 connected,
1043 tool_count,
1044 error,
1045 });
1046 return;
1047 }
1048 };
1049
1050 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
1051 let _ = self.tx.send(msg);
1052 }
1053}