use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use abk::cli::ResumeInfo;
use abk::context::RunContext;
use std::sync::atomic::{AtomicU8, Ordering};
use crate::types::{
AutoHandoffConfig, BuildInfo, CapturedText, HandoffCaptureSink, McpServerInfo, McpServerStatus,
TuiMessage, WorkflowState,
};
pub struct Session {
pub input: String,
pub output_lines: Vec<String>,
pub workflow_tx: mpsc::UnboundedSender<TuiMessage>,
pub workflow_state: WorkflowState,
pub config_toml: Option<String>,
pub secrets: Option<HashMap<String, String>>,
pub build_info: Option<BuildInfo>,
pub resume_info: Option<ResumeInfo>,
pub backup_resume_info: Option<ResumeInfo>,
pub todo_lines: Vec<String>,
pub cancel_token: CancellationToken,
pub pending_command: Option<String>,
pub handoff_pending: bool,
pub pending_tool_lines: Vec<(String, usize, Option<String>)>,
pub current_context_tokens: usize,
pub auto_handoff: AutoHandoffConfig,
pub mcp_servers: Vec<McpServerInfo>,
pub should_quit: bool,
pub auto_scroll: bool,
pub agent_name: String,
pub token_store: Option<Arc<dyn pep::token_store::TokenStore>>,
pub project_id: Option<String>,
pub project_name: Option<String>,
pub session_id: Option<String>,
pub session_name: Option<String>,
pub workflow_permit: Option<tokio::sync::OwnedSemaphorePermit>,
}
impl Session {
pub fn new() -> (Self, mpsc::UnboundedReceiver<TuiMessage>) {
let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
let session = Self {
input: String::new(),
output_lines: Vec::new(),
workflow_tx,
workflow_state: WorkflowState::Idle,
config_toml: None,
secrets: None,
build_info: None,
resume_info: None,
backup_resume_info: None,
todo_lines: Vec::new(),
cancel_token: CancellationToken::new(),
pending_command: None,
handoff_pending: false,
pending_tool_lines: Vec::new(),
current_context_tokens: 0,
auto_handoff: AutoHandoffConfig::default(),
mcp_servers: Vec::new(),
should_quit: false,
auto_scroll: true,
agent_name: "trustee".to_string(),
token_store: None,
project_id: None,
project_name: None,
session_id: None,
session_name: None,
workflow_permit: None,
};
(session, workflow_rx)
}
pub fn parse_auto_handoff_config(&mut self) {
if let Some(ref config_toml) = self.config_toml {
self.auto_handoff = crate::config::parse_auto_handoff_config(config_toml);
}
}
pub fn handle_workflow_message(&mut self, msg: TuiMessage) {
match msg {
TuiMessage::WorkflowCancelled => {
self.output_lines.push("⏹ Workflow cancelled".to_string());
self.output_lines.push("".to_string());
self.workflow_state = WorkflowState::Cancelling;
}
TuiMessage::OutputLine(line) => {
self.output_lines.push(line);
}
TuiMessage::StreamDelta(delta) => {
if let Some(last) = self.output_lines.last_mut() {
last.push_str(&delta);
} else {
self.output_lines.push(delta);
}
}
TuiMessage::ReasoningDelta(delta) => {
if let Some(last) = self.output_lines.last_mut() {
if !last.starts_with('\x01') {
last.insert(0, '\x01');
}
last.push_str(&delta);
} else {
self.output_lines.push(format!("\x01{}", delta));
}
}
TuiMessage::WorkflowCompleted => {
self.output_lines.push("✓ Workflow completed".to_string());
self.output_lines.push("".to_string());
if self.workflow_state == WorkflowState::Running {
self.workflow_state = WorkflowState::Cancelling;
}
}
TuiMessage::WorkflowError(err) => {
self.output_lines.push(format!("✗ Error: {}", err));
self.output_lines.push("".to_string());
if self.workflow_state == WorkflowState::Running {
self.workflow_state = WorkflowState::Cancelling;
}
}
TuiMessage::TodoUpdate(content) => {
self.todo_lines = content.lines().map(|l| l.to_string()).collect();
}
TuiMessage::ToolPending { tool_name, hint } => {
let label = match &hint {
Some(h) => format!("⠋ {} {}", tool_name, h),
None => format!("⠋ {}", tool_name),
};
let idx = self.output_lines.len();
self.output_lines.push(label);
self.pending_tool_lines.push((tool_name, idx, hint));
}
TuiMessage::ToolDone { tool_name, success, hint } => {
let status = if success { "✓" } else { "✗" };
if let Some(pos) = self.pending_tool_lines.iter().position(|(n, _, _)| *n == tool_name) {
let (_, idx, pending_hint) = self.pending_tool_lines.remove(pos);
let h = hint.or(pending_hint);
let label = match &h {
Some(h) => format!("{} {} {}", status, tool_name, h),
None => format!("{} {}", status, tool_name),
};
if idx < self.output_lines.len() {
self.output_lines[idx] = label;
return;
}
self.output_lines.push(label);
} else {
let label = match &hint {
Some(h) => format!("{} {} {}", status, tool_name, h),
None => format!("{} {}", status, tool_name),
};
self.output_lines.push(label);
}
}
TuiMessage::ResumeInfo(info) => {
if self.workflow_state == WorkflowState::Cancelling && info.is_none() {
self.resume_info = self.backup_resume_info.take();
} else if info.is_some() {
self.resume_info = info;
self.backup_resume_info = None;
}
if let Some(ref ri) = self.resume_info {
if self.session_id.is_none() {
self.session_id = Some(ri.session_id.clone());
}
}
if self.workflow_state == WorkflowState::Cancelling {
self.workflow_state = WorkflowState::Idle;
self.workflow_permit = None;
}
if self.resume_info.is_some() {
if std::env::var("RUST_LOG")
.map(|v| v.to_lowercase().contains("debug"))
.unwrap_or(false)
{
self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
}
}
if self.workflow_state == WorkflowState::Idle && self.handoff_pending {
self.handoff_pending = false;
self.trigger_handoff(String::new());
} else if let Some(cmd) = self.pending_command.take() {
self.input = cmd;
self.execute_command();
}
}
TuiMessage::ContextTokensUpdated(count) => {
self.current_context_tokens = count;
if self.auto_handoff.enabled
&& count >= self.auto_handoff.context_threshold
&& self.workflow_state == WorkflowState::Running
&& !self.handoff_pending
&& self.resume_info.is_some()
{
self.handoff_pending = true;
self.cancel_token.cancel();
self.workflow_state = WorkflowState::Cancelling;
self.output_lines.push(format!(
"🔄 Auto-handoff: cancelling workflow, context tokens ({}) ≥ threshold ({})",
count, self.auto_handoff.context_threshold
));
}
}
TuiMessage::McpServerStatus { name, connected, tool_count, error } => {
let status = if connected { McpServerStatus::Connected } else { McpServerStatus::Failed };
if let Some(existing) = self.mcp_servers.iter_mut().find(|s| s.name == name) {
existing.status = status;
existing.tool_count = tool_count;
existing.error = error;
} else {
self.mcp_servers.push(McpServerInfo { name, status, tool_count, error });
}
}
TuiMessage::HandoffReady(briefing) => {
self.workflow_state = WorkflowState::Idle;
self.resume_info = None;
self.input = briefing;
self.execute_command();
}
}
if self.auto_scroll {
}
}
pub fn execute_command(&mut self) {
let command = self.input.trim().to_string();
if self.workflow_state != WorkflowState::Idle {
self.pending_command = Some(command);
self.output_lines.push("⏳ Previous workflow finishing — command queued".to_string());
self.input.clear();
return;
}
let is_continuation = self.resume_info.is_some();
if !is_continuation {
self.output_lines.clear();
if self.session_id.is_none() {
let timestamp = chrono::Utc::now().format("%Y_%m_%d_%H_%M");
let slug = command
.chars()
.take(30)
.filter(|c| c.is_alphanumeric() || *c == ' ')
.collect::<String>()
.split_whitespace()
.take(3)
.collect::<Vec<&str>>()
.join("_")
.to_lowercase();
self.session_id = Some(format!("session_{}_{}", timestamp, slug));
}
if self.session_name.is_none() {
let derived = if command.len() > 80 {
format!("{}...", &command[..77])
} else {
command.clone()
};
self.session_name = Some(derived);
}
}
self.output_lines.push(format!("> {}", command));
let config_toml = match &self.config_toml {
Some(c) => c.clone(),
None => {
self.output_lines.push("✗ Error: Configuration not loaded".to_string());
self.output_lines.push("".to_string());
return;
}
};
let secrets = self.secrets.clone().unwrap_or_default();
let build_info = self.build_info.clone();
let tx = self.workflow_tx.clone();
let agent_name = self.agent_name.clone();
let token_store = self.token_store.clone();
let project_id = self.project_id.clone();
let project_name = self.project_name.clone();
let session_id = self.session_id.clone();
let session_name = self.session_name.clone();
self.backup_resume_info = self.resume_info.clone();
let resume_info = self.resume_info.take();
self.workflow_state = WorkflowState::Running;
self.auto_scroll = true;
self.cancel_token = CancellationToken::new();
let child_token = self.cancel_token.clone();
let (resume_tx, mut resume_rx) = mpsc::unbounded_channel();
let resume_forward_tx = tx.clone();
tokio::spawn(async move {
while let Some(info) = resume_rx.recv().await {
resume_forward_tx.send(TuiMessage::ResumeInfo(info)).ok();
}
});
tokio::spawn(async move {
let tui_sink: abk::orchestration::output::SharedSink =
Arc::new(crate::session::TuiForwardSink::new(tx.clone()));
let mut run_ctx = RunContext::new()
.with_agent_name(agent_name.clone());
if project_id.is_some() || project_name.is_some() {
run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
id: project_id.unwrap_or_else(|| "default".to_string()),
name: project_name,
});
}
if session_id.is_some() || session_name.is_some() {
run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
id: session_id.unwrap_or_else(|| "default".to_string()),
name: session_name,
});
}
#[cfg(feature = "registry-mcp-token")]
{
if let Some(ref ts) = token_store {
run_ctx = run_ctx.with_token_store(ts.clone());
}
}
let result = abk::observability::with_tui_mode(true, async {
abk::cli::run_task_from_raw_config(
&config_toml,
secrets,
build_info,
&command,
Some(tui_sink),
resume_info,
Some(resume_tx),
Some(child_token),
Some(&run_ctx),
)
.await
})
.await;
let task_result = result.unwrap_or_else(|e| abk::cli::TaskResult {
success: false,
error: Some(e.to_string()),
resume_info: None,
});
let msg = if task_result.success {
TuiMessage::WorkflowCompleted
} else {
TuiMessage::WorkflowError(task_result.error.unwrap_or_default())
};
tx.send(msg).ok();
tx.send(TuiMessage::ResumeInfo(task_result.resume_info)).ok();
});
self.input.clear();
}
pub fn trigger_handoff(&mut self, hint: String) {
if self.resume_info.is_none() {
self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
return;
}
let config_toml = match &self.config_toml {
Some(c) => c.clone(),
None => {
self.output_lines.push("✗ Error: Configuration not loaded".to_string());
return;
}
};
let secrets = self.secrets.clone().unwrap_or_default();
let build_info = self.build_info.clone();
let tx = self.workflow_tx.clone();
let agent_name = self.agent_name.clone();
let token_store = self.token_store.clone();
let resume_info = self.resume_info.take();
self.workflow_state = WorkflowState::Running;
self.auto_scroll = true;
self.cancel_token = CancellationToken::new();
let child_token = self.cancel_token.clone();
self.output_lines.push("🔀 Generating session handoff briefing...".to_string());
tokio::spawn(async move {
let (cap_tx, mut cap_rx) = mpsc::unbounded_channel::<CapturedText>();
let cap_sink: abk::orchestration::output::SharedSink =
Arc::new(HandoffCaptureSink::new(cap_tx, child_token.clone()));
let base = "Output a session handoff briefing in at most 300 lines. \
Do NOT use any tools. Include: the FULL ABSOLUTE PATH of every \
project/repository being worked on (e.g. /Projects/Foo/bar — never \
omit the leading path), all project/task/workstream UUIDs referenced, \
every file created or modified with its full absolute path, all \
commands run and their outcomes, the current state of the work, any \
blockers, and the exact next action to take. \
Output ONLY the briefing text — no preamble, headers, or closing remarks.";
let prompt = if hint.is_empty() {
base.to_string()
} else {
format!("{base}\n\nIn the briefing also consider: {hint}")
};
let (dummy_tx, _dummy_rx) = mpsc::unbounded_channel();
let run_ctx = RunContext::new()
.with_agent_name(agent_name.clone());
#[cfg(feature = "registry-mcp-token")]
{
}
let _res = abk::observability::with_tui_mode(true, async {
abk::cli::run_task_from_raw_config(
&config_toml,
secrets,
build_info,
&prompt,
Some(cap_sink),
resume_info,
Some(dummy_tx),
Some(child_token),
Some(&run_ctx),
)
.await
})
.await;
let mut text_parts = String::new();
let mut reasoning_parts = String::new();
while let Ok(captured) = cap_rx.try_recv() {
match captured {
CapturedText::Text(s) => text_parts.push_str(&s),
CapturedText::Reasoning(s) => reasoning_parts.push_str(&s),
}
}
let briefing = if !text_parts.trim().is_empty() {
text_parts.trim().to_string()
} else if !reasoning_parts.trim().is_empty() {
reasoning_parts.trim().to_string()
} else {
"Session handoff: briefing unavailable — continue from previous context.".to_string()
};
tx.send(TuiMessage::HandoffReady(briefing)).ok();
});
}
}
impl Default for Session {
fn default() -> Self {
Self::new().0
}
}
pub struct TuiForwardSink {
tx: mpsc::UnboundedSender<TuiMessage>,
stream_state: AtomicU8,
}
const STREAM_IDLE: u8 = 0;
const STREAM_REASONING: u8 = 1;
const STREAM_CONTENT: u8 = 2;
impl TuiForwardSink {
pub fn new(tx: mpsc::UnboundedSender<TuiMessage>) -> Self {
Self {
tx,
stream_state: AtomicU8::new(STREAM_IDLE),
}
}
}
impl abk::orchestration::output::OutputSink for TuiForwardSink {
fn emit(&self, event: abk::orchestration::output::OutputEvent) {
use abk::orchestration::output::OutputEvent;
let msg = match event {
OutputEvent::StreamingChunk { delta } => {
if delta.is_empty() {
return;
}
let prev = self.stream_state.swap(STREAM_CONTENT, Ordering::Relaxed);
if prev != STREAM_CONTENT {
let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
}
let _ = self.tx.send(TuiMessage::StreamDelta(delta));
return;
}
OutputEvent::LlmResponse { text, model } => {
TuiMessage::OutputLine(format!("[{}] {}", model, text))
}
OutputEvent::Info { message } => {
if message.contains("API call completed successfully") {
return;
}
TuiMessage::OutputLine(message)
}
OutputEvent::WorkflowStarted { task_description } => {
TuiMessage::OutputLine(format!("🚀 Workflow started: {}", task_description))
}
OutputEvent::WorkflowCompleted { reason, iterations } => {
TuiMessage::OutputLine(format!(
"✅ Workflow completed after {} iterations: {}",
iterations, reason
))
}
OutputEvent::IterationStarted { iteration, context_tokens } => {
let _ = self.tx.send(TuiMessage::ContextTokensUpdated(context_tokens));
TuiMessage::OutputLine(format!(
"📡 Iteration {} | Context = {} tokens",
iteration, context_tokens
))
}
OutputEvent::ApiCallStarted {
call_number,
model,
tool_count,
streaming,
context_tokens,
tool_tokens,
} => {
let mode = if streaming { "Streaming" } else { "Non-streaming" };
let total = context_tokens + tool_tokens;
let _ = self.tx.send(TuiMessage::ContextTokensUpdated(total));
let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
TuiMessage::OutputLine(format!(
"🔥 API Call {} | Ctx={}({}+{}) | {} | Model: {} | Tools: {}",
call_number, total, context_tokens, tool_tokens, mode, model, tool_count
))
}
OutputEvent::ToolsExecuting { tool_names, hints } => {
for (name, hint) in tool_names.into_iter().zip(hints.into_iter()) {
let _ = self.tx.send(TuiMessage::ToolPending { tool_name: name, hint });
}
self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
return;
}
OutputEvent::ToolCompleted {
tool_name,
success,
content,
description,
} => {
if tool_name == "todowrite" && success {
let _ = self.tx.send(TuiMessage::TodoUpdate(content.clone()));
}
let hint = description;
let _ = self.tx.send(TuiMessage::ToolDone { tool_name, success, hint });
self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
return;
}
OutputEvent::Error { message, context } => {
if let Some(ctx) = context {
TuiMessage::OutputLine(format!("❌ Error: {} — {}", message, ctx))
} else {
TuiMessage::OutputLine(format!("❌ Error: {}", message))
}
}
OutputEvent::ReasoningChunk { delta } => {
if delta.is_empty() {
return;
}
let prev = self.stream_state.swap(STREAM_REASONING, Ordering::Relaxed);
if prev != STREAM_REASONING {
let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
}
let _ = self.tx.send(TuiMessage::ReasoningDelta(delta));
return;
}
OutputEvent::McpServerStatus { name, connected, tool_count, error } => {
let _ = self.tx.send(TuiMessage::McpServerStatus {
name,
connected,
tool_count,
error,
});
return;
}
};
self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
let _ = self.tx.send(msg);
}
}