1use std::collections::HashMap;
7use std::sync::Arc;
8
9use tokio::sync::mpsc;
10use tokio_util::sync::CancellationToken;
11
12use abk::cli::ResumeInfo;
13
14use std::sync::atomic::{AtomicU8, Ordering};
15
16use crate::types::{
17 AutoHandoffConfig, BuildInfo, CapturedText, HandoffCaptureSink, McpServerInfo, McpServerStatus,
18 TuiMessage, WorkflowState,
19};
20
21pub struct Session {
26 pub input: String,
28 pub output_lines: Vec<String>,
30 pub workflow_tx: mpsc::UnboundedSender<TuiMessage>,
32 pub workflow_state: WorkflowState,
34 pub config_toml: Option<String>,
36 pub secrets: Option<HashMap<String, String>>,
38 pub build_info: Option<BuildInfo>,
40 pub resume_info: Option<ResumeInfo>,
42 pub backup_resume_info: Option<ResumeInfo>,
45 pub todo_lines: Vec<String>,
47 pub cancel_token: CancellationToken,
49 pub pending_command: Option<String>,
51 pub handoff_pending: bool,
53 pub pending_tool_lines: Vec<(String, usize, Option<String>)>,
55 pub current_context_tokens: usize,
57 pub auto_handoff: AutoHandoffConfig,
59 pub mcp_servers: Vec<McpServerInfo>,
61 pub should_quit: bool,
63 pub auto_scroll: bool,
65}
66
67impl Session {
68 pub fn new() -> (Self, mpsc::UnboundedReceiver<TuiMessage>) {
73 let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
74 let session = Self {
75 input: String::new(),
76 output_lines: Vec::new(),
77 workflow_tx,
78 workflow_state: WorkflowState::Idle,
79 config_toml: None,
80 secrets: None,
81 build_info: None,
82 resume_info: None,
83 backup_resume_info: None,
84 todo_lines: Vec::new(),
85 cancel_token: CancellationToken::new(),
86 pending_command: None,
87 handoff_pending: false,
88 pending_tool_lines: Vec::new(),
89 current_context_tokens: 0,
90 auto_handoff: AutoHandoffConfig::default(),
91 mcp_servers: Vec::new(),
92 should_quit: false,
93 auto_scroll: true,
94 };
95 (session, workflow_rx)
96 }
97
98 pub fn parse_auto_handoff_config(&mut self) {
100 if let Some(ref config_toml) = self.config_toml {
101 self.auto_handoff = crate::config::parse_auto_handoff_config(config_toml);
102 }
103 }
104
105 pub fn handle_workflow_message(&mut self, msg: TuiMessage) {
110 match msg {
111 TuiMessage::WorkflowCancelled => {
112 self.output_lines.push("⏹ Workflow cancelled".to_string());
113 self.output_lines.push("".to_string());
114 self.workflow_state = WorkflowState::Cancelling;
115 }
116 TuiMessage::OutputLine(line) => {
117 self.output_lines.push(line);
118 }
119 TuiMessage::StreamDelta(delta) => {
120 if let Some(last) = self.output_lines.last_mut() {
121 last.push_str(&delta);
122 } else {
123 self.output_lines.push(delta);
124 }
125 }
126 TuiMessage::ReasoningDelta(delta) => {
127 if let Some(last) = self.output_lines.last_mut() {
128 if !last.starts_with('\x01') {
129 last.insert(0, '\x01');
130 }
131 last.push_str(&delta);
132 } else {
133 self.output_lines.push(format!("\x01{}", delta));
134 }
135 }
136 TuiMessage::WorkflowCompleted => {
137 self.output_lines.push("✓ Workflow completed".to_string());
138 self.output_lines.push("".to_string());
139 if self.workflow_state == WorkflowState::Running {
140 self.workflow_state = WorkflowState::Cancelling;
141 }
142 }
143 TuiMessage::WorkflowError(err) => {
144 self.output_lines.push(format!("✗ Error: {}", err));
145 self.output_lines.push("".to_string());
146 if self.workflow_state == WorkflowState::Running {
147 self.workflow_state = WorkflowState::Cancelling;
148 }
149 }
150 TuiMessage::TodoUpdate(content) => {
151 self.todo_lines = content.lines().map(|l| l.to_string()).collect();
152 }
153 TuiMessage::ToolPending { tool_name, hint } => {
154 let label = match &hint {
155 Some(h) => format!("⠋ {} {}", tool_name, h),
156 None => format!("⠋ {}", tool_name),
157 };
158 let idx = self.output_lines.len();
159 self.output_lines.push(label);
160 self.pending_tool_lines.push((tool_name, idx, hint));
161 }
162 TuiMessage::ToolDone { tool_name, success, hint } => {
163 let status = if success { "✓" } else { "✗" };
164 if let Some(pos) = self.pending_tool_lines.iter().position(|(n, _, _)| *n == tool_name) {
165 let (_, idx, pending_hint) = self.pending_tool_lines.remove(pos);
166 let h = hint.or(pending_hint);
167 let label = match &h {
168 Some(h) => format!("{} {} {}", status, tool_name, h),
169 None => format!("{} {}", status, tool_name),
170 };
171 if idx < self.output_lines.len() {
172 self.output_lines[idx] = label;
173 return;
174 }
175 self.output_lines.push(label);
176 } else {
177 let label = match &hint {
178 Some(h) => format!("{} {} {}", status, tool_name, h),
179 None => format!("{} {}", status, tool_name),
180 };
181 self.output_lines.push(label);
182 }
183 }
184 TuiMessage::ResumeInfo(info) => {
185 if self.workflow_state == WorkflowState::Cancelling && info.is_none() {
186 self.resume_info = self.backup_resume_info.take();
187 } else if info.is_some() {
188 self.resume_info = info;
194 self.backup_resume_info = None;
195 }
196 if self.workflow_state == WorkflowState::Cancelling {
198 self.workflow_state = WorkflowState::Idle;
199 }
200 if self.resume_info.is_some() {
201 if std::env::var("RUST_LOG")
202 .map(|v| v.to_lowercase().contains("debug"))
203 .unwrap_or(false)
204 {
205 self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
206 }
207 }
208 if self.workflow_state == WorkflowState::Idle && self.handoff_pending {
209 self.handoff_pending = false;
210 self.trigger_handoff(String::new());
211 } else if let Some(cmd) = self.pending_command.take() {
212 self.input = cmd;
213 self.execute_command();
214 }
215 }
216 TuiMessage::ContextTokensUpdated(count) => {
217 self.current_context_tokens = count;
218 if self.auto_handoff.enabled
219 && count >= self.auto_handoff.context_threshold
220 && self.workflow_state == WorkflowState::Running
221 && !self.handoff_pending
222 && self.resume_info.is_some()
223 {
224 self.handoff_pending = true;
225 self.cancel_token.cancel();
226 self.workflow_state = WorkflowState::Cancelling;
227 self.output_lines.push(format!(
228 "🔄 Auto-handoff: cancelling workflow, context tokens ({}) ≥ threshold ({})",
229 count, self.auto_handoff.context_threshold
230 ));
231 }
232 }
233 TuiMessage::McpServerStatus { name, connected, tool_count, error } => {
234 let status = if connected { McpServerStatus::Connected } else { McpServerStatus::Failed };
235 if let Some(existing) = self.mcp_servers.iter_mut().find(|s| s.name == name) {
236 existing.status = status;
237 existing.tool_count = tool_count;
238 existing.error = error;
239 } else {
240 self.mcp_servers.push(McpServerInfo { name, status, tool_count, error });
241 }
242 }
243 TuiMessage::HandoffReady(briefing) => {
244 self.workflow_state = WorkflowState::Idle;
245 self.resume_info = None;
246 self.input = briefing;
247 self.execute_command();
248 }
249 }
250 if self.auto_scroll {
251 }
254 }
255
256 pub fn execute_command(&mut self) {
261 let command = self.input.trim().to_string();
262
263 if self.workflow_state != WorkflowState::Idle {
264 self.pending_command = Some(command);
265 self.output_lines.push("⏳ Previous workflow finishing — command queued".to_string());
266 self.input.clear();
267 return;
268 }
269
270 let is_continuation = self.resume_info.is_some();
271
272 if !is_continuation {
273 self.output_lines.clear();
274 }
275
276 self.output_lines.push(format!("> {}", command));
277
278 let config_toml = match &self.config_toml {
279 Some(c) => c.clone(),
280 None => {
281 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
282 self.output_lines.push("".to_string());
283 return;
284 }
285 };
286
287 let secrets = self.secrets.clone().unwrap_or_default();
288 let build_info = self.build_info.clone();
289 let tx = self.workflow_tx.clone();
290
291 self.backup_resume_info = self.resume_info.clone();
292 let resume_info = self.resume_info.take();
293
294 self.workflow_state = WorkflowState::Running;
295 self.auto_scroll = true;
296
297 self.cancel_token = CancellationToken::new();
298 let child_token = self.cancel_token.clone();
299
300 let (resume_tx, mut resume_rx) = mpsc::unbounded_channel();
301
302 let resume_forward_tx = tx.clone();
303 tokio::spawn(async move {
304 while let Some(info) = resume_rx.recv().await {
305 resume_forward_tx.send(TuiMessage::ResumeInfo(info)).ok();
306 }
307 });
308
309 tokio::spawn(async move {
310 let tui_sink: abk::orchestration::output::SharedSink =
311 Arc::new(crate::session::TuiForwardSink::new(tx.clone()));
312
313 abk::observability::set_tui_mode(true);
314
315 let result = abk::cli::run_task_from_raw_config(
316 &config_toml,
317 secrets,
318 build_info,
319 &command,
320 Some(tui_sink),
321 resume_info,
322 Some(resume_tx),
323 Some(child_token),
324 )
325 .await;
326
327 abk::observability::set_tui_mode(false);
328
329 let task_result = result.unwrap_or_else(|e| abk::cli::TaskResult {
330 success: false,
331 error: Some(e.to_string()),
332 resume_info: None,
337 });
338
339 let msg = if task_result.success {
340 TuiMessage::WorkflowCompleted
341 } else {
342 TuiMessage::WorkflowError(task_result.error.unwrap_or_default())
343 };
344 tx.send(msg).ok();
345 tx.send(TuiMessage::ResumeInfo(task_result.resume_info)).ok();
346 });
347
348 self.input.clear();
349 }
350
351 pub fn trigger_handoff(&mut self, hint: String) {
356 if self.resume_info.is_none() {
357 self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
358 return;
359 }
360
361 let config_toml = match &self.config_toml {
362 Some(c) => c.clone(),
363 None => {
364 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
365 return;
366 }
367 };
368
369 let secrets = self.secrets.clone().unwrap_or_default();
370 let build_info = self.build_info.clone();
371 let tx = self.workflow_tx.clone();
372 let resume_info = self.resume_info.take();
373
374 self.workflow_state = WorkflowState::Running;
375 self.auto_scroll = true;
376 self.cancel_token = CancellationToken::new();
377 let child_token = self.cancel_token.clone();
378
379 self.output_lines.push("🔀 Generating session handoff briefing...".to_string());
380
381 tokio::spawn(async move {
382 let (cap_tx, mut cap_rx) = mpsc::unbounded_channel::<CapturedText>();
383 let cap_sink: abk::orchestration::output::SharedSink =
384 Arc::new(HandoffCaptureSink::new(cap_tx, child_token.clone()));
385
386 abk::observability::set_tui_mode(true);
387
388 let base = "Output a session handoff briefing in at most 300 lines. \
389 Do NOT use any tools. Include: the FULL ABSOLUTE PATH of every \
390 project/repository being worked on (e.g. /Projects/Foo/bar — never \
391 omit the leading path), all project/task/workstream UUIDs referenced, \
392 every file created or modified with its full absolute path, all \
393 commands run and their outcomes, the current state of the work, any \
394 blockers, and the exact next action to take. \
395 Output ONLY the briefing text — no preamble, headers, or closing remarks.";
396 let prompt = if hint.is_empty() {
397 base.to_string()
398 } else {
399 format!("{base}\n\nIn the briefing also consider: {hint}")
400 };
401
402 let (dummy_tx, _dummy_rx) = mpsc::unbounded_channel();
403 let _res = abk::cli::run_task_from_raw_config(
404 &config_toml,
405 secrets,
406 build_info,
407 &prompt,
408 Some(cap_sink),
409 resume_info,
410 Some(dummy_tx),
411 Some(child_token),
412 )
413 .await;
414
415 abk::observability::set_tui_mode(false);
416
417 let mut text_parts = String::new();
418 let mut reasoning_parts = String::new();
419 while let Ok(captured) = cap_rx.try_recv() {
420 match captured {
421 CapturedText::Text(s) => text_parts.push_str(&s),
422 CapturedText::Reasoning(s) => reasoning_parts.push_str(&s),
423 }
424 }
425
426 let briefing = if !text_parts.trim().is_empty() {
427 text_parts.trim().to_string()
428 } else if !reasoning_parts.trim().is_empty() {
429 reasoning_parts.trim().to_string()
430 } else {
431 "Session handoff: briefing unavailable — continue from previous context.".to_string()
432 };
433
434 tx.send(TuiMessage::HandoffReady(briefing)).ok();
435 });
436 }
437}
438
439impl Default for Session {
440 fn default() -> Self {
441 Self::new().0
442 }
443}
444
445pub struct TuiForwardSink {
451 tx: mpsc::UnboundedSender<TuiMessage>,
452 stream_state: AtomicU8,
453}
454
455const STREAM_IDLE: u8 = 0;
457const STREAM_REASONING: u8 = 1;
458const STREAM_CONTENT: u8 = 2;
459
460impl TuiForwardSink {
461 pub fn new(tx: mpsc::UnboundedSender<TuiMessage>) -> Self {
462 Self {
463 tx,
464 stream_state: AtomicU8::new(STREAM_IDLE),
465 }
466 }
467}
468
469impl abk::orchestration::output::OutputSink for TuiForwardSink {
470 fn emit(&self, event: abk::orchestration::output::OutputEvent) {
471 use abk::orchestration::output::OutputEvent;
472
473 let msg = match event {
474 OutputEvent::StreamingChunk { delta } => {
475 if delta.is_empty() {
476 return;
477 }
478 let prev = self.stream_state.swap(STREAM_CONTENT, Ordering::Relaxed);
479 if prev != STREAM_CONTENT {
480 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
481 }
482 let _ = self.tx.send(TuiMessage::StreamDelta(delta));
483 return;
484 }
485
486 OutputEvent::LlmResponse { text, model } => {
487 TuiMessage::OutputLine(format!("[{}] {}", model, text))
488 }
489
490 OutputEvent::Info { message } => {
491 if message.contains("API call completed successfully") {
493 return;
494 }
495 TuiMessage::OutputLine(message)
496 }
497
498 OutputEvent::WorkflowStarted { task_description } => {
499 TuiMessage::OutputLine(format!("🚀 Workflow started: {}", task_description))
500 }
501
502 OutputEvent::WorkflowCompleted { reason, iterations } => {
503 TuiMessage::OutputLine(format!(
504 "✅ Workflow completed after {} iterations: {}",
505 iterations, reason
506 ))
507 }
508
509 OutputEvent::IterationStarted { iteration, context_tokens } => {
510 let _ = self.tx.send(TuiMessage::ContextTokensUpdated(context_tokens));
511 TuiMessage::OutputLine(format!(
512 "📡 Iteration {} | Context = {} tokens",
513 iteration, context_tokens
514 ))
515 }
516
517 OutputEvent::ApiCallStarted {
518 call_number,
519 model,
520 tool_count,
521 streaming,
522 context_tokens,
523 tool_tokens,
524 } => {
525 let mode = if streaming { "Streaming" } else { "Non-streaming" };
526 let total = context_tokens + tool_tokens;
527 let _ = self.tx.send(TuiMessage::ContextTokensUpdated(total));
528 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
530 TuiMessage::OutputLine(format!(
531 "🔥 API Call {} | Ctx={}({}+{}) | {} | Model: {} | Tools: {}",
532 call_number, total, context_tokens, tool_tokens, mode, model, tool_count
533 ))
534 }
535
536 OutputEvent::ToolsExecuting { tool_names, hints } => {
537 for (name, hint) in tool_names.into_iter().zip(hints.into_iter()) {
538 let _ = self.tx.send(TuiMessage::ToolPending { tool_name: name, hint });
539 }
540 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
541 return;
542 }
543
544 OutputEvent::ToolCompleted {
545 tool_name,
546 success,
547 content,
548 description,
549 } => {
550 if tool_name == "todowrite" && success {
551 let _ = self.tx.send(TuiMessage::TodoUpdate(content.clone()));
552 }
553 let hint = description;
554 let _ = self.tx.send(TuiMessage::ToolDone { tool_name, success, hint });
555 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
556 return;
557 }
558
559 OutputEvent::Error { message, context } => {
560 if let Some(ctx) = context {
561 TuiMessage::OutputLine(format!("❌ Error: {} — {}", message, ctx))
562 } else {
563 TuiMessage::OutputLine(format!("❌ Error: {}", message))
564 }
565 }
566
567 OutputEvent::ReasoningChunk { delta } => {
568 if delta.is_empty() {
569 return;
570 }
571 let prev = self.stream_state.swap(STREAM_REASONING, Ordering::Relaxed);
572 if prev != STREAM_REASONING {
573 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
574 }
575 let _ = self.tx.send(TuiMessage::ReasoningDelta(delta));
576 return;
577 }
578
579 OutputEvent::McpServerStatus { name, connected, tool_count, error } => {
580 let _ = self.tx.send(TuiMessage::McpServerStatus {
581 name,
582 connected,
583 tool_count,
584 error,
585 });
586 return;
587 }
588 };
589
590 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
591 let _ = self.tx.send(msg);
592 }
593}