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 pub rotating_from_handoff: bool,
113 pub briefing_born: bool,
118 pub handoff_count: u32,
122}
123
124impl Session {
125 pub fn new() -> (Self, mpsc::UnboundedReceiver<TuiMessage>) {
130 let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
131 let session = Self {
132 input: String::new(),
133 output_lines: Vec::new(),
134 workflow_tx,
135 workflow_state: WorkflowState::Idle,
136 config_toml: None,
137 secrets: None,
138 build_info: None,
139 resume_info: None,
140 backup_resume_info: None,
141 todo_lines: Vec::new(),
142 cancel_token: CancellationToken::new(),
143 pending_command: None,
144 handoff_pending: false,
145 pending_tool_lines: Vec::new(),
146 current_context_tokens: 0,
147 auto_handoff: AutoHandoffConfig::default(),
148 mcp_servers: Vec::new(),
149 should_quit: false,
150 auto_scroll: true,
151 agent_name: "trustee".to_string(),
152 token_store: None,
153 project_id: None,
154 project_name: None,
155 session_id: None,
156 session_name: None,
157 home_dir: None,
158 workflow_permit: None,
159 identity: None,
160 model: None,
161 rotating_from_handoff: false,
162 briefing_born: false,
163 handoff_count: 0,
164 };
165 (session, workflow_rx)
166 }
167
168 pub fn parse_auto_handoff_config(&mut self) {
170 if let Some(ref config_toml) = self.config_toml {
171 self.auto_handoff = crate::config::parse_auto_handoff_config(config_toml);
172 }
173 }
174
175 pub fn handle_workflow_message(&mut self, msg: TuiMessage) {
180 match msg {
181 TuiMessage::WorkflowCancelled => {
182 self.output_lines.push("⏹ Workflow cancelled".to_string());
183 self.output_lines.push("".to_string());
184 self.workflow_state = WorkflowState::Cancelling;
185 }
186 TuiMessage::OutputLine(line) => {
187 self.output_lines.push(line);
188 }
189 TuiMessage::StreamDelta(delta) => {
190 if let Some(last) = self.output_lines.last_mut() {
191 last.push_str(&delta);
192 } else {
193 self.output_lines.push(delta);
194 }
195 }
196 TuiMessage::ReasoningDelta(delta) => {
197 if let Some(last) = self.output_lines.last_mut() {
198 if !last.starts_with('\x01') {
199 last.insert(0, '\x01');
200 }
201 last.push_str(&delta);
202 } else {
203 self.output_lines.push(format!("\x01{}", delta));
204 }
205 }
206 TuiMessage::WorkflowCompleted => {
207 self.output_lines.push("✓ Workflow completed".to_string());
208 self.output_lines.push("".to_string());
209 if self.workflow_state == WorkflowState::Running {
210 self.workflow_state = WorkflowState::Cancelling;
211 }
212 }
213 TuiMessage::WorkflowError(err) => {
214 self.output_lines.push(format!("✗ Error: {}", err));
215 self.output_lines.push("".to_string());
216 if self.workflow_state == WorkflowState::Running {
217 self.workflow_state = WorkflowState::Cancelling;
218 }
219 }
220 TuiMessage::TodoUpdate(content) => {
221 self.todo_lines = content.lines().map(|l| l.to_string()).collect();
222 }
223 TuiMessage::ToolPending { tool_name, hint } => {
224 let label = match &hint {
225 Some(h) => format!("⠋ {} {}", tool_name, h),
226 None => format!("⠋ {}", tool_name),
227 };
228 let idx = self.output_lines.len();
229 self.output_lines.push(label);
230 self.pending_tool_lines.push((tool_name, idx, hint));
231 }
232 TuiMessage::ToolDone { tool_name, success, hint } => {
233 let status = if success { "✓" } else { "✗" };
234 if let Some(pos) = self.pending_tool_lines.iter().position(|(n, _, _)| *n == tool_name) {
235 let (_, idx, pending_hint) = self.pending_tool_lines.remove(pos);
236 let h = hint.or(pending_hint);
237 let label = match &h {
238 Some(h) => format!("{} {} {}", status, tool_name, h),
239 None => format!("{} {}", status, tool_name),
240 };
241 if idx < self.output_lines.len() {
242 self.output_lines[idx] = label;
243 return;
244 }
245 self.output_lines.push(label);
246 } else {
247 let label = match &hint {
248 Some(h) => format!("{} {} {}", status, tool_name, h),
249 None => format!("{} {}", status, tool_name),
250 };
251 self.output_lines.push(label);
252 }
253 }
254 TuiMessage::ResumeInfo(info) => {
255 if self.workflow_state == WorkflowState::Cancelling && info.is_none() {
256 self.resume_info = self.backup_resume_info.take();
257 } else if info.is_some() {
258 self.resume_info = info;
264 self.backup_resume_info = None;
265 }
266 if let Some(ref ri) = self.resume_info {
272 if self.session_id.is_none() {
273 self.session_id = Some(ri.session_id.clone());
274 }
275 }
276
277 if self.workflow_state == WorkflowState::Cancelling {
278 self.workflow_state = WorkflowState::Idle;
279 self.workflow_permit = None;
281 }
282 if self.resume_info.is_some() {
283 if std::env::var("RUST_LOG")
284 .map(|v| v.to_lowercase().contains("debug"))
285 .unwrap_or(false)
286 {
287 self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
288 }
289 }
290 if self.workflow_state == WorkflowState::Idle && self.handoff_pending {
291 self.handoff_pending = false;
292 self.trigger_handoff(String::new());
293 } else if let Some(cmd) = self.pending_command.take() {
294 self.input = cmd;
295 self.execute_command();
296 }
297 }
298 TuiMessage::ContextTokensUpdated(count) => {
299 self.current_context_tokens = count;
300 if self.auto_handoff.enabled
301 && count >= self.auto_handoff.context_threshold
302 && self.workflow_state == WorkflowState::Running
303 && !self.handoff_pending
304 && self.resume_info.is_some()
305 && !self.briefing_born
310 {
311 self.handoff_pending = true;
312 self.cancel_token.cancel();
313 self.workflow_state = WorkflowState::Cancelling;
314 self.output_lines.push(format!(
315 "🔄 Auto-handoff: cancelling workflow, context tokens ({}) ≥ threshold ({})",
316 count, self.auto_handoff.context_threshold
317 ));
318 }
319 }
320 TuiMessage::McpServerStatus { name, connected, tool_count, error } => {
321 let status = if connected { McpServerStatus::Connected } else { McpServerStatus::Failed };
322 if let Some(existing) = self.mcp_servers.iter_mut().find(|s| s.name == name) {
323 existing.status = status;
324 existing.tool_count = tool_count;
325 existing.error = error;
326 } else {
327 self.mcp_servers.push(McpServerInfo { name, status, tool_count, error });
328 }
329 }
330 TuiMessage::HandoffReady(briefing) => {
331 self.workflow_state = WorkflowState::Idle;
332 self.resume_info = None;
333 let old_session_id = self.session_id.clone();
336 self.session_id = None;
339 self.session_name = None;
340 self.rotating_from_handoff = true;
345 self.briefing_born = true;
346 self.handoff_count += 1;
347 self.input = briefing;
348 self.execute_command();
349 let _ = self.workflow_tx.send(TuiMessage::SessionRotated {
353 old: old_session_id,
354 new: self.session_id.clone(),
355 });
356 }
357 TuiMessage::SessionRotated { .. } => {
358 }
362 TuiMessage::HandoffFailed => {
363 self.workflow_state = WorkflowState::Idle;
367 self.output_lines.push(
368 "✗ Handoff briefing unavailable — session preserved, try again.".to_string(),
369 );
370 self.output_lines.push("".to_string());
371 }
372 TuiMessage::SessionTitleUpdated(title) => {
373 self.session_name = Some(title);
375 }
376 }
377 if self.auto_scroll {
378 }
381 }
382
383 pub fn execute_command(&mut self) {
388 let command = self.input.trim().to_string();
389
390 if self.workflow_state != WorkflowState::Idle {
391 self.pending_command = Some(command);
392 self.output_lines.push("⏳ Previous workflow finishing — command queued".to_string());
393 self.input.clear();
394 return;
395 }
396
397 let is_continuation = self.resume_info.is_some();
398
399 let rotating_from_handoff = self.rotating_from_handoff;
404 self.rotating_from_handoff = false;
405
406 if !rotating_from_handoff {
410 self.briefing_born = false;
411 }
412
413 if !is_continuation && !rotating_from_handoff && self.handoff_count == 0 {
420 self.output_lines.clear();
421 }
422 if !is_continuation {
426 if self.session_id.is_none() {
432 let timestamp = chrono::Utc::now().format("%Y_%m_%d_%H_%M");
433 let uuid_suffix = uuid::Uuid::new_v4().simple().to_string();
434 let uuid8 = &uuid_suffix[..8];
435 self.session_id = Some(format!("session_{}_{}", timestamp, uuid8));
436 }
437 if self.session_name.is_none() {
438 let derived = if command.len() > 80 {
439 format!("{}...", &command[..77])
440 } else {
441 command.clone()
442 };
443 self.session_name = Some(derived);
444 }
445 }
446
447 self.output_lines.push(format!("> {}", command));
448
449 let config_toml = match &self.config_toml {
450 Some(c) => c.clone(),
451 None => {
452 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
453 self.output_lines.push("".to_string());
454 return;
455 }
456 };
457
458 let config_toml = inject_identity(config_toml, &self.identity);
460
461 let model_override = self.model.take();
463 let config_toml = inject_model(config_toml, model_override);
464
465 let secrets = self.secrets.clone().unwrap_or_default();
466 let title_secrets = secrets.clone();
468 let build_info = self.build_info.clone();
469 let tx = self.workflow_tx.clone();
470
471 let agent_name = self.agent_name.clone();
472 let token_store = self.token_store.clone();
473 let project_id = self.project_id.clone();
474 let project_name = self.project_name.clone();
475 let session_id = self.session_id.clone();
476 let session_name = self.session_name.clone();
477 let home_dir = self.home_dir.clone();
478
479 self.backup_resume_info = self.resume_info.clone();
480 let resume_info = self.resume_info.take();
481
482 self.workflow_state = WorkflowState::Running;
483 self.auto_scroll = true;
484
485 self.cancel_token = CancellationToken::new();
486 let child_token = self.cancel_token.clone();
487
488 let (resume_tx, mut resume_rx) = mpsc::unbounded_channel();
489
490 let resume_forward_tx = tx.clone();
491 tokio::spawn(async move {
492 while let Some(info) = resume_rx.recv().await {
493 resume_forward_tx.send(TuiMessage::ResumeInfo(info)).ok();
494 }
495 });
496
497 tokio::spawn(async move {
498 let tui_sink: abk::orchestration::output::SharedSink =
499 Arc::new(crate::session::TuiForwardSink::new(tx.clone()));
500
501 let mut run_ctx = RunContext::new()
503 .with_agent_name(agent_name.clone());
504
505 if let Some(ref dir) = home_dir {
507 run_ctx = run_ctx.with_home_dir(dir.clone());
508 }
509
510 if project_id.is_some() || project_name.is_some() {
512 run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
513 id: project_id.unwrap_or_else(|| "default".to_string()),
514 name: project_name,
515 });
516 }
517
518 if session_id.is_some() || session_name.is_some() {
520 run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
521 id: session_id.unwrap_or_else(|| "default".to_string()),
522 name: session_name,
523 });
524 }
525
526 #[cfg(feature = "registry-mcp-token")]
527 {
528 if let Some(ref ts) = token_store {
529 run_ctx = run_ctx.with_token_store(ts.clone());
530 }
531 }
532
533 let scope_logger = {
538 abk::observability::Logger::with_agent_name(
539 None::<&std::path::Path>,
540 Some("INFO"),
541 Some(&agent_name),
542 ).unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap())
543 };
544
545 let result = abk::observability::with_logger(scope_logger, async {
546 abk::observability::with_tui_mode(true, async {
547 abk::cli::run_task_from_raw_config(
548 &config_toml,
549 secrets,
550 build_info,
551 &command,
552 Some(tui_sink),
553 resume_info,
554 Some(resume_tx),
555 Some(child_token),
556 Some(&run_ctx),
557 )
558 .await
559 })
560 .await
561 })
562 .await;
563
564 let task_result = result.unwrap_or_else(|e| abk::cli::TaskResult {
565 success: false,
566 error: Some(e.to_string()),
567 resume_info: None,
572 });
573
574 let msg = if task_result.success {
575 TuiMessage::WorkflowCompleted
576 } else {
577 TuiMessage::WorkflowError(task_result.error.unwrap_or_default())
578 };
579
580 let title_session_id = task_result.resume_info
582 .as_ref()
583 .map(|ri| ri.session_id.clone());
584
585 tx.send(msg).ok();
586 tx.send(TuiMessage::ResumeInfo(task_result.resume_info)).ok();
587
588 if task_result.success {
595 let title_tx = tx.clone();
596 let title_config = config_toml.clone();
597 let title_command = command.clone();
598 let title_ctx = run_ctx.clone();
599
600 tokio::spawn(async move {
601 if let Some(ref sid) = title_session_id {
605 if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
606 return;
607 }
608 } else {
609 return; }
611
612 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
614
615 if let Some(ref sid) = title_session_id {
617 if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
618 return;
619 }
620 }
621
622 match abk::cli::generate_session_title(
623 &title_config,
624 title_secrets,
625 &title_command,
626 )
627 .await
628 {
629 Ok(Some(title)) => {
630 if let Some(ref sid) = title_session_id {
632 if let Err(e) = abk::cli::persist_session_title(
633 &title_ctx,
634 &title_config,
635 sid,
636 &title,
637 ).await {
638 let _ = e; }
640 }
641 title_tx.send(TuiMessage::SessionTitleUpdated(title)).ok();
643 }
644 Ok(None) => {}
645 Err(e) => { let _ = e; }
646 }
647 });
648 }
649 });
650
651 self.input.clear();
652 }
653
654 pub fn request_handoff(&mut self, hint: String) {
665 if self.briefing_born {
671 self.workflow_tx
672 .send(TuiMessage::OutputLine(
673 "ℹ Handoff unavailable — this session started from a handoff briefing. Run a command first.".to_string(),
674 ))
675 .ok();
676 return;
677 }
678 match self.workflow_state {
679 WorkflowState::Idle => self.trigger_handoff(hint),
680 WorkflowState::Running => {
681 self.cancel_token.cancel();
682 self.workflow_state = WorkflowState::Cancelling;
683 self.handoff_pending = true;
684 self.workflow_tx
689 .send(TuiMessage::OutputLine("⏹ Cancelling before handoff...".to_string()))
690 .ok();
691 }
692 WorkflowState::Cancelling => {
693 self.handoff_pending = true;
694 }
695 }
696 }
697
698 pub fn trigger_handoff(&mut self, hint: String) {
706 if self.workflow_state != WorkflowState::Idle {
710 self.output_lines
711 .push("⏳ Workflow still running — handoff will fire after it stops".to_string());
712 return;
713 }
714 if self.briefing_born {
718 self.output_lines
719 .push("ℹ Handoff unavailable — this session started from a handoff briefing. Run a command first.".to_string());
720 return;
721 }
722 if self.resume_info.is_none() {
723 self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
724 return;
725 }
726
727 let config_toml = match &self.config_toml {
728 Some(c) => c.clone(),
729 None => {
730 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
731 return;
732 }
733 };
734
735 let config_toml = inject_identity(config_toml, &self.identity);
737
738 let model_override = self.model.clone();
740 let config_toml = inject_model(config_toml, model_override);
741
742 let tx = self.workflow_tx.clone();
743 let agent_name = self.agent_name.clone();
744 let project_id = self.project_id.clone();
745 let project_name = self.project_name.clone();
746 let session_id = self.session_id.clone();
747 let session_name = self.session_name.clone();
748 let home_dir = self.home_dir.clone();
749
750 let backup_resume_info = self.resume_info.clone();
753 let resume_info = match self.resume_info.take() {
754 Some(ri) => ri,
755 None => {
756 self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
758 return;
759 }
760 };
761
762 self.workflow_state = WorkflowState::Running;
763 self.auto_scroll = true;
764 self.cancel_token = CancellationToken::new();
765
766 self.workflow_tx
773 .send(TuiMessage::OutputLine("🔀 Generating session handoff briefing...".to_string()))
774 .ok();
775
776 tokio::spawn(async move {
777 let base = "Output a session handoff briefing in at most 300 lines. \
778 Do NOT use any tools. The FIRST line must be exactly: \
779 \"Session Title: <a concise descriptive title of this work, maximum 50 characters>\" \
780 — nothing else on that line. The rest of the briefing must include: \
781 the FULL ABSOLUTE PATH of every \
782 project/repository being worked on (e.g. /Projects/Foo/bar — never \
783 omit the leading path), all project/task/workstream UUIDs referenced, \
784 every file created or modified with its full absolute path, all \
785 commands run and their outcomes, the current state of the work, any \
786 blockers, and the exact next action to take. \
787 Output ONLY the briefing text — the title line first, then the body, \
788 with no other headers, preamble, or closing remarks.";
789 let prompt = if hint.is_empty() {
790 base.to_string()
791 } else {
792 format!("{base}\n\nIn the briefing also consider: {hint}")
793 };
794
795 let mut run_ctx = RunContext::new()
797 .with_agent_name(agent_name.clone());
798
799 if let Some(ref dir) = home_dir {
801 run_ctx = run_ctx.with_home_dir(dir.clone());
802 }
803
804 if project_id.is_some() || project_name.is_some() {
806 run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
807 id: project_id.unwrap_or_else(|| "default".to_string()),
808 name: project_name,
809 });
810 }
811
812 if session_id.is_some() || session_name.is_some() {
814 run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
815 id: session_id.unwrap_or_else(|| "default".to_string()),
816 name: session_name,
817 });
818 }
819
820 let scope_logger = abk::observability::Logger::with_agent_name(
822 None,
823 Some("INFO"),
824 Some(&agent_name),
825 ).unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap());
826
827 let result = abk::observability::with_logger(scope_logger, async {
831 abk::observability::with_tui_mode(true, async {
832 abk::cli::generate_handoff_briefing(
833 &config_toml,
834 &run_ctx,
835 &resume_info,
836 &prompt,
837 )
838 .await
839 })
840 .await
841 })
842 .await;
843
844 match result {
845 Ok(briefing) if !briefing.trim().is_empty() => {
846 tx.send(TuiMessage::HandoffReady(briefing)).ok();
847 }
848 _ => {
849 tx.send(TuiMessage::ResumeInfo(backup_resume_info)).ok();
853 tx.send(TuiMessage::HandoffFailed).ok();
854 }
855 }
856 });
857 }
858}
859
860impl Default for Session {
861 fn default() -> Self {
862 Self::new().0
863 }
864}
865
866fn inject_identity(config_toml: String, identity: &Option<String>) -> String {
871 let Some(identity) = identity.as_ref().filter(|s| !s.is_empty()) else {
872 return config_toml;
873 };
874
875 let Ok(mut table) = config_toml.parse::<toml::Value>() else {
876 return config_toml;
877 };
878
879 let lifecycle = table
880 .get_mut("lifecycle")
881 .and_then(|v| v.as_table_mut());
882
883 if let Some(lifecycle) = lifecycle {
884 let existing = lifecycle
885 .get("system_template")
886 .and_then(|v| v.as_str())
887 .unwrap_or("");
888 let combined = format!("{}\n\n{}", identity, existing);
889 lifecycle.insert(
890 "system_template".to_string(),
891 toml::Value::String(combined),
892 );
893 } else {
894 let mut ltable = toml::value::Table::new();
896 ltable.insert(
897 "system_template".to_string(),
898 toml::Value::String(identity.clone()),
899 );
900 if let Some(table) = table.as_table_mut() {
901 table.insert("lifecycle".to_string(), toml::Value::Table(ltable));
902 }
903 }
904
905 toml::to_string(&table).unwrap_or(config_toml)
906}
907
908fn inject_model(config_toml: String, model: Option<String>) -> String {
922 let Some(model_name) = model.as_ref().filter(|s| !s.is_empty()) else {
923 return config_toml;
924 };
925
926 let Ok(mut table) = config_toml.parse::<toml::Value>() else {
927 return config_toml;
928 };
929
930 let llm = match table.get_mut("llm").and_then(|v| v.as_table_mut()) {
931 Some(l) => l,
932 None => return config_toml,
933 };
934
935 let looks_like_provider = |v: &toml::Value| -> bool {
937 v.as_table()
938 .map(|t| {
939 t.contains_key("base_url")
940 || t.contains_key("api_key")
941 || t.contains_key("models")
942 })
943 .unwrap_or(false)
944 };
945 let offers_model = |v: &toml::Value| -> bool {
947 v.get("models")
948 .and_then(|m| m.as_array())
949 .map(|arr| arr.iter().any(|m| m.as_str() == Some(model_name)))
950 .unwrap_or(false)
951 || v.get("model").and_then(|m| m.as_str()) == Some(model_name.as_str())
952 };
953
954 let providers = llm.get("providers").cloned();
955
956 if let Some(ref ps) = providers {
958 if let Some(selected) = ps.get(model_name.as_str()) {
959 if looks_like_provider(selected) {
960 let mut selected = selected.clone();
961 if let Some(st) = selected.as_table_mut() {
962 st.entry("provider_type".to_string())
963 .or_insert_with(|| toml::Value::String("openai".to_string()));
964 }
965 llm.insert("provider".to_string(), selected);
966 return toml::to_string(&table).unwrap_or(config_toml);
967 }
968 }
969 }
970
971 if let Some(ref ps) = providers {
973 if let Some(ptable) = ps.as_table() {
974 for (_key, entry) in ptable {
975 if looks_like_provider(entry) && offers_model(entry) {
976 let mut selected = entry.clone();
977 if let Some(st) = selected.as_table_mut() {
978 st.insert(
979 "model".to_string(),
980 toml::Value::String(model_name.clone()),
981 );
982 st.entry("provider_type".to_string())
983 .or_insert_with(|| toml::Value::String("openai".to_string()));
984 }
985 llm.insert("provider".to_string(), selected);
986 return toml::to_string(&table).unwrap_or(config_toml);
987 }
988 }
989 }
990 }
991
992 if let Some(provider) = llm.get_mut("provider").and_then(|p| p.as_table_mut()) {
994 provider.insert(
995 "model".to_string(),
996 toml::Value::String(model_name.clone()),
997 );
998 }
999
1000 toml::to_string(&table).unwrap_or(config_toml)
1001}
1002pub struct TuiForwardSink {
1007 tx: mpsc::UnboundedSender<TuiMessage>,
1008 stream_state: AtomicU8,
1009}
1010
1011const STREAM_IDLE: u8 = 0;
1013const STREAM_REASONING: u8 = 1;
1014const STREAM_CONTENT: u8 = 2;
1015
1016impl TuiForwardSink {
1017 pub fn new(tx: mpsc::UnboundedSender<TuiMessage>) -> Self {
1018 Self {
1019 tx,
1020 stream_state: AtomicU8::new(STREAM_IDLE),
1021 }
1022 }
1023}
1024
1025impl abk::orchestration::output::OutputSink for TuiForwardSink {
1026 fn emit(&self, event: abk::orchestration::output::OutputEvent) {
1027 use abk::orchestration::output::OutputEvent;
1028
1029 let msg = match event {
1030 OutputEvent::StreamingChunk { delta } => {
1031 if delta.is_empty() {
1032 return;
1033 }
1034 let prev = self.stream_state.swap(STREAM_CONTENT, Ordering::Relaxed);
1035 if prev != STREAM_CONTENT {
1036 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
1037 }
1038 let _ = self.tx.send(TuiMessage::StreamDelta(delta));
1039 return;
1040 }
1041
1042 OutputEvent::LlmResponse { text, model } => {
1043 TuiMessage::OutputLine(format!("[{}] {}", model, text))
1044 }
1045
1046 OutputEvent::Info { message } => {
1047 if message.contains("API call completed successfully") {
1049 return;
1050 }
1051 TuiMessage::OutputLine(message)
1052 }
1053
1054 OutputEvent::WorkflowStarted { task_description } => {
1055 TuiMessage::OutputLine(format!("🚀 Workflow started: {}", task_description))
1056 }
1057
1058 OutputEvent::WorkflowCompleted { reason, iterations } => {
1059 TuiMessage::OutputLine(format!(
1060 "✅ Workflow completed after {} iterations: {}",
1061 iterations, reason
1062 ))
1063 }
1064
1065 OutputEvent::IterationStarted { iteration, context_tokens } => {
1066 let _ = self.tx.send(TuiMessage::ContextTokensUpdated(context_tokens));
1067 TuiMessage::OutputLine(format!(
1068 "📡 Iteration {} | Context = {} tokens",
1069 iteration, context_tokens
1070 ))
1071 }
1072
1073 OutputEvent::ApiCallStarted {
1074 call_number,
1075 model,
1076 tool_count,
1077 streaming,
1078 context_tokens,
1079 tool_tokens,
1080 } => {
1081 let mode = if streaming { "Streaming" } else { "Non-streaming" };
1082 let total = context_tokens + tool_tokens;
1083 let _ = self.tx.send(TuiMessage::ContextTokensUpdated(total));
1084 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
1086 TuiMessage::OutputLine(format!(
1087 "🔥 API Call {} | Ctx={}({}+{}) | {} | Model: {} | Tools: {}",
1088 call_number, total, context_tokens, tool_tokens, mode, model, tool_count
1089 ))
1090 }
1091
1092 OutputEvent::ToolsExecuting { tool_names, hints } => {
1093 for (name, hint) in tool_names.into_iter().zip(hints.into_iter()) {
1094 let _ = self.tx.send(TuiMessage::ToolPending { tool_name: name, hint });
1095 }
1096 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
1097 return;
1098 }
1099
1100 OutputEvent::ToolCompleted {
1101 tool_name,
1102 success,
1103 content,
1104 description,
1105 } => {
1106 if tool_name == "todowrite" && success {
1107 let _ = self.tx.send(TuiMessage::TodoUpdate(content.clone()));
1108 }
1109 let hint = description;
1110 let _ = self.tx.send(TuiMessage::ToolDone { tool_name, success, hint });
1111 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
1112 return;
1113 }
1114
1115 OutputEvent::Error { message, context } => {
1116 if let Some(ctx) = context {
1117 TuiMessage::OutputLine(format!("❌ Error: {} — {}", message, ctx))
1118 } else {
1119 TuiMessage::OutputLine(format!("❌ Error: {}", message))
1120 }
1121 }
1122
1123 OutputEvent::ReasoningChunk { delta } => {
1124 if delta.is_empty() {
1125 return;
1126 }
1127 let prev = self.stream_state.swap(STREAM_REASONING, Ordering::Relaxed);
1128 if prev != STREAM_REASONING {
1129 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
1130 }
1131 let _ = self.tx.send(TuiMessage::ReasoningDelta(delta));
1132 return;
1133 }
1134
1135 OutputEvent::McpServerStatus { name, connected, tool_count, error } => {
1136 let _ = self.tx.send(TuiMessage::McpServerStatus {
1137 name,
1138 connected,
1139 tool_count,
1140 error,
1141 });
1142 return;
1143 }
1144 };
1145
1146 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
1147 let _ = self.tx.send(msg);
1148 }
1149}