use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use bcode_agent_profile::AgentInfo;
use bcode_client::{AttachedSessionHistory, BcodeClient};
use bcode_session_models::SessionId;
use bmux_tui::geometry::Rect;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use super::app::BmuxApp;
use super::{TuiError, history_flow};
static PRESENTATION_NOTE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatSessionAttachment {
Draft,
Opening {
session_id: SessionId,
anchor_sequence: Option<u64>,
},
Attached { session_id: SessionId },
Detached { session_id: SessionId },
}
impl ChatSessionAttachment {
#[must_use]
pub const fn viewing_session_id(&self) -> Option<SessionId> {
match self {
Self::Draft => None,
Self::Opening { session_id, .. }
| Self::Attached { session_id }
| Self::Detached { session_id } => Some(*session_id),
}
}
#[must_use]
pub const fn attached_session_id(&self) -> Option<SessionId> {
match self {
Self::Draft | Self::Opening { .. } => None,
Self::Attached { session_id } | Self::Detached { session_id } => Some(*session_id),
}
}
#[must_use]
pub const fn opening_session_id(&self) -> Option<SessionId> {
match self {
Self::Opening { session_id, .. } => Some(*session_id),
Self::Draft | Self::Attached { .. } | Self::Detached { .. } => None,
}
}
#[must_use]
pub const fn opening_anchor_sequence(&self) -> Option<u64> {
match self {
Self::Opening {
anchor_sequence, ..
} => *anchor_sequence,
Self::Draft | Self::Attached { .. } | Self::Detached { .. } => None,
}
}
}
pub struct ActiveChat {
pub app: BmuxApp,
pub agents: AgentCatalog,
pub attachment: ChatSessionAttachment,
pub event_sender: mpsc::Sender<history_flow::SessionStreamUpdate>,
pub event_receiver: mpsc::Receiver<history_flow::SessionStreamUpdate>,
pub event_task: Option<JoinHandle<()>>,
pub opening_session_progress: Option<bcode_session_models::SessionOpenOperationSnapshot>,
pub pending_effects: super::effects::TuiEffectQueue,
}
impl ActiveChat {
#[must_use]
pub const fn viewing_session_id(&self) -> Option<SessionId> {
self.attachment.viewing_session_id()
}
#[must_use]
pub const fn attached_session_id(&self) -> Option<SessionId> {
self.attachment.attached_session_id()
}
#[must_use]
pub const fn opening_session_id(&self) -> Option<SessionId> {
self.attachment.opening_session_id()
}
pub const fn mark_attached(&mut self, session_id: SessionId) {
self.attachment = ChatSessionAttachment::Attached { session_id };
}
pub const fn mark_stream_detached(&mut self, session_id: SessionId) {
self.attachment = ChatSessionAttachment::Detached { session_id };
}
#[cfg(test)]
pub fn queued_effect_count(&self) -> usize {
self.pending_effects.queued_effect_count()
}
pub fn append_durable_presentation_note(
&mut self,
source_id: impl Into<String>,
text: String,
format: bcode_command::CommandTextFormat,
) {
let source_id = source_id.into();
if let Some(session_id) = self.attached_session_id() {
self.pending_effects
.start_ordered(super::effects::TuiEffect::AppendPresentationNote {
session_id,
source_id,
note_id: format!(
"{:020}-{}",
PRESENTATION_NOTE_SEQUENCE.fetch_add(1, Ordering::Relaxed),
uuid::Uuid::new_v4()
),
text,
format,
});
return;
}
match format {
bcode_command::CommandTextFormat::PlainText => {
self.app.push_ephemeral_system_plain(text);
}
bcode_command::CommandTextFormat::Markdown => {
self.app.push_ephemeral_system_markdown(text);
}
bcode_command::CommandTextFormat::Json => {
self.app.push_ephemeral_system_json(text);
}
}
}
pub fn push_presentation_markdown(&mut self, source_id: impl Into<String>, text: String) {
self.append_durable_presentation_note(
source_id,
text,
bcode_command::CommandTextFormat::Markdown,
);
}
pub fn start_effect(&mut self, mut effect: super::effects::TuiEffect) {
if self.app.stamp_child_effect(&mut effect) {
self.pending_effects.replace(effect);
} else {
self.pending_effects.start(effect);
}
}
pub fn replace_effect(&mut self, mut effect: super::effects::TuiEffect) {
self.app.stamp_child_effect(&mut effect);
self.pending_effects.replace(effect);
}
pub fn queue_latest_effect(&mut self, effect: super::effects::TuiEffect) {
self.pending_effects.queue_latest(effect);
}
}
#[derive(Debug, Clone, Default)]
pub struct AgentCatalog {
agents: Vec<AgentInfo>,
by_id: BTreeMap<String, AgentInfo>,
}
impl AgentCatalog {
pub async fn load(client: &BcodeClient) -> Result<Self, TuiError> {
Ok(Self::from_agents(client.list_agents().await?))
}
#[must_use]
pub fn from_agents(agents: Vec<AgentInfo>) -> Self {
let by_id = agents
.iter()
.map(|agent| (agent.id.clone(), agent.clone()))
.collect();
Self { agents, by_id }
}
pub fn apply_agent_to_app(&self, app: &mut BmuxApp, agent_id: impl Into<String>) {
let agent_id = agent_id.into();
let accent = self
.by_id
.get(&agent_id)
.and_then(|agent| agent.accent.clone());
app.set_current_agent(agent_id, accent);
}
pub fn refresh_app_agent_metadata(&self, app: &mut BmuxApp) {
let agent_id = app.current_agent_id().to_owned();
self.apply_agent_to_app(app, agent_id);
}
pub fn apply_runtime_selection(
&self,
app: &mut BmuxApp,
selection: bcode_ipc::SessionRuntimeSelection,
) {
let accent = selection
.agent_id
.as_ref()
.and_then(|id| self.by_id.get(id))
.and_then(|agent| agent.accent.clone());
app.apply_runtime_selection(selection, accent);
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.agents.is_empty()
}
#[must_use]
pub fn next_agent(&self, current_agent_id: &str) -> Option<&AgentInfo> {
next_agent(&self.agents, current_agent_id)
}
}
#[must_use]
pub fn next_agent<'a>(agents: &'a [AgentInfo], current_agent_id: &str) -> Option<&'a AgentInfo> {
if agents.is_empty() {
return None;
}
if let Some(index) = agents.iter().position(|agent| agent.id == current_agent_id) {
return agents.get((index + 1) % agents.len());
}
agents
.iter()
.find(|agent| agent.is_default)
.or_else(|| agents.first())
}
#[must_use]
pub fn initial_transcript_window_request(
transcript_area: Rect,
) -> bcode_session_models::ProjectionWindowRequest {
history_flow::initial_transcript_window_request(transcript_area)
}
pub fn start_switch_session(
chat: &mut ActiveChat,
next_session_id: SessionId,
mut initial_window_request: bcode_session_models::ProjectionWindowRequest,
) {
let saved = chat.app.saved_reading_sequence(next_session_id);
if let Some(sequence) = saved {
initial_window_request.anchor =
bcode_session_models::ProjectionWindowAnchor::AroundSequence(sequence);
}
start_switch_session_at_sequence(chat, next_session_id, initial_window_request, None);
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn start_switch_session_from_search_hit(
chat: &mut ActiveChat,
hydrated: &bcode_session_search::HydratedSessionSearchHit,
mut initial_window_request: bcode_session_models::ProjectionWindowRequest,
) -> Result<(), super::session_search_effect::SessionSearchNavigationUnavailable> {
let target = super::session_search_effect::canonical_navigation_target(hydrated)?;
initial_window_request.anchor =
bcode_session_models::ProjectionWindowAnchor::AroundSequence(target.sequence);
start_switch_session_at_sequence(
chat,
target.session_id,
initial_window_request,
Some(target.sequence),
);
Ok(())
}
fn start_switch_session_at_sequence(
chat: &mut ActiveChat,
next_session_id: SessionId,
initial_window_request: bcode_session_models::ProjectionWindowRequest,
anchor_sequence: Option<u64>,
) {
if let Some(event_task) = chat.event_task.take() {
event_task.abort();
}
while chat.event_receiver.try_recv().is_ok() {}
let draft_text = chat.app.composer().text().to_owned();
chat.attachment = ChatSessionAttachment::Opening {
session_id: next_session_id,
anchor_sequence,
};
chat.opening_session_progress = None;
let previous_app = std::mem::replace(
&mut chat.app,
BmuxApp::new_with_history(Some(next_session_id), &[], &[], false),
);
chat.app.take_cross_session_state_from(&previous_app);
chat.app
.take_same_session_transcript_state_from(&previous_app);
chat.app
.take_same_session_reasoning_state_from(&previous_app);
chat.agents.refresh_app_agent_metadata(&mut chat.app);
if !draft_text.is_empty() {
chat.app.replace_composer_with(&draft_text);
}
chat.app.set_status("Opening session…".to_owned());
chat.replace_effect(super::effects::TuiEffect::OpenSession {
session_id: next_session_id,
initial_window_request,
event_sender: chat.event_sender.clone(),
allow_daemon_start: true,
});
}
pub fn complete_switch_session(
chat: &mut ActiveChat,
session_id: SessionId,
has_older_history: bool,
result: Result<(AttachedSessionHistory, JoinHandle<()>), TuiError>,
) {
if chat.opening_session_id() != Some(session_id) {
if let Ok((_, event_task)) = result {
event_task.abort();
}
return;
}
chat.opening_session_progress = None;
let anchor_sequence = chat.attachment.opening_anchor_sequence();
match result {
Ok((attached, next_task)) => {
let draft_text = chat.app.composer().text().to_owned();
chat.event_task = Some(next_task);
chat.mark_attached(session_id);
let previous_app = std::mem::replace(
&mut chat.app,
BmuxApp::new_with_history(
Some(session_id),
&attached.history,
&attached.input_history,
has_older_history,
),
);
chat.app.apply_submission_records(&attached.submissions);
chat.app.take_cross_session_state_from(&previous_app);
chat.app
.take_same_session_transcript_state_from(&previous_app);
chat.app
.take_same_session_reasoning_state_from(&previous_app);
chat.agents.refresh_app_agent_metadata(&mut chat.app);
if !draft_text.is_empty() {
chat.app.replace_composer_with(&draft_text);
} else if let Some(draft) = attached.draft {
chat.app.replace_composer_with(&draft);
}
chat.app.apply_session_summary(&attached.session);
if anchor_sequence.is_none() {
chat.app.restore_saved_reading_position();
}
chat.app.apply_usage_summary(&attached.usage_summary);
chat.agents
.apply_runtime_selection(&mut chat.app, attached.runtime_selection.clone());
chat.app
.set_status("session writable and attached".to_owned());
if let Some(sequence) = anchor_sequence {
if chat.app.transcript_index_for_sequence(sequence).is_some() {
chat.app.request_transcript_top_anchor_sequence(sequence);
chat.app.set_status("jumped to search result".to_owned());
} else {
chat.app.set_status(format!(
"search result seq {sequence} was not in the loaded canonical window"
));
}
}
chat.replace_effect(super::effects::TuiEffect::LoadSessionStatus { session_id });
chat.start_effect(super::effects::TuiEffect::ListPermissions);
}
Err(error) => {
chat.attachment = ChatSessionAttachment::Draft;
chat.app.set_status(format!("session open failed: {error}"));
chat.app
.push_ephemeral_system_plain(format!("session open failed: {error}"));
}
}
}
pub fn auth_security_status(config: &bcode_config::BcodeConfig) -> Option<String> {
let selection = config.resolved_model_selection();
let auth_profile_name = std::env::var(bcode_config::BCODE_AUTH_PROFILE_ENV)
.ok()
.filter(|profile| !profile.trim().is_empty())
.or(selection.auth_profile)?;
let auth_profile = config.auth.profiles.get(&auth_profile_name)?;
if auth_profile.backend != "sshenv" {
return None;
}
let vault = auth_profile.settings.get("vault").map_or_else(
bcode_config::default_auth_vault_path,
std::path::PathBuf::from,
);
let profile = auth_profile
.settings
.get("profile")
.map_or(auth_profile_name.as_str(), String::as_str);
let options = bcode_provider_auth::security::device_seal_options_for_auth_profile(auth_profile);
let report = bcode_provider_auth::security::reconcile_auth_vault_security_report_with_options(
&vault,
profile,
options,
auth_profile
.settings
.get("recipient_key")
.map(String::as_str),
);
report
.diagnostics
.iter()
.find(|diagnostic| {
diagnostic.severity
== bcode_provider_auth::security::AuthSecurityDiagnosticSeverity::Error
})
.or_else(|| {
report.diagnostics.iter().find(|diagnostic| {
diagnostic.severity
== bcode_provider_auth::security::AuthSecurityDiagnosticSeverity::Warning
})
})
.map(|diagnostic| format!("⚠ {} Run `bcode auth status`.", diagnostic.message))
}
pub fn switch_to_draft_session(chat: &mut ActiveChat) {
if let Some(event_task) = chat.event_task.take() {
event_task.abort();
}
while chat.event_receiver.try_recv().is_ok() {}
chat.attachment = ChatSessionAttachment::Draft;
chat.opening_session_progress = None;
let current_agent_id = chat.app.current_agent_id().to_owned();
let previous_app = std::mem::replace(
&mut chat.app,
BmuxApp::new_with_history(None, &[], &[], false),
);
chat.app.take_cross_session_state_from(&previous_app);
chat.app.clear_pending_reasoning_effort_for_session_change();
chat.agents
.apply_agent_to_app(&mut chat.app, current_agent_id);
chat.app.set_status("New draft".to_owned());
}