use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use bcode_config::{
TuiAccentTransitionCurve, TuiConfig, TuiDiffViewerConfig, TuiDiffViewerLayout, TuiThemeConfig,
TuiThinkingConfig,
};
use bcode_plugin_sdk::path::display;
#[cfg(test)]
use bcode_session_models::{ModelTurnOutcome, RuntimeWorkStatus};
use bcode_session_models::{
ProviderStreamEvent, SessionEvent, SessionEventKind, SessionHistoryCursor, SessionId,
SessionInputHistoryEntry, SessionLiveEvent, SessionLiveEventKind, SessionTraceEvent,
SessionTracePayload, SessionTracePhase, ToolInvocationResult,
};
use bmux_text_edit::{SelectionMode, TextEditBuffer, TextMotion};
use bmux_tui::event::MouseEvent;
use bmux_tui::geometry::Rect;
use bmux_tui::style::Color;
use bmux_tui_components::text_input::{
TextInputControl, TextInputOutcome, TextInputPolicy, TextInputState,
};
use super::activity::{ActivityState, model_turn_outcome_label, runtime_work_detail};
use super::cursor_blink::CursorBlink;
use super::exit_state::ExitState;
use super::input_history::{InputHistory, InputHistoryOutcome};
use super::invalidation::{InvalidationKey, InvalidationRequest, UiInvalidation};
use super::keymap::{BmuxAction, BmuxKeyActivation, BmuxKeyBinding, BmuxScope};
use super::older_history::OlderHistoryState;
use super::pending_submission::PendingSubmission;
use super::pending_submissions::PendingSubmissions;
use super::theme::definition::ResolvedThemeDefinition;
use super::theme::discovery::DiscoveredThemes;
use super::theme::{PresentedTheme, ResolvedTheme};
use super::timeline_dialog::TimelineEntry;
use super::transcript::{TranscriptItem, terminal_item_from_shared};
#[cfg(test)]
use super::transcript_document::TranscriptPresentationOrigin;
use super::transcript_document::{
SessionViewTerminalAdapter, TranscriptDocument, TranscriptDocumentDamage, TranscriptItems,
TranscriptPresentationEntryId,
};
use super::transcript_layout::{TranscriptLayoutCache, VisibleTranscriptSource};
use super::transcript_markdown_cache::TranscriptMarkdownCache;
use super::transcript_resident_window::{TranscriptResidentWindow, TranscriptWindowPolicy};
use super::transcript_viewport::TranscriptViewport;
const MANUAL_TRANSCRIPT_SCROLL_GRACE: Duration = Duration::from_millis(450);
const TRANSCRIPT_SCROLL_ANIMATION_DURATION: Duration = Duration::from_millis(180);
const TRANSCRIPT_SCROLL_ANIMATION_FRAME: Duration = Duration::from_millis(16);
const TRANSCRIPT_SCROLL_ANIMATION_INVALIDATION_KEY: &str = "transcript-scroll-animation";
const LATEST_BAR_ANIMATION_INVALIDATION_KEY: &str = "latest-bar-animation";
const THEME_TRANSITION_INVALIDATION_KEY: &str = "theme-transition";
const ACTIVITY_ANIMATION_INVALIDATION_KEY: &str = "activity-animation";
const ACTIVITY_ANIMATION_FRAME: Duration = Duration::from_millis(100);
const THEME_TRANSITION_FRAME: Duration = Duration::from_millis(16);
const LATEST_BAR_ACTIVE_WINDOW: Duration = Duration::from_millis(420);
const TOOL_ELAPSED_INVALIDATION_PREFIX: &str = "tool-elapsed";
const TOOL_ELAPSED_INVALIDATION_MAX_INTERVAL: Duration = Duration::from_secs(1);
const RESIDENT_TRANSCRIPT_MAX_EVENTS: usize = 1_024;
const RESIDENT_TRANSCRIPT_TARGET_EVENTS: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PendingKeyActivation {
scope: BmuxScope,
stroke: bmux_keyboard::KeyStroke,
action: BmuxAction,
taps: u8,
expires_at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyActivationOutcome {
Activated(BmuxAction),
Pending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TemporalDamage {
Composer,
Status,
LatestBar,
Transcript,
Full,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TranscriptAnimationTarget {
LatestBottom,
ItemTop(TranscriptPresentationEntryId),
Row,
}
#[derive(Debug, Clone, Default)]
enum TranscriptScrollClock {
#[default]
Realtime,
#[cfg(test)]
Fixed(Instant),
}
impl TranscriptScrollClock {
fn now(&self) -> Instant {
match self {
Self::Realtime => Instant::now(),
#[cfg(test)]
Self::Fixed(now) => *now,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TranscriptScrollAnimation {
start_top_row: usize,
target_top_row: usize,
target: TranscriptAnimationTarget,
started_at: Instant,
duration: Duration,
next_frame_at: Instant,
}
impl TranscriptScrollAnimation {
fn new(start_top_row: usize, target_top_row: usize, started_at: Instant) -> Self {
Self {
start_top_row,
target_top_row,
target: TranscriptAnimationTarget::Row,
started_at,
duration: TRANSCRIPT_SCROLL_ANIMATION_DURATION,
next_frame_at: started_at + TRANSCRIPT_SCROLL_ANIMATION_FRAME,
}
}
fn advance_frame(&mut self, now: Instant) {
let elapsed = now.saturating_duration_since(self.started_at);
let frame_count = elapsed.as_nanos() / TRANSCRIPT_SCROLL_ANIMATION_FRAME.as_nanos().max(1);
let next_offset = TRANSCRIPT_SCROLL_ANIMATION_FRAME
.as_nanos()
.saturating_mul(frame_count.saturating_add(1));
self.next_frame_at =
self.started_at + Duration::from_nanos(u64::try_from(next_offset).unwrap_or(u64::MAX));
}
fn top_row_at(self, now: Instant) -> usize {
let duration_ms = self.duration.as_millis().max(1);
let elapsed_ms = now
.saturating_duration_since(self.started_at)
.as_millis()
.min(duration_ms);
let remaining_ms = duration_ms.saturating_sub(elapsed_ms);
let denominator = duration_ms.saturating_pow(3);
let numerator = denominator.saturating_sub(remaining_ms.saturating_pow(3));
let start = i128::try_from(self.start_top_row).unwrap_or(i128::MAX);
let target = i128::try_from(self.target_top_row).unwrap_or(i128::MAX);
let delta = target.saturating_sub(start);
let eased_delta = delta.saturating_mul(i128::try_from(numerator).unwrap_or(i128::MAX))
/ i128::try_from(denominator).unwrap_or(1);
usize::try_from(start.saturating_add(eased_delta).max(0)).unwrap_or(usize::MAX)
}
fn finished(self, now: Instant) -> bool {
now.saturating_duration_since(self.started_at) >= self.duration
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ThemeTransitionState {
displayed_accent: Color,
source_accent: Color,
target_accent: Color,
started_at: Instant,
duration: Duration,
curve: TuiAccentTransitionCurve,
next_frame_at: Option<Instant>,
}
impl ThemeTransitionState {
const fn new(accent: Color, now: Instant) -> Self {
Self {
displayed_accent: accent,
source_accent: accent,
target_accent: accent,
started_at: now,
duration: Duration::ZERO,
curve: TuiAccentTransitionCurve::EaseOut,
next_frame_at: None,
}
}
fn set_target(&mut self, target: Color, config: &TuiThemeConfig, now: Instant) {
if self.target_accent == target {
self.displayed_accent = self.accent_at(now);
return;
}
let duration_ms = config.effective_accent_transition_ms();
if duration_ms == 0 {
self.displayed_accent = target;
self.source_accent = target;
self.target_accent = target;
self.started_at = now;
self.duration = Duration::ZERO;
self.curve = config.accent_transition_curve;
self.next_frame_at = None;
return;
}
self.source_accent = self.accent_at(now);
self.displayed_accent = self.source_accent;
self.target_accent = target;
self.started_at = now;
self.duration = Duration::from_millis(duration_ms);
self.curve = config.accent_transition_curve;
self.next_frame_at = Some(now + THEME_TRANSITION_FRAME);
}
fn accent_at(&self, now: Instant) -> Color {
if self.duration.is_zero() {
return self.target_accent;
}
let duration_ms = u64::try_from(self.duration.as_millis())
.unwrap_or(u64::MAX)
.max(1);
let elapsed_ms = u64::try_from(now.saturating_duration_since(self.started_at).as_millis())
.unwrap_or(u64::MAX)
.min(duration_ms);
if elapsed_ms >= duration_ms {
return self.target_accent;
}
interpolate_color(
self.source_accent,
self.target_accent,
elapsed_ms,
duration_ms,
self.curve,
)
}
fn update(&mut self, now: Instant) -> Color {
self.displayed_accent = self.accent_at(now);
if self.is_active(now) {
let elapsed = now.saturating_duration_since(self.started_at);
let frame_count = elapsed.as_nanos() / THEME_TRANSITION_FRAME.as_nanos().max(1);
let next_offset = THEME_TRANSITION_FRAME
.as_nanos()
.saturating_mul(frame_count.saturating_add(1));
self.next_frame_at = Some(
self.started_at
+ Duration::from_nanos(u64::try_from(next_offset).unwrap_or(u64::MAX)),
);
} else {
self.next_frame_at = None;
}
self.displayed_accent
}
fn is_active(&self, now: Instant) -> bool {
!self.duration.is_zero()
&& now.saturating_duration_since(self.started_at) < self.duration
&& self.displayed_accent != self.target_accent
}
const fn finish(&mut self) {
self.displayed_accent = self.target_accent;
self.source_accent = self.target_accent;
self.duration = Duration::ZERO;
self.next_frame_at = None;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AgentMetadataHydration {
Pending,
Hydrated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReasoningSupport {
Unsupported,
Supported,
}
impl ReasoningSupport {
const fn is_supported(self) -> bool {
matches!(self, Self::Supported)
}
}
#[cfg(test)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TranscriptFrameObservation {
pub semantic_items: Vec<(
bcode_session_view_models::TranscriptViewItemId,
bcode_session_view_models::ViewRevision,
)>,
pub terminal_items: Vec<(
super::transcript::TranscriptItemId,
Option<bcode_session_view_models::TranscriptViewItemId>,
Option<bcode_session_view_models::ViewRevision>,
)>,
pub terminal_rows: Vec<(
Option<bcode_session_view_models::TranscriptViewItemId>,
usize,
)>,
pub damage: TranscriptDocumentDamage,
pub viewport_top: usize,
pub scroll_mode: &'static str,
pub anchor: Option<StableTranscriptAnchor>,
}
#[derive(Debug, Clone)]
pub struct StableTranscriptAnchor {
pub item_id: TranscriptPresentationEntryId,
pub row_in_item: usize,
pub content: Option<(String, usize)>,
pub fallback_index: usize,
pub source_position: Option<usize>,
pub viewport_row_offset: usize,
}
impl PartialEq for StableTranscriptAnchor {
fn eq(&self, other: &Self) -> bool {
self.item_id == other.item_id
&& self.row_in_item == other.row_in_item
&& self.content == other.content
&& self.source_position == other.source_position
&& self.viewport_row_offset == other.viewport_row_offset
}
}
impl Eq for StableTranscriptAnchor {}
impl StableTranscriptAnchor {
fn resolve_row(&self, layout: &TranscriptLayoutCache, index: usize, rows: usize) -> usize {
if let Some(row) = self
.source_position
.and_then(|position| layout.source_row(index, position))
{
return row.min(rows.saturating_sub(1));
}
self.content
.as_ref()
.and_then(|(key, offset)| layout.resolve_content_anchor(index, key, *offset))
.unwrap_or(self.row_in_item)
.min(rows.saturating_sub(1))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PendingReasoningEffort {
value: String,
generation: u64,
}
#[derive(Debug, Clone)]
struct TranscriptNavigationCheckpoint {
viewport: TranscriptViewport,
animation: Option<TranscriptScrollAnimation>,
anchor: Option<StableTranscriptAnchor>,
submitted: SubmittedUserMessageFollowing,
assistant: AssistantScrollAnchorState,
pending_assistant: bool,
pending_sequence: Option<u64>,
automatic_item: Option<super::transcript::TranscriptItemId>,
admitted_items: BTreeSet<super::transcript::TranscriptItemId>,
history: OlderHistoryState,
hidden_activity_at: Option<Instant>,
hidden_activity_burst: u8,
bar_started_at: Instant,
bar_next_frame_at: Option<Instant>,
}
#[derive(Debug, Clone)]
struct SavedSessionReadingPosition {
sequence: u64,
row_in_item: usize,
source_position: Option<usize>,
content: Option<(String, usize)>,
}
#[derive(Debug, Clone)]
pub struct BmuxApp {
pending_session_link: Option<SessionId>,
child_request_fence: crate::child_request_fence::ChildRequestFence,
child_activity: Option<bcode_session_models::SessionActivityPage>,
child_activity_error: Option<String>,
child_panel: ChildPanelPresentation,
child_panel_selected: usize,
child_summaries: Vec<bcode_session_models::SessionSummary>,
child_expanded: BTreeSet<bcode_session_view::navigation::SessionNavigationKey>,
child_pages: BTreeMap<SessionId, bcode_session_models::SessionActivityPage>,
child_cursors: BTreeMap<SessionId, Option<SessionId>>,
session_reading_positions: BTreeMap<SessionId, SavedSessionReadingPosition>,
session_id: Option<SessionId>,
selected_auth_profile: Option<String>,
selected_context_format_version: Option<u16>,
selected_compatibility_key: Option<String>,
current_agent_accent: Option<String>,
pending_agent_id: Option<String>,
pending_agent_accent: Option<String>,
agent_metadata_hydration: AgentMetadataHydration,
target_theme: ResolvedTheme,
presented_theme: PresentedTheme,
theme_catalog: Option<DiscoveredThemes>,
theme_transition: ThemeTransitionState,
theme_preview_id: Option<String>,
thinking_label: String,
reasoning_support: ReasoningSupport,
reasoning_display_mode: bcode_config::TuiThinkingMode,
reasoning_effort_values: Vec<String>,
pending_reasoning_effort: Option<PendingReasoningEffort>,
reasoning_effort_generation: u64,
reasoning_default_effort: Option<String>,
reasoning_default_summary: Option<String>,
token_usage: TokenUsageMeter,
composer: TextInputState,
input_history: InputHistory,
transcript: TranscriptDocument,
transcript_projection_revision: u64,
active_interaction_layout: Option<(String, u16)>,
session_view_terminal_adapter: SessionViewTerminalAdapter,
pub queue_recovery: QueueRecoveryPresentation,
session_view: bcode_session_view::SessionView,
transcript_window: TranscriptResidentWindow,
transcript_selection_pinned: bool,
latest_history_sequence: Option<u64>,
pending_submissions: PendingSubmissions,
transcript_layout: TranscriptLayoutCache,
transcript_markdown_cache: crate::transcript_markdown_cache::TranscriptMarkdownCache,
elapsed_layout_revision: u64,
elapsed_dirty_visuals: BTreeSet<String>,
transcript_dirty_items: BTreeSet<TranscriptPresentationEntryId>,
viewport: TranscriptViewport,
manual_transcript_scroll_until: Option<Instant>,
transcript_scroll_animation: Option<TranscriptScrollAnimation>,
transcript_scroll_clock: TranscriptScrollClock,
pending_visual_overflow_bottom: Option<usize>,
pending_stable_transcript_anchor: Option<StableTranscriptAnchor>,
presented_transcript_anchor: Option<StableTranscriptAnchor>,
navigation_checkpoint: Option<TranscriptNavigationCheckpoint>,
automatic_item: Option<super::transcript::TranscriptItemId>,
admitted_items: BTreeSet<super::transcript::TranscriptItemId>,
latest_hidden_activity_at: Option<Instant>,
latest_hidden_activity_burst: u8,
latest_bar_animation_started_at: Instant,
activity_frame: usize,
activity_elapsed: Duration,
latest_bar_next_frame_at: Option<Instant>,
submitted_user_message_following: SubmittedUserMessageFollowing,
assistant_scroll_anchor: AssistantScrollAnchorState,
pending_assistant_stream_anchor: bool,
pending_transcript_top_anchor_sequence: Option<u64>,
older_history: OlderHistoryState,
history_window_generation: u64,
activity: ActivityState,
activity_started_at: Instant,
daemon_connection: DaemonConnectionState,
execution_mode_indicator: Option<String>,
status: String,
key_hints: String,
jump_to_latest_key_label: String,
tui_config: TuiConfig,
effective_keymap: bcode_plugin_sdk::tui::keymap::KeymapRegistry,
diff_viewer_layout_override: Option<TuiDiffViewerLayout>,
exit: ExitState,
cursor: CursorBlink,
pending_key_activation: Option<PendingKeyActivation>,
plugin_presentation: Option<Arc<crate::plugin_tui::PluginTuiPresentation>>,
markdown_interaction: crate::markdown_interaction::MarkdownInteractionState,
markdown_source_view: Option<(String, String)>,
markdown_details_open: BTreeMap<String, bool>,
markdown_presentation_revision: u64,
markdown_semantics_reconciled: Option<(u64, u64, u16)>,
footnote_return_targets: BTreeMap<String, String>,
markdown_footnote_rows: BTreeMap<String, usize>,
pending_markdown_focus: Option<String>,
markdown_fragment_rows: BTreeMap<String, usize>,
#[cfg(test)]
observations: PresentationObservations,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DaemonConnectionState {
#[default]
Connecting,
Starting,
Connected,
IdleOffline,
Unavailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum SubmittedUserMessageFollowing {
#[default]
Idle,
PendingAnchor,
Anchored,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum AssistantScrollAnchorState {
#[default]
Idle,
Anchored {
index: usize,
},
Interrupted {
index: usize,
},
}
impl AssistantScrollAnchorState {
const fn index(self) -> Option<usize> {
match self {
Self::Idle => None,
Self::Anchored { index } | Self::Interrupted { index } => Some(index),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PermissionRequestInput<'a> {
event_sequence: u64,
permission_id: &'a str,
tool_call_id: &'a str,
tool_name: &'a str,
arguments_json: &'a str,
policy_source: Option<&'a str>,
policy_reason: Option<&'a str>,
application: SessionEventApplication,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionEventApplication {
Replay,
Live,
}
impl SessionEventApplication {
const fn live_activity(self) -> bool {
matches!(self, Self::Live)
}
}
const fn reasoning_view_mode(
mode: bcode_config::TuiThinkingMode,
) -> bcode_session_view_models::ReasoningDisplayMode {
match mode {
bcode_config::TuiThinkingMode::All => bcode_session_view_models::ReasoningDisplayMode::All,
bcode_config::TuiThinkingMode::Summary => {
bcode_session_view_models::ReasoningDisplayMode::Summary
}
bcode_config::TuiThinkingMode::Raw => bcode_session_view_models::ReasoningDisplayMode::Raw,
}
}
#[cfg(test)]
#[derive(Debug, Clone, Default)]
struct PresentationObservations {
theme_catalog_discovery_count: usize,
last_transcript_damage: TranscriptDocumentDamage,
}
#[derive(Debug, Clone, Default)]
pub struct QueueRecoveryPresentation {
pub generation: u64,
pub settings_visible: bool,
pub confirmation: Option<bcode_session_models::SubmissionResumeConfirmation>,
}
impl BmuxApp {
#[must_use]
pub fn new_with_history(
session_id: Option<SessionId>,
history: &[SessionEvent],
input_history: &[SessionInputHistoryEntry],
has_older_history: bool,
) -> Self {
Self::initial_session_state(
session_id,
input_history,
OlderHistoryState::new(history, has_older_history),
)
.with_initial_history(history)
}
fn initial_session_state(
session_id: Option<SessionId>,
input_history: &[SessionInputHistoryEntry],
older_history: OlderHistoryState,
) -> Self {
let (now, initial_theme) = Self::initial_presentation();
Self::initial_session_state_with_presentation(
session_id,
input_history,
older_history,
now,
&initial_theme,
)
}
fn initial_session_state_with_presentation(
session_id: Option<SessionId>,
input_history: &[SessionInputHistoryEntry],
older_history: OlderHistoryState,
now: Instant,
initial_theme: &ResolvedTheme,
) -> Self {
Self {
pending_session_link: None,
child_request_fence: crate::child_request_fence::ChildRequestFence::default(),
child_activity: None,
child_activity_error: None,
child_panel: ChildPanelPresentation::Hidden,
child_panel_selected: 0,
child_summaries: Vec::new(),
child_expanded: BTreeSet::new(),
child_pages: BTreeMap::new(),
child_cursors: BTreeMap::new(),
session_reading_positions: BTreeMap::new(),
session_id,
selected_auth_profile: None,
selected_context_format_version: None,
selected_compatibility_key: None,
current_agent_accent: None,
pending_agent_id: None,
pending_agent_accent: None,
agent_metadata_hydration: AgentMetadataHydration::Pending,
target_theme: *initial_theme,
presented_theme: (*initial_theme).into(),
theme_catalog: None,
theme_transition: ThemeTransitionState::new(initial_theme.accent, now),
theme_preview_id: None,
thinking_label: "reasoning output shown · unsupported".to_owned(),
reasoning_support: ReasoningSupport::Unsupported,
reasoning_display_mode: bcode_config::TuiThinkingMode::All,
reasoning_effort_values: Vec::new(),
pending_reasoning_effort: None,
reasoning_effort_generation: 0,
reasoning_default_effort: None,
reasoning_default_summary: None,
token_usage: TokenUsageMeter::default(),
composer: TextInputState::new(TextEditBuffer::new()),
input_history: InputHistory::from_entries(input_history),
transcript: TranscriptDocument::default(),
transcript_projection_revision: 0,
active_interaction_layout: None,
session_view_terminal_adapter: SessionViewTerminalAdapter::default(),
queue_recovery: QueueRecoveryPresentation::default(),
session_view: bcode_session_view::SessionView::new(),
transcript_window: TranscriptResidentWindow::default(),
transcript_selection_pinned: false,
latest_history_sequence: None,
pending_submissions: PendingSubmissions::default(),
transcript_layout: TranscriptLayoutCache::default(),
transcript_markdown_cache: TranscriptMarkdownCache::default(),
elapsed_layout_revision: 0,
elapsed_dirty_visuals: BTreeSet::new(),
transcript_dirty_items: BTreeSet::new(),
viewport: TranscriptViewport::default(),
manual_transcript_scroll_until: None,
transcript_scroll_animation: None,
transcript_scroll_clock: TranscriptScrollClock::default(),
pending_visual_overflow_bottom: None,
pending_stable_transcript_anchor: None,
presented_transcript_anchor: None,
navigation_checkpoint: None,
automatic_item: None,
admitted_items: BTreeSet::new(),
latest_hidden_activity_at: None,
latest_hidden_activity_burst: 0,
latest_bar_animation_started_at: now,
activity_frame: 0,
activity_elapsed: Duration::ZERO,
latest_bar_next_frame_at: None,
submitted_user_message_following: SubmittedUserMessageFollowing::Idle,
assistant_scroll_anchor: AssistantScrollAnchorState::Idle,
pending_assistant_stream_anchor: false,
pending_transcript_top_anchor_sequence: None,
older_history,
history_window_generation: 0,
activity: ActivityState::Idle,
activity_started_at: now,
daemon_connection: DaemonConnectionState::Connecting,
execution_mode_indicator: None,
status: String::from("Connecting to daemon…"),
key_hints: String::new(),
jump_to_latest_key_label: "ctrl+end".to_owned(),
tui_config: TuiConfig::default(),
effective_keymap: bcode_plugin_sdk::tui::keymap::KeymapRegistry::default(),
diff_viewer_layout_override: None,
exit: ExitState::default(),
cursor: CursorBlink::new(),
pending_key_activation: None,
plugin_presentation: None,
markdown_interaction: crate::markdown_interaction::MarkdownInteractionState::default(),
markdown_source_view: None,
markdown_details_open: BTreeMap::new(),
markdown_presentation_revision: 0,
markdown_semantics_reconciled: None,
footnote_return_targets: BTreeMap::new(),
markdown_footnote_rows: BTreeMap::new(),
pending_markdown_focus: None,
markdown_fragment_rows: BTreeMap::new(),
#[cfg(test)]
observations: PresentationObservations::default(),
}
}
fn initial_presentation() -> (Instant, ResolvedTheme) {
(Instant::now(), super::theme::resolve_initial_theme())
}
fn with_initial_history(mut self, history: &[SessionEvent]) -> Self {
self.absorb_history(history);
self
}
pub(crate) fn take_cross_session_state_from(&mut self, source: &Self) {
self.session_reading_positions
.clone_from(&source.session_reading_positions);
if source.scroll_offset() > 0
&& let Some(session_id) = source.session_id
&& let Some(line) = source
.transcript_layout
.visible_lines_from_top(source.transcript_top_row(source.viewport.height()), 1)
.first()
&& line.source == VisibleTranscriptSource::Transcript
&& let Some(sequence) = source
.transcript
.items()
.get(line.entry_index)
.and_then(super::transcript::TranscriptItem::event_sequence)
{
self.session_reading_positions.insert(
session_id,
SavedSessionReadingPosition {
sequence,
row_in_item: line.row_in_entry,
source_position: source
.transcript_layout
.source_position(line.entry_index, line.row_in_entry),
content: source
.transcript_layout
.content_anchor(line.entry_index, line.row_in_entry)
.map(|(key, offset)| (key.to_owned(), offset)),
},
);
}
self.apply_tui_config(source.tui_config().clone());
self.set_daemon_connection(source.daemon_connection());
self.set_agent_metadata_hydrated(source.is_agent_metadata_hydrated());
self.plugin_presentation
.clone_from(&source.plugin_presentation);
self.theme_catalog.clone_from(&source.theme_catalog);
#[cfg(test)]
{
self.observations.theme_catalog_discovery_count =
source.observations.theme_catalog_discovery_count;
}
self.take_theme_transition_state_from(source);
}
#[must_use]
pub fn saved_reading_sequence(&self, session_id: SessionId) -> Option<u64> {
self.session_reading_positions
.get(&session_id)
.map(|position| position.sequence)
}
pub(crate) fn restore_saved_reading_position(&mut self) {
let Some(position) = self
.session_id
.and_then(|id| self.session_reading_positions.get(&id))
.cloned()
else {
return;
};
let Some(index) = self
.transcript
.items()
.iter()
.position(|item| item.event_sequence() == Some(position.sequence))
else {
return;
};
let Some(item_id) = self.transcript.presentation_id(index) else {
return;
};
self.viewport.follow_anchor(0);
self.pending_stable_transcript_anchor = Some(StableTranscriptAnchor {
item_id,
fallback_index: index,
row_in_item: position.row_in_item,
viewport_row_offset: 0,
source_position: position.source_position,
content: position.content,
});
}
pub(crate) fn take_same_session_transcript_state_from(&mut self, source: &Self) {
if self.session_id != source.session_id || self.session_id.is_none() {
return;
}
self.transcript.copy_ephemeral_from(&source.transcript);
self.transcript_projection_revision = self.transcript_projection_revision.saturating_add(1);
}
pub(crate) fn take_same_session_reasoning_state_from(&mut self, source: &Self) {
if self.session_id == source.session_id {
self.pending_reasoning_effort
.clone_from(&source.pending_reasoning_effort);
self.reasoning_effort_generation = source.reasoning_effort_generation;
self.reasoning_effort_values
.clone_from(&source.reasoning_effort_values);
self.reasoning_support = source.reasoning_support;
self.reasoning_default_effort
.clone_from(&source.reasoning_default_effort);
self.reasoning_default_summary
.clone_from(&source.reasoning_default_summary);
self.refresh_thinking_label();
}
}
#[cfg(test)]
pub fn set_plugin_host(&mut self, host: Arc<bcode_plugin::PluginHost>) {
self.plugin_presentation = Some(Arc::new(
crate::plugin_tui::PluginTuiPresentation::from_shared(host),
));
}
#[must_use]
pub fn shortcut_hint(&self, scope: &str, action: &str) -> String {
let keys = self.effective_keymap.keys_for_action(scope, action);
if keys.is_empty() {
"unbound".into()
} else {
keys.join(" / ")
}
}
pub fn set_effective_keymap(
&mut self,
registry: bcode_plugin_sdk::tui::keymap::KeymapRegistry,
) {
if self.effective_keymap != registry {
self.capture_stable_transcript_anchor();
self.effective_keymap = registry;
self.transcript_layout = TranscriptLayoutCache::default();
self.key_hints = format!(
"{} send · {} interrupt · {} exit · {} palette",
self.shortcut_hint("chat", crate::keymap::BmuxAction::InputSubmitSteering.id()),
self.shortcut_hint("chat", crate::keymap::BmuxAction::AppInterrupt.id()),
self.shortcut_hint("chat", crate::keymap::BmuxAction::AppExit.id()),
self.shortcut_hint("chat", crate::keymap::BmuxAction::CommandPaletteOpen.id())
);
self.elapsed_layout_revision = self.elapsed_layout_revision.saturating_add(1);
}
}
#[must_use]
pub const fn effective_keymap(&self) -> &bcode_plugin_sdk::tui::keymap::KeymapRegistry {
&self.effective_keymap
}
pub fn set_plugin_presentation(
&mut self,
presentation: Arc<crate::plugin_tui::PluginTuiPresentation>,
) {
self.plugin_presentation = Some(presentation);
}
#[cfg(test)]
#[must_use]
pub const fn has_plugin_backend(&self) -> bool {
self.plugin_presentation.is_some()
}
#[must_use]
pub fn plugin_presentation(&self) -> Option<&crate::plugin_tui::PluginTuiPresentation> {
self.plugin_presentation.as_deref()
}
pub fn begin_markdown_semantics_reconciliation(&mut self, width: u16) -> bool {
let key = (
self.transcript.revision(),
self.markdown_presentation_revision,
width,
);
if self.markdown_semantics_reconciled == Some(key) {
return false;
}
self.markdown_semantics_reconciled = Some(key);
true
}
pub fn reconcile_markdown_interactions(
&mut self,
visible: Vec<crate::markdown_interaction::VisibleMarkdownContribution>,
) {
self.markdown_interaction.reconcile(visible);
if let Some(target) = self.pending_markdown_focus.take()
&& !self.markdown_interaction.focus_target(&target)
{
self.pending_markdown_focus = Some(target);
}
if self
.markdown_source_view
.as_ref()
.is_some_and(|(id, _)| self.markdown_interaction.contribution(id).is_none())
{
self.markdown_source_view = None;
}
self.footnote_return_targets
.retain(|definition, reference| {
self.markdown_interaction.contribution(definition).is_some()
&& self.markdown_interaction.contribution(reference).is_some()
});
}
pub fn reconcile_markdown_details(&mut self, resident_ids: &BTreeSet<String>) {
let before = self.markdown_details_open.len();
self.markdown_details_open
.retain(|id, _| resident_ids.contains(id));
if self.markdown_details_open.len() != before {
self.markdown_presentation_revision =
self.markdown_presentation_revision.saturating_add(1);
}
}
pub fn reconcile_markdown_footnote_rows(&mut self, rows: BTreeMap<String, usize>) {
self.markdown_footnote_rows = rows;
self.footnote_return_targets
.retain(|definition, reference| {
self.markdown_footnote_rows.contains_key(definition)
&& self.markdown_footnote_rows.contains_key(reference)
});
if self
.pending_markdown_focus
.as_ref()
.is_some_and(|target| !self.markdown_footnote_rows.contains_key(target))
{
self.pending_markdown_focus = None;
}
}
fn navigate_markdown_contribution(&mut self, contribution_id: &str) -> bool {
let Some(row) = self.markdown_footnote_rows.get(contribution_id).copied() else {
return false;
};
self.transcript_scroll_animation = None;
self.pending_visual_overflow_bottom = None;
self.viewport.detach();
self.viewport.follow_anchor(row);
self.presented_transcript_anchor = None;
self.pending_stable_transcript_anchor = None;
self.pending_markdown_focus = Some(contribution_id.to_owned());
true
}
pub fn reconcile_markdown_fragments(&mut self, fragments: BTreeMap<String, usize>) {
self.markdown_fragment_rows = fragments;
}
fn navigate_markdown_fragment(&mut self, fragment: &str) -> bool {
let normalized = fragment.strip_prefix('#').unwrap_or(fragment);
let Some(row) = self.markdown_fragment_rows.get(normalized).copied() else {
self.set_status("Markdown fragment target was not found".to_owned());
return true;
};
self.transcript_scroll_animation = None;
self.pending_visual_overflow_bottom = None;
self.viewport.detach();
self.viewport.follow_anchor(row);
self.presented_transcript_anchor = None;
self.pending_stable_transcript_anchor = None;
self.set_status("Navigated to Markdown section".to_owned());
true
}
pub fn move_markdown_focus(&mut self, reverse: bool) -> bool {
let changed = if reverse {
self.markdown_interaction.focus_previous()
} else {
self.markdown_interaction.focus_next()
};
if changed && self.markdown_interaction.focused().is_some() {
let hint = format!(
"{} activates; {} copies; {} moves",
self.shortcut_hint("chat", "tui.markdown.activate"),
self.shortcut_hint("chat", "tui.markdown.copyDestination"),
self.shortcut_hint("chat", "tui.markdown.focusNext")
);
self.set_status(format!("Markdown contribution focused; {hint}"));
}
changed
}
pub fn focus_markdown_contribution(&mut self, contribution_id: &str) -> bool {
self.markdown_interaction.focus(contribution_id)
}
#[must_use]
pub fn focused_markdown_contribution(&self) -> Option<&str> {
self.markdown_interaction.focused()
}
#[must_use]
pub fn visible_markdown_contribution(
&self,
contribution_id: &str,
) -> Option<&bcode_markdown_render::MarkdownContributionKind> {
self.markdown_interaction.contribution(contribution_id)
}
#[must_use]
pub const fn markdown_details_open(&self) -> &BTreeMap<String, bool> {
&self.markdown_details_open
}
#[must_use]
pub const fn markdown_presentation_revision(&self) -> u64 {
self.markdown_presentation_revision
}
#[cfg(test)]
pub(crate) fn set_transcript_viewport_for_details_test(
&mut self,
total_rows: usize,
viewport_height: u16,
scroll_up: usize,
) {
self.viewport.sync_max(
total_rows.saturating_sub(usize::from(viewport_height)),
0,
total_rows,
viewport_height,
false,
&mut self.older_history,
);
self.viewport.scroll_up(scroll_up, &mut self.older_history);
}
#[cfg(test)]
fn transcript_viewport_top_row_for_test(
&self,
total_rows: usize,
viewport_height: u16,
) -> usize {
self.viewport.top_row(total_rows, viewport_height)
}
#[cfg(test)]
pub(crate) fn frame_observation(&self) -> TranscriptFrameObservation {
let total_rows = self.transcript_layout.total_rows();
let viewport_top = self.viewport.top_row(total_rows, self.viewport.height());
let anchor = self
.transcript_layout
.visible_lines_from_top(viewport_top, self.viewport.height().max(1))
.into_iter()
.find(|line| line.source == VisibleTranscriptSource::Transcript)
.and_then(|line| {
self.transcript
.presentation_id(line.entry_index)
.map(|item_id| StableTranscriptAnchor {
item_id,
fallback_index: line.entry_index,
source_position: self
.transcript_layout
.source_position(line.entry_index, line.row_in_entry),
row_in_item: line.row_in_entry,
viewport_row_offset: line.row_index.saturating_sub(viewport_top),
content: self
.transcript_layout
.content_anchor(line.entry_index, line.row_in_entry)
.map(|(key, offset)| (key.to_owned(), offset)),
})
});
TranscriptFrameObservation {
semantic_items: self
.session_view
.snapshot()
.transcript
.items
.iter()
.map(|item| (item.id.clone(), item.revision))
.collect(),
terminal_items: self
.transcript
.items()
.iter()
.map(|item| {
(
item.id(),
item.source_view_item_id().cloned(),
item.source_view_item_revision(),
)
})
.collect(),
terminal_rows: (0..total_rows)
.filter_map(|row| self.transcript_layout.line_at_row(row))
.map(|line| {
let item_id = (line.source == VisibleTranscriptSource::Transcript)
.then(|| {
self.transcript
.get(line.entry_index)?
.source_view_item_id()
.cloned()
})
.flatten();
(item_id, line.row_in_entry)
})
.collect(),
damage: self.observations.last_transcript_damage.clone(),
viewport_top,
scroll_mode: if self.transcript_scroll_animation.is_some() {
"transition_to_entry"
} else {
self.viewport.intent_name()
},
anchor,
}
}
fn preserve_transcript_anchor_for_disclosure(&mut self) {
self.transcript_scroll_animation = None;
self.pending_visual_overflow_bottom = None;
self.viewport.detach();
let top_row = self
.viewport
.top_row(self.transcript_layout.total_rows(), self.viewport.height());
self.viewport.materialize_top_row(top_row);
}
pub fn activate_markdown_contribution(&mut self, contribution_id: &str) -> bool {
use bcode_markdown_render::MarkdownContributionKind;
let contribution = self.visible_markdown_contribution(contribution_id).cloned();
if let Some(MarkdownContributionKind::Details { default_open, .. }) = contribution {
self.preserve_transcript_anchor_for_disclosure();
let open = self
.markdown_details_open
.get(contribution_id)
.copied()
.unwrap_or(default_open);
self.markdown_details_open
.insert(contribution_id.to_owned(), !open);
self.markdown_presentation_revision =
self.markdown_presentation_revision.saturating_add(1);
self.set_status(if open {
"Details collapsed".to_owned()
} else {
"Details expanded".to_owned()
});
return true;
}
if let Some(MarkdownContributionKind::Mermaid { source, .. }) = contribution {
if self
.markdown_source_view()
.is_some_and(|(active_id, _)| active_id == contribution_id)
{
self.markdown_source_view = None;
self.set_status("Mermaid source view closed".to_owned());
} else {
self.markdown_source_view = Some((contribution_id.to_owned(), source));
self.set_status("Mermaid source view opened; activate again to close".to_owned());
}
return true;
}
if let Some(MarkdownContributionKind::FootnoteReference { target_id, .. }) = &contribution {
if target_id.is_empty()
|| !(self.markdown_interaction.focus_target(target_id)
|| self.navigate_markdown_contribution(target_id))
{
self.set_status("Footnote definition is not available".to_owned());
return true;
}
self.footnote_return_targets
.insert(target_id.clone(), contribution_id.to_owned());
self.set_status("Footnote definition focused; activate to return".to_owned());
return true;
}
if let Some(MarkdownContributionKind::FootnoteDefinition { reference_ids, .. }) =
&contribution
{
let target = self
.footnote_return_targets
.remove(contribution_id)
.or_else(|| reference_ids.first().cloned());
if let Some(target) = target
&& (self.markdown_interaction.focus_target(&target)
|| self.navigate_markdown_contribution(&target))
{
self.set_status("Returned to footnote reference".to_owned());
} else {
self.set_status("Footnote reference is not available".to_owned());
}
return true;
}
let destination = match contribution {
Some(
MarkdownContributionKind::Link { destination, .. }
| MarkdownContributionKind::GitHubIssue { destination, .. },
) => destination,
Some(
MarkdownContributionKind::Details { .. }
| MarkdownContributionKind::FootnoteReference { .. }
| MarkdownContributionKind::FootnoteDefinition { .. }
| MarkdownContributionKind::Image { .. }
| MarkdownContributionKind::InlineMath { .. }
| MarkdownContributionKind::DisplayMath { .. }
| MarkdownContributionKind::Mermaid { .. },
)
| None => return false,
};
self.activate_markdown_link(destination)
}
fn activate_markdown_link(
&mut self,
destination: bcode_markdown_render::MarkdownDestination,
) -> bool {
if let bcode_markdown_render::MarkdownDestination::Fragment(fragment) = &destination
&& let Some(child) = fragment.strip_prefix("bcode-session-")
&& let Ok(session_id) = child.parse()
{
self.pending_session_link = Some(session_id);
return true;
}
match crate::markdown_activation::activate_markdown_destination(&destination) {
Ok(crate::markdown_activation::MarkdownActivation::External) => {
self.set_status("Opened Markdown destination".to_owned());
}
Ok(crate::markdown_activation::MarkdownActivation::Fragment) => {
let bcode_markdown_render::MarkdownDestination::Fragment(fragment) = destination
else {
unreachable!("fragment activation must preserve typed destination")
};
return self.navigate_markdown_fragment(&fragment);
}
Ok(crate::markdown_activation::MarkdownActivation::Inert) => return false,
Err(error) => {
self.set_status(format!("Could not open Markdown destination: {error}"));
}
}
true
}
#[must_use]
pub fn markdown_source_view(&self) -> Option<(&str, &str)> {
self.markdown_source_view
.as_ref()
.map(|(id, source)| (id.as_str(), source.as_str()))
}
pub(crate) const fn take_session_link_request(&mut self) -> Option<SessionId> {
self.pending_session_link.take()
}
pub fn activate_focused_markdown_contribution(&mut self) -> bool {
let Some(contribution_id) = self.focused_markdown_contribution().map(str::to_owned) else {
return false;
};
self.activate_markdown_contribution(&contribution_id)
}
pub fn copy_focused_markdown_destination(&mut self) -> bool {
use bcode_markdown_render::MarkdownContributionKind;
let Some(contribution_id) = self.focused_markdown_contribution().map(str::to_owned) else {
return false;
};
let destination = match self.visible_markdown_contribution(&contribution_id) {
Some(
MarkdownContributionKind::Link { destination, .. }
| MarkdownContributionKind::GitHubIssue { destination, .. },
) => destination.clone(),
_ => return false,
};
if crate::markdown_activation::markdown_destination_text(&destination).is_none() {
return false;
}
match crate::markdown_activation::copy_markdown_destination(&destination) {
Ok(true) => self.set_status("Copied Markdown destination".to_owned()),
Ok(false) => return false,
Err(error) => self.set_status(format!("Could not copy Markdown destination: {error}")),
}
true
}
#[must_use]
pub fn timeline_entries(&self) -> Vec<TimelineEntry> {
self.input_history
.entries()
.iter()
.map(|entry| TimelineEntry::new(entry.sequence, entry.timestamp_ms, entry.text.clone()))
.collect()
}
pub fn transcript_index_for_sequence(&self, sequence: u64) -> Option<usize> {
self.transcript
.iter()
.position(|item| item.event_sequence() == Some(sequence))
}
pub fn replace_latest_transcript_window(
&mut self,
events: &[SessionEvent],
has_older_history: bool,
) {
self.history_window_generation = self.history_window_generation.wrapping_add(1);
self.latest_history_sequence = events.last().map(|event| event.sequence);
self.transcript_window.replace_window(events);
self.older_history = OlderHistoryState::new(events, has_older_history);
self.rebuild_transcript_from_history(false);
self.pending_visual_overflow_bottom = None;
}
pub fn replace_transcript_window(
&mut self,
events: &[SessionEvent],
has_older: bool,
has_newer: bool,
anchor_sequence: u64,
) {
self.history_window_generation = self.history_window_generation.wrapping_add(1);
self.latest_history_sequence = events.last().map(|event| event.sequence);
self.transcript_window.replace_window(events);
self.older_history
.replace_centered(events, has_older, has_newer, anchor_sequence);
self.rebuild_transcript_from_history(false);
self.pending_visual_overflow_bottom = None;
}
pub fn request_transcript_top_anchor_sequence(&mut self, sequence: u64) {
self.navigation_checkpoint = None;
self.pending_transcript_top_anchor_sequence = Some(sequence);
self.transcript_scroll_animation = None;
self.viewport.detach();
}
pub fn jump_to_transcript_index(&mut self, index: usize) -> bool {
self.navigation_checkpoint = None;
let Some(top_row) = self
.transcript_layout
.entry_start_row(VisibleTranscriptSource::Transcript, index)
else {
return false;
};
self.transcript_scroll_animation = None;
self.viewport.detach();
self.viewport.follow_anchor(top_row);
true
}
#[must_use]
pub const fn session_id(&self) -> Option<SessionId> {
self.session_id
}
#[must_use]
pub fn next_streaming_presentation_deadline(&self, now: Instant) -> Option<Instant> {
self.session_view.next_streaming_presentation_deadline(now)
}
pub fn advance_streaming_presentation(&mut self, now: Instant) -> bool {
if !self.session_view.advance_streaming_presentation(now) {
return false;
}
let _ = self.apply_session_view_terminal_adapter();
true
}
#[must_use]
pub fn session_title(&self) -> Option<&str> {
self.session_view.snapshot().title.as_deref()
}
#[must_use]
pub fn working_directory(&self) -> Option<&std::path::Path> {
self.session_view.snapshot().working_directory.as_deref()
}
pub fn apply_usage_summary(&mut self, summary: &bcode_session_models::SessionUsageSummary) {
self.session_view.set_usage_summary(summary.clone());
}
pub(crate) fn stamp_child_effect(&mut self, effect: &mut super::effects::TuiEffect) -> bool {
match effect {
super::effects::TuiEffect::LoadSessionChildren {
parent_id,
generation,
..
} => {
*generation = self.child_request_fence.begin(false, *parent_id);
true
}
super::effects::TuiEffect::LoadChildActivity {
session_id,
after,
generation,
} => {
*after = self.monitor_activity_cursor(*session_id);
*generation = self.child_request_fence.begin(true, *session_id);
true
}
_ => false,
}
}
pub(crate) fn accepts_child_result(
&self,
activity: bool,
session_id: SessionId,
generation: u64,
) -> bool {
self.child_request_fence
.accepts(activity, session_id, generation)
}
pub fn apply_child_activity(
&mut self,
result: Result<bcode_session_models::SessionActivityPage, bcode_client::ClientError>,
) {
match result {
Ok(page) => {
let now_ms = u64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
)
.unwrap_or(u64::MAX);
self.session_view.observe_child_card_activity(&page, now_ms);
let selected = self.selected_monitor_session();
self.child_activity = Some(page);
if let Some(selected) = selected {
self.child_panel_selected = self
.monitor_tree()
.rows
.iter()
.position(|row| self.child_summaries[row.source_index].id == selected)
.unwrap_or(0);
}
self.apply_session_view_terminal_adapter();
self.child_activity_error = None;
}
Err(error) => {
self.child_activity_error = Some(format!("children: stale/degraded · {error}"));
}
}
}
#[must_use]
pub fn child_activity_text(&self) -> String {
let relationship_known = !self.child_summaries.is_empty()
|| !self.child_pages.is_empty()
|| self.child_activity_error.is_some()
|| self
.session_view
.snapshot()
.session_summary
.as_ref()
.is_some_and(|summary| summary.parent_id.is_some())
|| self
.child_activity
.as_ref()
.is_some_and(|page| !page.descendants.is_empty());
if !relationship_known && !self.child_panel_open() {
return String::new();
}
let mut observed = BTreeMap::new();
if let Some(page) = &self.child_activity {
for activity in &page.descendants {
observed.insert(activity.session_id, activity);
}
}
for page in self.child_pages.values() {
observed.insert(page.own.session_id, &page.own);
for activity in &page.descendants {
observed.insert(activity.session_id, activity);
}
}
if !self.child_pages.is_empty() {
let count = observed
.values()
.filter(|activity| {
bcode_session_view::child_activity::has_active_evidence(activity)
})
.count();
let degraded = self.child_pages.values().any(|page| {
page.coverage == bcode_session_models::SessionActivityCoverage::Degraded
});
return format!(
"children: {count} known active observed subtree · {}{}",
if degraded {
"partial/degraded"
} else {
"partial"
},
self.child_activity_error
.as_ref()
.map_or(String::new(), |error| format!(" · {error}"))
);
}
self.child_activity_error.clone().unwrap_or_else(|| {
self.child_activity.as_ref().map_or_else(
|| {
format!(
"children: unknown · {} monitor",
self.shortcut_hint("chat", "tui.monitor.toggle")
)
},
bcode_session_view::child_activity::child_activity_label,
)
})
}
pub const fn open_child_panel(&mut self) {
self.child_panel = ChildPanelPresentation::Monitor;
}
pub const fn toggle_child_panel(&mut self) {
self.child_panel = if self.child_panel_focused() {
ChildPanelPresentation::Hidden
} else {
ChildPanelPresentation::Monitor
};
}
pub const fn release_child_panel_focus(&mut self) {
if self.child_panel_open() {
self.child_panel = ChildPanelPresentation::Composer;
}
}
#[must_use]
pub const fn child_panel_focused(&self) -> bool {
matches!(self.child_panel, ChildPanelPresentation::Monitor)
}
#[must_use]
pub const fn child_panel_open(&self) -> bool {
!matches!(self.child_panel, ChildPanelPresentation::Hidden)
}
pub fn apply_monitor_children(
&mut self,
parent_id: SessionId,
page: &bcode_session_models::SessionChildPage,
) {
if self.session_id != Some(parent_id)
&& !self
.child_summaries
.iter()
.any(|summary| summary.id == parent_id)
{
return;
}
let selected = self.selected_monitor_session();
self.child_cursors.insert(parent_id, page.next_after);
for child in &page.children {
if let Some(index) = self
.child_summaries
.iter()
.position(|summary| summary.id == child.id)
{
self.child_summaries[index] = child.clone();
} else {
self.child_summaries.push(child.clone());
}
}
while self.child_summaries.len() > 256 {
let index = self
.child_summaries
.iter()
.position(|summary| {
Some(summary.id) != selected
&& !page.children.iter().any(|child| child.id == summary.id)
})
.unwrap_or(0);
let evicted = self.child_summaries.remove(index);
self.child_pages.remove(&evicted.id);
self.child_expanded.remove(
&bcode_session_view::navigation::SessionNavigationKey::from_summary(&evicted),
);
self.child_cursors.remove(&evicted.id);
}
self.child_cursors.retain(|id, _| {
Some(*id) == self.session_id
|| self.child_summaries.iter().any(|summary| summary.id == *id)
});
if let Some(selected) = selected {
self.child_panel_selected = self
.monitor_tree()
.rows
.iter()
.position(|row| self.child_summaries[row.source_index].id == selected)
.unwrap_or(0);
}
if !page.complete || !page.issues.is_empty() {
self.child_activity_error = Some(format!(
"children: degraded/incomplete · {}",
page.issues.join("; ")
));
}
}
#[must_use]
pub fn expanded_monitor_sessions(&self) -> Vec<SessionId> {
self.child_expanded
.iter()
.map(|key| key.session_id)
.collect()
}
#[must_use]
pub fn monitor_discovery_frontier(&self) -> Vec<SessionId> {
let mut frontier: Vec<_> = self
.child_summaries
.iter()
.map(|summary| summary.id)
.collect();
frontier.sort_unstable();
frontier
}
#[must_use]
pub fn monitor_pending_activity_sessions(&self) -> Vec<SessionId> {
self.child_pages
.iter()
.filter_map(|(id, page)| page.next_after.map(|_| *id))
.collect()
}
#[allow(clippy::must_use_candidate)] pub fn monitor_activity_cursor(&self, session_id: SessionId) -> Option<SessionId> {
if self.session_id == Some(session_id) {
self.child_activity
.as_ref()
.and_then(|page| page.next_after)
} else {
self.child_pages
.get(&session_id)
.and_then(|page| page.next_after)
}
}
#[allow(clippy::must_use_candidate)] pub fn monitor_frontier_cursor(&self, parent_id: SessionId) -> Option<SessionId> {
self.child_cursors.get(&parent_id).copied().flatten()
}
pub fn monitor_observation_failed(&mut self, session_id: SessionId, error: &str) {
self.child_activity_error = Some(format!("child {session_id}: stale/degraded · {error}"));
}
pub fn apply_monitor_activity(
&mut self,
session_id: SessionId,
page: bcode_session_models::SessionActivityPage,
) {
let selected = self.selected_monitor_session();
self.child_pages.insert(session_id, page);
if let Some(selected) = selected {
self.child_panel_selected = self
.monitor_tree()
.rows
.iter()
.position(|row| self.child_summaries[row.source_index].id == selected)
.unwrap_or(0);
}
self.child_pages
.retain(|id, _| self.child_summaries.iter().any(|summary| summary.id == *id));
}
fn monitor_tree(&self) -> bcode_session_view::navigation::SessionTreeProjection {
bcode_session_view::navigation::project_session_tree(
&self.child_summaries,
&self.child_expanded,
None,
self.child_summaries.len(),
)
}
#[must_use]
pub fn selected_monitor_session(&self) -> Option<SessionId> {
let tree = self.monitor_tree();
tree.rows
.get(self.child_panel_selected)
.map(|row| self.child_summaries[row.source_index].id)
}
pub fn move_monitor_selection(&mut self, down: bool) {
let count = self.monitor_tree().rows.len();
self.child_panel_selected = if down {
self.child_panel_selected
.saturating_add(1)
.min(count.saturating_sub(1))
} else {
self.child_panel_selected.saturating_sub(1)
};
}
pub fn expand_monitor_selected(&mut self) -> Option<SessionId> {
let session_id = self.selected_monitor_session()?;
let summary = self
.child_summaries
.iter()
.find(|summary| summary.id == session_id)?;
let key = bcode_session_view::navigation::SessionNavigationKey::from_summary(summary);
if self.child_expanded.remove(&key) {
self.child_request_fence.begin(false, session_id);
self.child_request_fence.begin(true, session_id);
return None;
}
self.child_expanded.insert(key);
Some(session_id)
}
#[must_use]
pub fn monitor_next_page(&self) -> Option<(SessionId, SessionId)> {
let parent_id = self.selected_monitor_session().or(self.session_id)?;
self.child_cursors
.get(&parent_id)
.copied()
.flatten()
.map(|after| (parent_id, after))
.or_else(|| {
self.session_id.and_then(|root| {
self.child_cursors
.get(&root)
.copied()
.flatten()
.map(|after| (root, after))
})
})
}
#[must_use]
pub fn monitor_selected_preview_row(&self) -> usize {
self.child_panel_selected.min(4).saturating_add(1)
}
pub fn select_monitor_session(&mut self, session_id: SessionId) {
if let Some(index) = self
.monitor_tree()
.rows
.iter()
.position(|row| self.child_summaries[row.source_index].id == session_id)
{
self.child_panel_selected = index;
self.open_child_panel();
}
}
#[must_use]
pub fn monitor_context(&self) -> super::monitor_presentation::MonitorContext {
use super::monitor_presentation::{MonitorContext, MonitorDescendantBadge};
let observations = bcode_session_view::child_attention::newest_observations(
self.child_activity.iter().chain(self.child_pages.values()),
);
let parents: BTreeMap<_, _> = self
.child_summaries
.iter()
.map(|summary| (summary.id, summary.parent_id))
.collect();
let mut badges: BTreeMap<SessionId, MonitorDescendantBadge> = BTreeMap::new();
for (activity, _) in observations.values() {
let mut parent = activity.parent_id;
let mut visited = BTreeSet::from([activity.session_id]);
for _ in 0..self.child_summaries.len() {
let Some(id) = parent else {
break;
};
if !visited.insert(id) {
break;
}
let Some(next_parent) = parents.get(&id) else {
break;
};
let badge = badges.entry(id).or_default();
badge.active += usize::from(
bcode_session_view::child_activity::has_active_evidence(activity),
);
badge.attention +=
usize::from(bcode_session_view::child_attention::attention_rank(activity) <= 1);
parent = *next_parent;
}
}
MonitorContext {
current_session: self.session_id,
descendant_badges: badges,
}
}
#[must_use]
pub fn monitor_presentation(&self) -> super::monitor_presentation::MonitorPresentation {
use super::monitor_presentation::{MonitorPresentation, MonitorRow};
let tree = self.monitor_tree();
let observations = bcode_session_view::child_attention::newest_observations(
self.child_activity.iter().chain(self.child_pages.values()),
);
let now = super::time_format::unix_time_millis(std::time::SystemTime::now());
let start = self.child_panel_selected.saturating_sub(4);
let rows: Vec<_> = tree
.rows
.iter()
.enumerate()
.skip(start)
.take(8)
.map(|(index, row)| {
let summary = &self.child_summaries[row.source_index];
let observation = observations.get(&summary.id);
let continues = |depth| {
tree.rows
.iter()
.skip(index + 1)
.take_while(|next| next.depth >= depth)
.any(|next| next.depth == depth)
};
MonitorRow {
session_id: summary.id,
title: summary.display_title().to_owned(),
parent_id: summary.parent_id,
detached: row.detached && summary.parent_id != self.session_id,
depth: row.depth,
ancestor_continuations: (0..row.depth.min(4)).map(continues).collect(),
last_sibling: !continues(row.depth),
expanded: self.child_expanded.contains(
&bcode_session_view::navigation::SessionNavigationKey::from_summary(
summary,
),
),
states: observation.map_or_else(
|| vec![bcode_session_models::SessionActivityState::Unknown],
|(activity, _)| {
if activity.states.is_empty() {
vec![bcode_session_models::SessionActivityState::Unknown]
} else {
activity.states.clone()
}
},
),
evidence: observation.and_then(|(activity, _)| activity.evidence.clone()),
age: observation.map_or_else(
|| "unobserved".to_owned(),
|(_, observed)| {
bcode_session_view::child_attention::observation_age_label(
*observed, now,
)
},
),
}
})
.collect();
let selected = self
.child_panel_selected
.checked_sub(start)
.filter(|index| *index < rows.len());
MonitorPresentation {
parent_title: self.session_title().unwrap_or("Session").to_owned(),
overview: self.child_activity.as_ref().map_or_else(
|| "Attention unknown · descendants unscanned".to_owned(),
|root| {
bcode_session_view::child_attention::overview_label(
root,
self.child_activity.iter().chain(self.child_pages.values()),
)
},
),
freshness: self.child_activity.as_ref().map_or_else(
|| "unobserved".to_owned(),
|page| {
bcode_session_view::child_attention::observation_age_label(
page.observed_at_ms,
now,
)
},
),
details: selected.and_then(|index| rows.get(index)).cloned(),
selected,
rows,
coverage: self.child_activity.as_ref().map(|page| page.coverage),
error: self.child_activity_error.clone(),
load_more: self.monitor_next_page().is_some(),
footer: self.child_panel_focus_hint(),
}
}
#[must_use]
pub fn child_panel_rows(&self) -> Vec<String> {
if !self.child_panel_open() {
return Vec::new();
}
let overview = self.child_activity.as_ref().map_or_else(
|| "Attention unknown · descendants unscanned".to_owned(),
|root| {
bcode_session_view::child_attention::overview_label(
root,
self.child_activity.iter().chain(self.child_pages.values()),
)
},
);
let mut rows = vec![format!("{overview} · {}", self.child_panel_focus_hint())];
let tree = self.monitor_tree();
let start = self.child_panel_selected.saturating_sub(4);
let observations = bcode_session_view::child_attention::newest_observations(
self.child_activity.iter().chain(self.child_pages.values()),
);
let now_ms = u64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
)
.unwrap_or(u64::MAX);
for (index, row) in tree.rows.iter().enumerate().skip(start).take(8) {
let summary = &self.child_summaries[row.source_index];
let evidence = observations.get(&summary.id).map_or_else(
|| "unknown · unscanned".to_owned(),
|(activity, observed)| {
let operation = activity
.evidence
.as_ref()
.and_then(|evidence| evidence.current_operation.as_deref())
.map_or(String::new(), |label| format!(" · {label}"));
let nested = self.child_pages.get(&summary.id).map_or(0, |page| {
page.descendants
.iter()
.filter(|child| {
bcode_session_view::child_activity::has_active_evidence(child)
})
.count()
});
format!(
"{}{}{} · {}",
bcode_session_view::child_activity::activity_evidence_label(activity),
operation,
if nested > 0 {
format!(" · {nested} known active descendants")
} else {
String::new()
},
bcode_session_view::child_attention::observation_age_label(
*observed, now_ms
)
)
},
);
let depth = if row.depth > 4 {
format!("[{}] ", row.depth)
} else {
" ".repeat(row.depth)
};
rows.push(format!(
"{}{depth}{} · {evidence}",
if index == self.child_panel_selected {
"› "
} else {
" "
},
summary.display_title()
));
}
if let Some(page) = &self.child_activity {
rows.push(format!(
"own: {} · {} · descendants partial · acceptance unavailable",
bcode_session_view::child_activity::activity_evidence_label(&page.own),
bcode_session_view::child_attention::observation_age_label(
page.observed_at_ms,
now_ms
)
));
rows.extend(page.issues.iter().take(2).cloned());
}
if self.monitor_next_page().is_some() {
rows.push(format!(
"{}: load next bounded child page",
self.shortcut_hint("monitor", "tui.monitor.nextPage")
));
}
rows
}
fn child_panel_focus_hint(&self) -> String {
if self.child_panel_focused() {
format!(
"MONITOR FOCUS · {} select {} open {} expand {} parent {} more · {} composer",
self.shortcut_hint("monitor", "tui.control.down"),
self.shortcut_hint("monitor", "tui.control.submit"),
self.shortcut_hint("monitor", "tui.control.right"),
self.shortcut_hint("monitor", "tui.control.left"),
self.shortcut_hint("monitor", "tui.monitor.nextPage"),
self.shortcut_hint("monitor", "tui.control.cancel")
)
} else {
format!(
"COMPOSER FOCUS · click selects · {} focus",
self.shortcut_hint("chat", "tui.monitor.toggle")
)
}
}
pub fn apply_session_summary(&mut self, summary: &bcode_session_models::SessionSummary) {
if self.session_id != Some(summary.id) {
self.child_activity = None;
self.child_activity_error = None;
self.child_summaries.clear();
self.child_pages.clear();
self.child_cursors.clear();
self.child_expanded.clear();
self.child_panel_selected = 0;
}
self.session_id = Some(summary.id);
self.session_view.set_session_summary(summary.clone());
}
pub fn apply_plugin_presentation_config(
&mut self,
config: &bcode_config::BcodeConfig,
) -> Result<bool, String> {
let output = bcode_config::ShellToolOutputConfig {
format_commands: config.tools.shell.output.format_commands,
presentation: config.tools.shell.output.presentation.clone(),
..bcode_config::ShellToolOutputConfig::default()
};
let configuration = bcode_plugin_sdk::tui::PluginTuiPresentationConfiguration {
version: 1,
config: serde_json::json!({"shell": {"output": output}}),
};
let Some(presentation) = self.plugin_presentation.as_ref() else {
return Ok(false);
};
let result = presentation.configure(configuration);
if !matches!(result, Ok(false)) {
self.elapsed_layout_revision = self.elapsed_layout_revision.saturating_add(1);
self.transcript_layout = TranscriptLayoutCache::default();
}
result
}
pub fn apply_tui_config(&mut self, config: TuiConfig) {
let markdown_changed = self.tui_config.markdown != config.markdown;
self.apply_thinking_config(config.thinking);
self.tui_config = config;
self.theme_preview_id = None;
self.invalidate_theme_catalog();
if markdown_changed {
self.markdown_presentation_revision =
self.markdown_presentation_revision.saturating_add(1);
}
self.sync_theme_target(Instant::now());
}
pub(crate) fn theme_catalog(&mut self) -> Option<&DiscoveredThemes> {
if self.theme_catalog.is_none() {
let discovered = super::theme::discover_theme_catalog(self).ok();
#[cfg(test)]
{
self.observations.theme_catalog_discovery_count = self
.observations
.theme_catalog_discovery_count
.saturating_add(1);
}
self.theme_catalog = discovered;
}
self.theme_catalog.as_ref()
}
pub(crate) fn resolve_theme_selection_cached(
&mut self,
theme_id: &str,
) -> Option<ResolvedThemeDefinition> {
let overlays = self.tui_config.theme.overlays.clone();
let variant = super::theme::resolve_variant_for_config(self.tui_config.theme.variant);
self.theme_catalog()?
.catalog
.resolve(
&super::theme::definition::ThemeSelection::new(theme_id)
.overlays(overlays)
.variant(variant),
)
.ok()
}
#[cfg(test)]
pub(crate) const fn theme_catalog_discovery_count(&self) -> usize {
self.observations.theme_catalog_discovery_count
}
pub(crate) fn invalidate_theme_catalog(&mut self) {
self.theme_catalog = None;
}
fn install_theme_preview(&mut self, theme_id: &str, resolved: &ResolvedThemeDefinition) {
let accent = resolved
.style("border.focused")
.and_then(|style| style.fg)
.or_else(|| resolved.color("accent"))
.unwrap_or(super::theme::PENDING_AGENT_METADATA_ACCENT);
let target = super::theme::resolved_definition_theme(Some(resolved), accent);
self.theme_preview_id = Some(theme_id.to_owned());
self.target_theme = target;
self.theme_transition
.set_target(target.accent, &self.tui_config.theme, Instant::now());
self.update_theme_animation(Instant::now());
}
pub(crate) fn preview_resolved_theme(&mut self, theme_id: &str, target: &ResolvedTheme) {
self.theme_preview_id = Some(theme_id.to_owned());
self.target_theme = *target;
self.theme_transition
.set_target(target.accent, &self.tui_config.theme, Instant::now());
self.update_theme_animation(Instant::now());
}
pub fn reload_theme_if_valid(&mut self) -> Option<&str> {
self.invalidate_theme_catalog();
let theme_id = self
.theme_preview_id
.as_deref()
.unwrap_or(&self.tui_config.theme.name)
.to_owned();
let resolved = self.resolve_theme_selection_cached(&theme_id)?;
let configured_theme_accent = super::theme::resolved_accent(Some(&resolved));
let accent = super::theme::resolve_app_accent(self, configured_theme_accent);
let target = super::theme::resolved_definition_theme(Some(&resolved), accent);
if target == self.target_theme {
return Some(
self.theme_preview_id
.as_deref()
.unwrap_or(&self.tui_config.theme.name),
);
}
self.target_theme = target;
self.theme_transition
.set_target(target.accent, &self.tui_config.theme, Instant::now());
self.update_theme_animation(Instant::now());
Some(
self.theme_preview_id
.as_deref()
.unwrap_or(&self.tui_config.theme.name),
)
}
pub fn preview_theme(&mut self, theme_id: &str) -> bool {
let Some(resolved) = self.resolve_theme_selection_cached(theme_id) else {
return false;
};
self.install_theme_preview(theme_id, &resolved);
true
}
pub fn cancel_theme_preview(&mut self) {
self.theme_preview_id = None;
self.sync_theme_target(Instant::now());
}
pub fn apply_theme(&mut self, theme_id: &str) -> bool {
if !self.preview_theme(theme_id) {
return false;
}
theme_id.clone_into(&mut self.tui_config.theme.name);
self.theme_preview_id = None;
self.sync_theme_target(Instant::now());
true
}
pub fn apply_presentation_config(&mut self, config: bcode_config::PresentationConfig) -> bool {
self.apply_streaming_presentation_policy(config.streaming.policy())
}
pub fn apply_streaming_presentation_policy(
&mut self,
policy: bcode_session_view_models::StreamingPresentationPolicy,
) -> bool {
let changed = self
.session_view
.set_streaming_presentation_policy(policy.normalized());
if changed {
let _ = self.apply_session_view_terminal_adapter();
}
changed
}
#[must_use]
pub const fn tui_config(&self) -> &TuiConfig {
&self.tui_config
}
#[must_use]
pub fn effective_diff_viewer_config(&self) -> TuiDiffViewerConfig {
TuiDiffViewerConfig {
layout: self
.diff_viewer_layout_override
.unwrap_or(self.tui_config.diff_viewer.layout),
side_by_side_breakpoint: self.tui_config.diff_viewer.side_by_side_breakpoint,
}
}
pub fn cycle_diff_viewer_layout(&mut self) {
let next = match self
.diff_viewer_layout_override
.unwrap_or(self.tui_config.diff_viewer.layout)
{
TuiDiffViewerLayout::Auto => TuiDiffViewerLayout::Unified,
TuiDiffViewerLayout::Unified => TuiDiffViewerLayout::SideBySide,
TuiDiffViewerLayout::SideBySide => TuiDiffViewerLayout::Auto,
};
self.diff_viewer_layout_override = Some(next);
let label = match next {
TuiDiffViewerLayout::Auto => "automatic",
TuiDiffViewerLayout::Unified => "unified",
TuiDiffViewerLayout::SideBySide => "side-by-side",
};
self.set_status(format!("Diff layout: {label}"));
}
#[must_use]
pub fn selected_provider_plugin_id(&self) -> Option<&str> {
self.session_view
.snapshot()
.runtime
.provider_plugin_id
.as_deref()
}
#[must_use]
pub fn selected_model_id(&self) -> Option<&str> {
self.session_view
.snapshot()
.runtime
.requested_model_id
.as_deref()
}
#[must_use]
pub fn current_agent_id(&self) -> &str {
self.session_view
.snapshot()
.runtime
.agent_id
.as_deref()
.unwrap_or("build")
}
pub fn owning_agent_id(&self) -> Option<&str> {
self.session_view.snapshot().runtime.agent_id.as_deref()
}
#[must_use]
pub fn pending_agent_id(&self) -> Option<&str> {
self.pending_agent_id.as_deref()
}
#[must_use]
pub const fn is_agent_metadata_hydrated(&self) -> bool {
matches!(
self.agent_metadata_hydration,
AgentMetadataHydration::Hydrated
)
}
pub fn set_agent_metadata_hydrated(&mut self, hydrated: bool) {
self.agent_metadata_hydration = if hydrated {
AgentMetadataHydration::Hydrated
} else {
AgentMetadataHydration::Pending
};
self.sync_theme_target(Instant::now());
}
#[must_use]
pub const fn presented_theme(&self) -> PresentedTheme {
self.presented_theme
}
pub fn update_theme_animation(&mut self, now: Instant) -> UiInvalidation {
let accent = self.theme_transition.update(now);
self.presented_theme = self.target_theme.presented(accent);
UiInvalidation::Paint
}
#[cfg(test)]
pub fn animated_accent(&mut self, target_accent: Color, now: Instant) -> Color {
self.theme_transition
.set_target(target_accent, &self.tui_config.theme, now);
self.update_theme_animation(now);
self.presented_theme.accent
}
#[must_use]
pub fn theme_transition_active(&self, now: Instant) -> bool {
self.theme_transition.is_active(now)
}
pub(crate) fn take_theme_transition_state_from(&mut self, source: &Self) {
self.target_theme = source.target_theme;
self.presented_theme = source.presented_theme;
self.theme_transition = source.theme_transition;
self.sync_theme_target(Instant::now());
}
fn sync_theme_target(&mut self, now: Instant) {
let target = super::theme::resolve_theme(self);
self.target_theme = target;
self.theme_transition
.set_target(target.accent, &self.tui_config.theme, now);
self.update_theme_animation(now);
}
#[must_use]
pub fn display_agent_id(&self) -> &str {
self.pending_agent_id
.as_deref()
.unwrap_or_else(|| self.current_agent_id())
}
#[must_use]
pub fn current_agent_accent(&self) -> Option<&str> {
self.current_agent_accent.as_deref()
}
#[must_use]
pub fn display_agent_accent(&self) -> Option<&str> {
self.pending_agent_accent
.as_deref()
.or_else(|| self.current_agent_accent())
}
pub fn set_current_agent_id(&mut self, agent_id: impl Into<String>) {
let agent_id = agent_id.into();
self.session_view.set_agent_id(Some(agent_id));
self.current_agent_accent = None;
self.clear_pending_agent_fields();
self.sync_theme_target(Instant::now());
}
pub fn set_current_agent(&mut self, agent_id: impl Into<String>, accent: Option<String>) {
let agent_id = agent_id.into();
self.session_view.set_agent_id(Some(agent_id));
self.current_agent_accent = accent;
self.clear_pending_agent_fields();
self.sync_theme_target(Instant::now());
}
pub fn set_pending_agent(&mut self, agent_id: impl Into<String>, accent: Option<String>) {
self.pending_agent_id = Some(agent_id.into());
self.pending_agent_accent = accent;
self.sync_theme_target(Instant::now());
}
fn clear_pending_agent_fields(&mut self) {
self.pending_agent_id = None;
self.pending_agent_accent = None;
}
pub fn take_pending_agent(&mut self) -> Option<String> {
let agent_id = self.pending_agent_id.take()?;
self.session_view.set_agent_id(Some(agent_id.clone()));
self.current_agent_accent = self.pending_agent_accent.take();
self.sync_theme_target(Instant::now());
Some(agent_id)
}
pub fn commit_submitted_agent(&mut self, agent_id: &str) {
if self.pending_agent_id() == Some(agent_id) {
let _ = self.take_pending_agent();
} else {
self.session_view.set_agent_id(Some(agent_id.to_owned()));
}
}
#[must_use]
pub fn thinking_label(&self) -> &str {
&self.thinking_label
}
#[must_use]
pub fn model_header_label(&self) -> String {
let model = self
.session_view
.snapshot()
.runtime
.display_name
.as_deref()
.or_else(|| self.selected_model_id())
.unwrap_or("default");
self.reasoning_header_label().map_or_else(
|| model.to_owned(),
|reasoning| format!("{model} [{reasoning}]"),
)
}
fn reasoning_header_label(&self) -> Option<&str> {
self.reasoning_support.is_supported().then(|| {
self.reasoning_effort()
.or(self.reasoning_default_effort.as_deref())
.unwrap_or("supported")
})
}
#[must_use]
pub fn token_summary(&self) -> String {
self.token_usage.footer_summary(
self.session_view
.snapshot()
.runtime
.context_occupancy
.as_ref(),
self.session_view
.snapshot()
.runtime
.cumulative_metered_tokens,
&self.session_view.snapshot().runtime.cost,
)
}
#[must_use]
pub const fn composer_panel_area(&self) -> Rect {
let content = self.composer_content_area();
Rect::new(
content.x.saturating_sub(2),
content.y.saturating_sub(1),
content.width.saturating_add(4),
content.height.saturating_add(2),
)
}
#[must_use]
pub const fn composer_content_area(&self) -> Rect {
self.composer.content_area()
}
pub fn set_composer_content_area(&mut self, area: Rect) {
self.composer.set_content_area(area, &composer_policy());
}
pub fn composer_scroll_offset_for_render(&self) -> usize {
let offset = if self.composer.vertical_scroll() == u64::MAX {
self.composer
.cursor_scroll_offset(&composer_policy())
.unwrap_or(0)
} else {
self.composer.vertical_scroll()
};
usize::try_from(offset).unwrap_or(usize::MAX)
}
#[must_use]
pub const fn composer_state(&self) -> &TextInputState {
&self.composer
}
pub const fn composer_state_mut(&mut self) -> &mut TextInputState {
&mut self.composer
}
#[must_use]
pub const fn composer(&self) -> &TextEditBuffer {
self.composer.buffer()
}
pub fn composer_mut(&mut self) -> &mut TextEditBuffer {
self.composer.buffer_mut()
}
pub fn paste_composer_text(&mut self, text: &str) {
TextInputControl::new(&composer_policy()).handle_paste(&mut self.composer, text);
}
pub fn set_submission_queue(&mut self, status: bcode_session_models::SubmissionQueueStatus) {
self.queue_recovery.generation = self.queue_recovery.generation.wrapping_add(1);
if self
.queue_recovery
.confirmation
.as_ref()
.is_some_and(|confirmation| {
!status.oldest.as_ref().is_some_and(|record| {
record.receipt.submission_id == confirmation.submission_id
&& record.receipt.accepted_event_sequence
== confirmation.expected_accepted_sequence
})
})
{
self.queue_recovery.confirmation = None;
self.queue_recovery.settings_visible = false;
}
self.session_view.set_submission_queue(status);
}
pub fn dismiss_queue_confirmation(&mut self) {
self.queue_recovery.generation = self.queue_recovery.generation.wrapping_add(1);
self.queue_recovery.confirmation = None;
self.queue_recovery.settings_visible = false;
}
pub fn set_submission_queue_unavailable(&mut self) {
self.dismiss_queue_confirmation();
self.session_view.set_submission_queue_unavailable();
}
pub fn apply_queue_recovery_result(
&mut self,
generation: u64,
outcome: bcode_session_view_models::SessionViewActionOutcome,
status: bcode_session_models::SubmissionQueueStatus,
) -> bool {
if self.queue_recovery.generation != generation {
return false;
}
self.set_submission_queue(status);
if let bcode_session_view_models::SessionViewActionOutcome::SubmissionResumePrepared {
confirmation,
} = outcome
{
self.prepare_queue_confirmation(confirmation);
} else {
self.dismiss_queue_confirmation();
}
true
}
pub fn prepare_queue_confirmation(
&mut self,
confirmation: bcode_session_models::SubmissionResumeConfirmation,
) {
self.queue_recovery.settings_visible = false;
self.queue_recovery.confirmation = self
.session_view
.snapshot()
.runtime
.queue_recovery()
.filter(|recovery| {
recovery.can_resume
&& recovery.submission_id == confirmation.submission_id
&& recovery.expected_accepted_sequence
== confirmation.expected_accepted_sequence
})
.map(|_| confirmation);
}
#[must_use]
pub const fn session_view_snapshot(&self) -> &bcode_session_view_models::SessionViewSnapshot {
self.session_view.snapshot()
}
#[must_use]
pub fn transcript(&self) -> TranscriptItems<'_> {
self.transcript.items()
}
#[must_use]
pub fn tool_invocation_is_terminal(&self, invocation_id: &str) -> bool {
self.session_view
.snapshot()
.tools
.get(invocation_id)
.is_some_and(|tool| {
matches!(
tool.status,
bcode_session_view_models::ToolInvocationViewStatus::Finished
| bcode_session_view_models::ToolInvocationViewStatus::Cancelled
| bcode_session_view_models::ToolInvocationViewStatus::Failed
)
})
}
#[must_use]
pub const fn elapsed_layout_revision(&self) -> u64 {
self.elapsed_layout_revision
}
#[must_use]
pub const fn transcript_projection_revision(&self) -> u64 {
self.transcript_projection_revision
}
pub fn drain_transcript_dirty_items(&mut self) -> BTreeSet<usize> {
std::mem::take(&mut self.transcript_dirty_items)
.into_iter()
.filter_map(|id| self.transcript.presentation_index(id))
.collect()
}
#[cfg(test)]
#[must_use]
pub const fn transcript_dirty_items_for_test(
&self,
) -> &BTreeSet<TranscriptPresentationEntryId> {
&self.transcript_dirty_items
}
pub fn drain_elapsed_dirty_visuals_bounded(&mut self, limit: usize) -> BTreeSet<String> {
let mut drained = BTreeSet::new();
for _ in 0..limit {
let Some(invocation_id) = self.elapsed_dirty_visuals.pop_first() else {
break;
};
drained.insert(invocation_id);
}
drained
}
#[cfg(test)]
pub fn drain_elapsed_dirty_visuals(&mut self) -> BTreeSet<String> {
std::mem::take(&mut self.elapsed_dirty_visuals)
}
#[must_use]
pub const fn pending_submissions_projection_revision(&self) -> u64 {
self.pending_submissions.revision()
}
pub fn extend_composer_selection(&mut self, motion: TextMotion) {
self.input_history.reset_navigation();
let width = usize::from(self.composer.content_area().width.max(1));
match motion {
TextMotion::VisualUp => self.extend_composer_selection_to_visual_delta(width, -1),
TextMotion::VisualDown => self.extend_composer_selection_to_visual_delta(width, 1),
motion => self
.composer
.buffer_mut()
.move_cursor_with_selection(motion, SelectionMode::Extend),
}
self.wake_cursor();
}
pub fn handle_composer_mouse(&mut self, mouse: MouseEvent) -> TextInputOutcome {
let outcome =
TextInputControl::new(&composer_policy()).handle_mouse(&mut self.composer, mouse);
if matches!(outcome, TextInputOutcome::Edited | TextInputOutcome::Redraw) {
self.input_history.reset_navigation();
self.wake_cursor();
}
outcome
}
#[must_use]
pub const fn composer_mouse_selection_active(&self) -> bool {
self.composer.mouse_selection_active()
}
pub fn move_composer_visual_up(&mut self) -> bool {
self.move_composer_visual_up_with_history_reset(true)
}
pub fn move_composer_visual_up_preserving_history(&mut self) -> bool {
self.move_composer_visual_up_with_history_reset(false)
}
pub fn move_composer_visual_down(&mut self) -> bool {
self.move_composer_visual_down_with_history_reset(true)
}
pub fn move_composer_visual_down_preserving_history(&mut self) -> bool {
self.move_composer_visual_down_with_history_reset(false)
}
fn move_composer_visual_up_with_history_reset(&mut self, reset_history: bool) -> bool {
let width = usize::from(self.composer.content_area().width.max(1));
let layout = self.composer.buffer().wrapped_layout(width);
if layout.cursor.row == 0 {
return false;
}
if reset_history {
self.input_history.reset_navigation();
}
self.composer.buffer_mut().move_cursor_to_wrapped_position(
width,
layout.cursor.row.saturating_sub(1),
layout.cursor.col,
);
self.wake_cursor();
true
}
fn move_composer_visual_down_with_history_reset(&mut self, reset_history: bool) -> bool {
let width = usize::from(self.composer.content_area().width.max(1));
let layout = self.composer.buffer().wrapped_layout(width);
if layout.cursor.row.saturating_add(1) >= layout.lines.len() {
return false;
}
if reset_history {
self.input_history.reset_navigation();
}
self.composer.buffer_mut().move_cursor_to_wrapped_position(
width,
layout.cursor.row.saturating_add(1),
layout.cursor.col,
);
self.wake_cursor();
true
}
pub fn apply_runtime_selection(
&mut self,
selection: bcode_ipc::SessionRuntimeSelection,
agent_accent: Option<String>,
) {
let provider_plugin_id = selection
.provider_plugin_id
.or_else(|| self.selected_provider_plugin_id().map(ToOwned::to_owned));
let requested_model_id = selection
.requested_model_id
.or(selection.model_id)
.or_else(|| self.selected_model_id().map(ToOwned::to_owned));
let effective_model_id = selection.effective_model_id.or_else(|| {
self.session_view
.snapshot()
.runtime
.effective_model_id
.clone()
});
let context_occupancy = self
.session_view
.snapshot()
.runtime
.context_occupancy
.clone();
self.session_view.set_runtime_selection(
provider_plugin_id,
requested_model_id,
effective_model_id,
selection.reasoning_effort,
selection.reasoning_summary,
context_occupancy,
);
if let Some(agent_id) = selection.agent_id {
self.session_view.set_agent_id(Some(agent_id));
self.current_agent_accent = agent_accent;
self.sync_theme_target(Instant::now());
}
self.refresh_thinking_label();
}
pub fn apply_model_status(&mut self, status: bcode_model::SessionModelStatus) {
let provider_plugin_id = status
.provider_plugin_id
.clone()
.or_else(|| self.selected_provider_plugin_id().map(ToOwned::to_owned));
let requested_model_id = status
.requested_model_id
.clone()
.or_else(|| status.model_id.clone())
.or_else(|| self.selected_model_id().map(ToOwned::to_owned));
let effective_model_id = status.effective_model_id.clone().or_else(|| {
self.session_view
.snapshot()
.runtime
.effective_model_id
.clone()
});
self.session_view.set_model_selection(
provider_plugin_id,
requested_model_id,
effective_model_id,
status.display_name.clone(),
);
if let Some(effort) = status.reasoning_effort.as_deref() {
self.reconcile_pending_reasoning_effort(effort);
}
self.selected_auth_profile.clone_from(&status.auth_profile);
self.selected_context_format_version = status.context_format_version;
self.selected_compatibility_key
.clone_from(&status.compatibility_key);
self.session_view.set_reasoning_selection(
status.reasoning_effort.clone(),
status.reasoning_summary.clone(),
);
self.reasoning_support = if status.reasoning.is_some() {
ReasoningSupport::Supported
} else {
ReasoningSupport::Unsupported
};
self.reasoning_effort_values = status
.reasoning
.as_ref()
.map_or_else(Vec::new, |reasoning| {
bcode_model::ordered_reasoning_effort_values(&reasoning.effort_values)
});
self.reasoning_default_effort = status
.reasoning
.as_ref()
.and_then(|reasoning| reasoning.default_effort.clone());
self.reasoning_default_summary = status
.reasoning
.as_ref()
.and_then(|reasoning| reasoning.default_summary.clone());
self.refresh_thinking_label();
let model = status
.context_window
.map(|context_window| bcode_model::ModelInfo {
model_id: self.selected_model_id().unwrap_or_default().to_owned(),
display_name: status
.display_name
.clone()
.or_else(|| self.selected_model_id().map(ToOwned::to_owned))
.unwrap_or_default(),
is_default: false,
context_window: Some(context_window),
max_output_tokens: status.max_output_tokens,
max_image_input_base64_bytes: None,
capabilities: std::collections::BTreeSet::new(),
feature_support: bcode_model::ModelFeatureSupport::default(),
reasoning: status.reasoning.clone(),
cache: bcode_model::ModelCacheInfo::default(),
metadata_source: None,
pricing: status.pricing.clone(),
api_surface: None,
visibility: bcode_model::ModelVisibility::Visible,
});
self.token_usage.apply_model_info(model.as_ref());
self.token_usage.proactive_compaction_target = status
.proactive_compaction_effective_threshold_tokens
.filter(|_| {
matches!(
status.compaction_mode.as_deref(),
Some("proactive" | "proactive_and_overflow")
)
});
self.apply_context_occupancy(status.context_occupancy.map(|occupancy| *occupancy));
}
pub fn mark_transcript_item_dirty(&mut self, index: usize) {
if let Some(id) = self.transcript.presentation_id(index) {
self.transcript_dirty_items.insert(id);
self.transcript_projection_revision =
self.transcript_projection_revision.saturating_add(1);
}
}
#[must_use]
pub fn pending_submissions(&self) -> &[PendingSubmission] {
self.pending_submissions.items()
}
#[must_use]
pub const fn transcript_layout(&self) -> &TranscriptLayoutCache {
&self.transcript_layout
}
#[must_use]
pub const fn transcript_layout_mut(&mut self) -> &mut TranscriptLayoutCache {
&mut self.transcript_layout
}
#[must_use]
pub const fn transcript_markdown_cache(
&self,
) -> &crate::transcript_markdown_cache::TranscriptMarkdownCache {
&self.transcript_markdown_cache
}
#[must_use]
pub const fn scroll_offset(&self) -> usize {
self.viewport.offset()
}
#[must_use]
pub const fn bottom_overscroll(&self) -> usize {
self.viewport.bottom_overscroll()
}
#[must_use]
pub fn newer_transcript_content_below(&self) -> bool {
self.hidden_entry_start_row_below_viewport().is_some()
}
fn resolve_pending_transcript_anchor(&self) -> Option<usize> {
let anchor = self.pending_stable_transcript_anchor.as_ref()?;
let index = self
.transcript
.presentation_index(anchor.item_id)
.unwrap_or_else(|| {
anchor
.fallback_index
.min(self.transcript.items().len().saturating_sub(1))
});
let start = self
.transcript_layout
.entry_start_row(VisibleTranscriptSource::Transcript, index)?;
let rows = self
.transcript_layout
.entry_row_count(VisibleTranscriptSource::Transcript, index)?;
let row = if self.transcript.presentation_id(index) == Some(anchor.item_id) {
anchor.resolve_row(&self.transcript_layout, index, rows)
} else {
0
};
Some(
start
.saturating_add(row)
.saturating_sub(anchor.viewport_row_offset),
)
}
pub(crate) fn needs_latest_bar_at_height(&self, height: u16) -> bool {
let total_rows = self.transcript_layout.total_rows();
let mut viewport = self.viewport;
let mut history = self.older_history.clone();
let anchor = self.resolve_pending_transcript_anchor();
viewport.sync_with_anchor(
(
total_rows.saturating_sub(usize::from(height)),
usize::from(height).saturating_sub(1),
total_rows,
height,
),
anchor,
self.older_history_page_rows(),
self.newer_history_page_rows(),
&mut history,
);
let bottom = viewport.bottom_row(total_rows);
bottom < total_rows
&& (self.transcript_layout.changed_content_below(bottom)
|| self.latest_hidden_activity_at.is_some()
|| self
.transcript_layout
.first_entry_start_at_or_after_row(bottom)
.is_some())
}
fn hidden_entry_start_row_below_viewport(&self) -> Option<usize> {
let total_rows = self.transcript_layout.total_rows();
let viewport_bottom = self.viewport.bottom_row(total_rows);
if viewport_bottom >= total_rows {
return None;
}
if self
.transcript_layout
.changed_content_below(viewport_bottom)
|| self.latest_hidden_activity_at.is_some()
{
Some(viewport_bottom)
} else {
self.transcript_layout
.first_entry_start_at_or_after_row(viewport_bottom)
}
}
#[must_use]
pub const fn latest_hidden_activity_at(&self) -> Option<Instant> {
self.latest_hidden_activity_at
}
#[must_use]
pub const fn activity_frame(&self) -> usize {
self.activity_frame
}
#[must_use]
pub const fn activity_elapsed(&self) -> Duration {
self.activity_elapsed
}
#[must_use]
pub const fn latest_hidden_activity_burst(&self) -> u8 {
self.latest_hidden_activity_burst
}
#[must_use]
pub const fn latest_bar_animation_started_at(&self) -> Instant {
self.latest_bar_animation_started_at
}
#[must_use]
pub fn jump_to_latest_key_label(&self) -> &str {
&self.jump_to_latest_key_label
}
#[cfg(test)]
pub fn stable_transcript_anchor(&self) -> Option<(TranscriptPresentationEntryId, usize)> {
let top_row = self.transcript_top_row(self.viewport.height());
let line = self
.transcript_layout
.visible_lines_from_top(top_row, 1)
.first()
.copied()?;
if line.source != VisibleTranscriptSource::Transcript {
return None;
}
Some((
self.transcript.presentation_id(line.entry_index)?,
line.row_in_entry,
))
}
#[cfg(test)]
pub fn transcript_row_for_source_position_for_test(
&self,
sequence: u64,
position: usize,
) -> Option<usize> {
let index = self
.transcript
.items()
.iter()
.position(|item| item.event_sequence() == Some(sequence))?;
self.transcript_layout.source_row(index, position)
}
#[cfg(test)]
pub fn stable_transcript_source_position_for_test(&self) -> Option<(u64, usize)> {
let top_row = self.transcript_top_row(self.viewport.height());
let line = self
.transcript_layout
.visible_lines_from_top(top_row, 1)
.first()
.copied()?;
if line.source != VisibleTranscriptSource::Transcript {
return None;
}
Some((
self.transcript
.items()
.get(line.entry_index)?
.event_sequence()?,
self.transcript_layout
.source_position(line.entry_index, line.row_in_entry)?,
))
}
#[cfg(test)]
pub fn transcript_presentation_id_for_test(
&self,
index: usize,
) -> Option<TranscriptPresentationEntryId> {
self.transcript.presentation_id(index)
}
#[cfg(test)]
pub fn transcript_origin_for_test(
&self,
index: usize,
) -> Option<&TranscriptPresentationOrigin> {
self.transcript.origin(index)
}
#[cfg(test)]
pub fn scroll_transcript_to_row_for_test(&mut self, row: usize) {
self.viewport.detach();
self.transcript_scroll_animation = None;
self.viewport.follow_anchor(row);
}
#[cfg(test)]
pub fn latest_user_message_start_row_for_test(&self) -> Option<usize> {
self.latest_user_message_start_row()
}
#[cfg(test)]
#[must_use]
pub const fn transcript_viewport_height_for_test(&self) -> u16 {
self.viewport.height()
}
fn transcript_scroll_animation_now(&self) -> Instant {
self.transcript_scroll_clock.now()
}
#[cfg(test)]
pub(crate) fn freeze_transcript_scroll_animation_for_test(&mut self) {
if matches!(
self.transcript_scroll_clock,
TranscriptScrollClock::Realtime
) {
self.transcript_scroll_clock = TranscriptScrollClock::Fixed(Instant::now());
}
}
#[cfg(test)]
pub(crate) fn advance_transcript_scroll_animation_for_test(&mut self, duration: Duration) {
self.freeze_transcript_scroll_animation_for_test();
self.transcript_scroll_clock = TranscriptScrollClock::Fixed(
self.transcript_scroll_animation_now()
.checked_add(duration)
.expect("frame harness scroll-animation time must fit in Instant"),
);
}
#[must_use]
pub fn transcript_top_row(&self, viewport_height: u16) -> usize {
if let Some(animation) = self.transcript_scroll_animation {
return animation.top_row_at(self.transcript_scroll_animation_now());
}
self.viewport
.top_row(self.transcript_layout.total_rows(), viewport_height)
}
#[must_use]
pub fn interaction_transcript_index(&self, interaction_id: &str) -> Option<usize> {
self.transcript().iter().position(|item| {
item.interaction()
.is_some_and(|interaction| interaction.interaction_id == interaction_id)
})
}
pub fn focus_interaction_in_transcript(&mut self, interaction_id: &str) -> bool {
let Some(index) = self.interaction_transcript_index(interaction_id) else {
return false;
};
let Some(top_row) = self
.transcript_layout
.entry_start_row(VisibleTranscriptSource::Transcript, index)
else {
return false;
};
self.transcript_scroll_animation = None;
self.viewport.detach();
self.viewport.follow_anchor(top_row);
true
}
#[must_use]
pub fn transcript_item_row_range(&self, index: usize) -> Option<std::ops::Range<usize>> {
let start = self
.transcript_layout
.entry_start_row(VisibleTranscriptSource::Transcript, index)?;
let rows = self
.transcript_layout
.entry_row_count(VisibleTranscriptSource::Transcript, index)?;
Some(start..start.saturating_add(rows))
}
#[must_use]
pub fn active_interaction_layout(&self) -> Option<(&str, u16)> {
self.active_interaction_layout
.as_ref()
.map(|(interaction_id, rows)| (interaction_id.as_str(), *rows))
}
pub fn set_active_interaction_layout(&mut self, active: Option<(String, u16)>) {
if self.active_interaction_layout == active {
return;
}
let previous_id = self
.active_interaction_layout
.as_ref()
.map(|(interaction_id, _)| interaction_id.clone());
let next_id = active
.as_ref()
.map(|(interaction_id, _)| interaction_id.clone());
self.active_interaction_layout = active;
for interaction_id in [previous_id, next_id].into_iter().flatten() {
if let Some(index) = self.interaction_transcript_index(&interaction_id) {
self.mark_transcript_item_dirty(index);
}
}
}
pub const fn set_transcript_selection_pinned(&mut self, pinned: bool) {
self.transcript_selection_pinned = pinned;
}
#[cfg(test)]
pub const fn resident_transcript_event_count(&self) -> usize {
self.transcript_window.len()
}
#[cfg(test)]
pub fn resident_transcript_oldest_sequence(&self) -> Option<u64> {
self.transcript_window.oldest_sequence()
}
#[must_use]
pub const fn has_older_history(&self) -> bool {
self.older_history.has_older_history()
}
#[must_use]
pub const fn history_window_generation(&self) -> u64 {
self.history_window_generation
}
#[must_use]
pub const fn accepts_history_page(
&self,
generation: u64,
cursor: SessionHistoryCursor,
older: bool,
) -> bool {
if generation != self.history_window_generation {
return false;
}
if older {
self.older_history.loading()
&& matches!(self.older_history.cursor(), Some(current) if current.sequence == cursor.sequence)
} else {
self.older_history.loading_newer()
&& matches!(self.older_history.newer_cursor(), Some(current) if current.sequence == cursor.sequence)
}
}
#[must_use]
pub fn accepts_history_page_payload(
&self,
cursor: SessionHistoryCursor,
older: bool,
page: &bcode_session_models::SessionHistoryPage,
) -> bool {
Some(page.session_id) == self.session_id
&& page.compatibility_issues.is_empty()
&& (!page.has_more || !page.events.is_empty())
&& page.next_cursor.is_none_or(|next| {
page.has_more
&& if older {
page.events
.first()
.is_some_and(|first| next.sequence < first.sequence)
} else {
page.events
.last()
.is_some_and(|last| next.sequence > last.sequence)
}
})
&& self.accepts_history_page_events(cursor, older, &page.events, page.has_more)
}
#[must_use]
pub fn accepts_history_page_events(
&self,
cursor: SessionHistoryCursor,
older: bool,
events: &[SessionEvent],
has_more: bool,
) -> bool {
if events.is_empty() {
return !has_more;
}
let valid = events
.iter()
.all(|event| Some(event.session_id) == self.session_id)
&& events
.windows(2)
.all(|pair| pair[0].sequence < pair[1].sequence);
if !valid {
return false;
}
if older {
events
.last()
.is_some_and(|last| last.sequence <= cursor.sequence)
&& self.transcript_window.events().first().is_none_or(|first| {
events
.last()
.is_some_and(|last| last.sequence < first.sequence)
})
} else {
events
.first()
.is_some_and(|first| first.sequence >= cursor.sequence)
&& self.transcript_window.events().last().is_none_or(|last| {
events
.first()
.is_some_and(|first| first.sequence > last.sequence)
})
}
}
#[must_use]
pub const fn loading_older_history(&self) -> bool {
self.older_history.loading()
}
pub fn set_loading_older_history(&mut self, loading: bool) {
self.restore_uncommitted_navigation();
self.older_history.set_loading(loading);
}
#[must_use]
pub const fn older_history_cursor(&self) -> Option<SessionHistoryCursor> {
self.older_history.cursor()
}
#[must_use]
pub const fn should_load_older_history(&self) -> bool {
self.older_history.should_load()
}
#[cfg(test)]
pub(crate) const fn request_older_history_for_test(&mut self, reveal_rows: usize) {
self.older_history.request_load(reveal_rows);
}
pub fn set_loading_newer_history(&mut self, loading: bool) {
self.restore_uncommitted_navigation();
self.older_history.set_loading_newer(loading);
}
#[must_use]
pub const fn newer_history_cursor(&self) -> Option<SessionHistoryCursor> {
self.older_history.newer_cursor()
}
#[must_use]
pub const fn should_load_newer_history(&self) -> bool {
self.older_history.should_load_newer()
}
#[cfg(test)]
pub(crate) const fn request_newer_history_for_test(&mut self, reveal_rows: usize) {
self.older_history.request_load_newer(reveal_rows);
}
#[must_use]
pub const fn activity(&self) -> &ActivityState {
&self.activity
}
#[must_use]
pub const fn activity_started_at(&self) -> Instant {
self.activity_started_at
}
#[must_use]
pub const fn daemon_connection(&self) -> DaemonConnectionState {
self.daemon_connection
}
pub const fn set_daemon_connection(&mut self, daemon_connection: DaemonConnectionState) {
self.daemon_connection = daemon_connection;
}
#[must_use]
pub fn execution_mode_indicator(&self) -> Option<&str> {
self.execution_mode_indicator.as_deref()
}
pub fn set_execution_mode_indicator(&mut self, indicator: Option<String>) {
self.execution_mode_indicator = indicator;
}
#[must_use]
pub fn status(&self) -> &str {
&self.status
}
pub fn set_active_skills(&mut self, skills: &[bcode_skill_models::SkillContextResponse]) {
self.session_view.set_active_skill_ids(
skills
.iter()
.map(|skill| skill.skill_id.to_string())
.collect(),
);
}
#[must_use]
pub fn active_skill_count(&self) -> usize {
self.session_view.snapshot().active_skills.len()
}
pub fn set_pending_permission_views(
&mut self,
permissions: Vec<bcode_session_view_models::PermissionView>,
) {
self.session_view.set_pending_permissions(permissions);
}
pub fn set_pending_interactions(
&mut self,
interactions: Vec<bcode_session_view_models::InteractionViewSummary>,
) {
self.session_view.set_pending_interactions(interactions);
self.apply_session_view_terminal_adapter();
}
pub fn plugin_status(
&self,
) -> impl Iterator<Item = &bcode_session_view_models::PluginStatusView> {
self.session_view.snapshot().plugin_status.values()
}
pub fn activity_inspection(&self, index: usize) -> Option<(String, String, serde_json::Value)> {
let source = self.transcript.plugin_activity_source(index)?;
let status = self.plugin_status().find(|status| {
source
== format!(
"bcode.plugin-activity:{}:{}",
status.plugin_id, status.note_id
)
})?;
let surface = status.metadata.get("inspection_surface")?.as_str()?;
let run = status.metadata.get("run_id")?.as_str()?;
Some((
status.plugin_id.clone(),
surface.to_owned(),
serde_json::json!({"run_id": run}),
))
}
pub fn set_plugin_status(
&mut self,
plugin_status: Vec<bcode_session_view_models::PluginStatusView>,
) {
let activity: Vec<_> = plugin_status
.iter()
.map(|status| {
(
format!(
"bcode.plugin-activity:{}:{}",
status.plugin_id, status.note_id
),
status.text.clone(),
)
})
.collect();
if self.transcript.set_plugin_activity(&activity) {
self.transcript_projection_revision =
self.transcript_projection_revision.saturating_add(1);
}
self.session_view.set_plugin_status(plugin_status);
}
#[must_use]
pub fn key_hints(&self) -> &str {
&self.key_hints
}
pub const fn clear_pending_key_activation(&mut self) {
self.pending_key_activation = None;
}
pub fn activate_key_binding(
&mut self,
scope: BmuxScope,
binding: &BmuxKeyBinding,
) -> KeyActivationOutcome {
self.activate_key_binding_at(scope, binding, Instant::now())
}
#[cfg(test)]
pub(crate) fn activate_key_binding_for_test(
&mut self,
scope: BmuxScope,
binding: &BmuxKeyBinding,
now: Instant,
) -> KeyActivationOutcome {
self.activate_key_binding_at(scope, binding, now)
}
fn activate_key_binding_at(
&mut self,
scope: BmuxScope,
binding: &BmuxKeyBinding,
now: Instant,
) -> KeyActivationOutcome {
match binding.activation() {
BmuxKeyActivation::Immediate => {
self.pending_key_activation = None;
KeyActivationOutcome::Activated(binding.action())
}
BmuxKeyActivation::MultiTap {
required_taps,
window_ms,
prompt,
} => self.activate_multi_tap_binding(
scope,
binding,
*required_taps,
*window_ms,
prompt,
now,
),
}
}
fn activate_multi_tap_binding(
&mut self,
scope: BmuxScope,
binding: &BmuxKeyBinding,
required_taps: u8,
window_ms: u64,
prompt: &str,
now: Instant,
) -> KeyActivationOutcome {
let taps = self
.pending_key_activation
.filter(|pending| {
pending.scope == scope
&& pending.stroke == binding.stroke()
&& pending.action == binding.action()
&& pending.expires_at >= now
})
.map_or(1, |pending| pending.taps.saturating_add(1));
if taps >= required_taps.max(1) {
self.pending_key_activation = None;
return KeyActivationOutcome::Activated(binding.action());
}
self.pending_key_activation = Some(PendingKeyActivation {
scope,
stroke: binding.stroke(),
action: binding.action(),
taps,
expires_at: now + Duration::from_millis(window_ms),
});
self.set_status(prompt.to_owned());
KeyActivationOutcome::Pending
}
pub fn push_ephemeral_system_plain(&mut self, text: String) {
self.push_ephemeral_system_notice(text, bcode_session_view_models::TextFormat::PlainText);
}
pub fn push_ephemeral_system_json(&mut self, text: String) {
self.push_ephemeral_system_notice(text, bcode_session_view_models::TextFormat::Json);
}
pub fn push_ephemeral_system_markdown(&mut self, text: String) {
self.push_ephemeral_system_notice(text, bcode_session_view_models::TextFormat::Markdown);
}
fn push_ephemeral_system_notice(
&mut self,
text: String,
format: bcode_session_view_models::TextFormat,
) {
self.transcript.push_ephemeral(
"bcode.tui".to_owned(),
TranscriptItem::with_format("System", text, format),
);
self.transcript_projection_revision = self.transcript_projection_revision.saturating_add(1);
}
pub fn set_status(&mut self, status: String) {
self.status = status;
}
#[must_use]
pub const fn reasoning_visible(&self) -> bool {
self.session_view.snapshot().thinking.visible
}
pub fn set_reasoning_visible(&mut self, visible: bool) {
if self.reasoning_visible() == visible {
return;
}
self.session_view.set_reasoning_visible(visible);
self.refresh_thinking_label();
self.apply_session_view_terminal_adapter();
}
pub fn apply_reasoning_selection(&mut self, effort: Option<String>, summary: Option<String>) {
self.session_view.set_reasoning_selection(effort, summary);
self.refresh_thinking_label();
}
pub fn cycle_pending_reasoning_effort(&mut self) -> Option<&str> {
let next = bcode_model::next_reasoning_effort_value(
&self.reasoning_effort_values,
self.reasoning_effort(),
)?;
self.set_pending_reasoning_effort(next);
self.reasoning_effort()
}
pub fn set_pending_reasoning_effort(&mut self, effort: String) -> u64 {
self.reasoning_effort_generation = self.reasoning_effort_generation.saturating_add(1);
let generation = self.reasoning_effort_generation;
self.pending_reasoning_effort = Some(PendingReasoningEffort {
value: effort,
generation,
});
self.refresh_thinking_label();
generation
}
#[must_use]
pub fn pending_reasoning_effort_generation(&self) -> Option<u64> {
self.pending_reasoning_effort
.as_ref()
.map(|pending| pending.generation)
}
pub fn clear_pending_reasoning_effort_for_session_change(&mut self) {
if self.pending_reasoning_effort.take().is_some() {
self.refresh_thinking_label();
}
}
pub fn reconcile_pending_reasoning_effort(&mut self, effort: &str) {
if self
.pending_reasoning_effort
.as_ref()
.is_some_and(|pending| pending.value == effort)
{
self.pending_reasoning_effort = None;
self.refresh_thinking_label();
}
}
pub fn clear_pending_reasoning_effort(&mut self, generation: u64) {
if self
.pending_reasoning_effort
.as_ref()
.is_some_and(|pending| pending.generation == generation)
{
self.pending_reasoning_effort = None;
self.refresh_thinking_label();
}
}
#[must_use]
pub fn pending_reasoning_effort(&self) -> Option<&str> {
self.pending_reasoning_effort
.as_ref()
.map(|pending| pending.value.as_str())
}
#[must_use]
pub fn reasoning_effort(&self) -> Option<&str> {
self.pending_reasoning_effort.as_ref().map_or_else(
|| {
self.session_view
.snapshot()
.runtime
.reasoning_effort
.as_deref()
},
|pending| Some(pending.value.as_str()),
)
}
#[must_use]
pub fn reasoning_summary(&self) -> Option<&str> {
self.session_view
.snapshot()
.runtime
.reasoning_summary
.as_deref()
}
pub fn apply_thinking_config(&mut self, config: TuiThinkingConfig) {
self.set_reasoning_display_mode(config.mode);
self.set_reasoning_visible(config.show);
}
#[must_use]
pub const fn reasoning_display_mode(&self) -> bcode_config::TuiThinkingMode {
self.reasoning_display_mode
}
pub fn set_reasoning_display_mode(&mut self, mode: bcode_config::TuiThinkingMode) {
if self.reasoning_display_mode != mode {
self.reasoning_display_mode = mode;
self.session_view
.set_reasoning_display_mode(reasoning_view_mode(mode));
self.refresh_thinking_label();
self.apply_session_view_terminal_adapter();
}
}
fn refresh_thinking_label(&mut self) {
let display = if self.reasoning_visible() {
match self.reasoning_display_mode {
bcode_config::TuiThinkingMode::All => "display: all",
bcode_config::TuiThinkingMode::Summary => "display: summaries",
bcode_config::TuiThinkingMode::Raw => "display: raw",
}
} else {
"display: hidden"
};
if !self.reasoning_support.is_supported() {
self.thinking_label = format!("{display} · unsupported by current model");
return;
}
let effort = self
.reasoning_effort()
.or(self.reasoning_default_effort.as_deref())
.unwrap_or("provider default");
let summary = self
.reasoning_summary()
.or(self.reasoning_default_summary.as_deref())
.unwrap_or("not requested");
self.thinking_label =
format!("{display} · request effort: {effort} · provider summary: {summary}");
}
pub fn set_cancelling(&mut self) {
self.set_activity(ActivityState::Cancelling);
}
pub fn set_idle(&mut self) {
self.set_activity(ActivityState::Idle);
}
pub fn stage_submission(&mut self) {
self.restore_uncommitted_navigation();
if self.viewport.allows_reveal() {
self.viewport.reveal(true);
}
let text = self.composer.buffer().text().to_owned();
self.submitted_user_message_following = SubmittedUserMessageFollowing::Idle;
self.assistant_scroll_anchor = AssistantScrollAnchorState::Idle;
self.pending_submissions.stage(text);
self.input_history.reset_navigation();
self.composer.buffer_mut().clear();
}
pub fn take_pending_submission(&mut self) -> String {
self.pending_submissions.take_staged()
}
pub fn clear_pending_submission(&mut self, text: &str) {
self.pending_submissions.clear_staged_if(text);
self.remove_pending_submission(text);
self.submitted_user_message_following = SubmittedUserMessageFollowing::Idle;
self.viewport.scroll_to_bottom(&mut self.older_history);
self.assistant_scroll_anchor = AssistantScrollAnchorState::Idle;
self.pending_assistant_stream_anchor = false;
}
pub fn mark_pending_submission_queued(&mut self, queue_position: Option<u32>) {
self.pending_submissions.mark_first_queued(queue_position);
}
pub fn mark_pending_submission_sent(&mut self) {
self.pending_submissions.mark_first_sent();
}
pub fn restore_pending_submission(&mut self, text: &str) {
self.pending_submissions.clear_staged_if(text);
self.remove_pending_submission(text);
self.submitted_user_message_following = SubmittedUserMessageFollowing::Idle;
self.viewport.scroll_to_bottom(&mut self.older_history);
self.assistant_scroll_anchor = AssistantScrollAnchorState::Idle;
self.pending_assistant_stream_anchor = false;
self.composer.buffer_mut().insert_str(text);
self.wake_cursor();
}
pub fn previous_input_history(&mut self) -> bool {
match self.input_history.previous(self.composer.buffer().text()) {
InputHistoryOutcome::Entry { index, total, text } => {
self.replace_composer_from_history(&text);
self.status = format!("input history {index}/{total}");
}
InputHistoryOutcome::DraftRestored(text) => {
self.replace_composer_from_history(&text);
"draft restored".clone_into(&mut self.status);
}
InputHistoryOutcome::Empty => {
"no input history in this session".clone_into(&mut self.status);
}
InputHistoryOutcome::NotBrowsing => {
"not browsing input history".clone_into(&mut self.status);
}
}
true
}
pub fn next_input_history(&mut self) -> bool {
match self.input_history.next() {
InputHistoryOutcome::Entry { index, total, text } => {
self.replace_composer_from_history(&text);
self.status = format!("input history {index}/{total}");
}
InputHistoryOutcome::DraftRestored(text) => {
self.replace_composer_from_history(&text);
"draft restored".clone_into(&mut self.status);
}
InputHistoryOutcome::Empty => {
"no input history in this session".clone_into(&mut self.status);
}
InputHistoryOutcome::NotBrowsing => {
"not browsing input history".clone_into(&mut self.status);
}
}
true
}
#[must_use]
pub const fn input_history_navigation_active(&self) -> bool {
self.input_history.is_browsing()
}
pub fn reset_input_history_navigation(&mut self) {
self.input_history.reset_navigation();
}
pub fn scroll_transcript_up(&mut self, rows: usize) -> bool {
self.navigation_checkpoint = None;
self.cancel_transcript_scroll_animation_for_manual_scroll();
self.mark_manual_transcript_scroll();
self.viewport.scroll_up(rows, &mut self.older_history)
}
pub fn scroll_transcript_down(&mut self, rows: usize) -> bool {
self.navigation_checkpoint = None;
self.cancel_transcript_scroll_animation_for_manual_scroll();
self.mark_manual_transcript_scroll();
let changed = self.viewport.scroll_down(rows, &mut self.older_history);
if self.viewport.at_bottom_threshold() {
self.viewport.scroll_to_bottom(&mut self.older_history);
self.latest_hidden_activity_at = None;
self.latest_hidden_activity_burst = 0;
self.latest_bar_next_frame_at = None;
} else {
self.viewport.detach();
}
changed
}
pub fn scroll_transcript_to_bottom(&mut self) -> bool {
self.restore_uncommitted_navigation();
self.presented_transcript_anchor = None;
self.pending_stable_transcript_anchor = None;
self.transcript_scroll_animation = None;
self.manual_transcript_scroll_until = None;
self.submitted_user_message_following = SubmittedUserMessageFollowing::Idle;
self.latest_hidden_activity_at = None;
self.latest_hidden_activity_burst = 0;
self.latest_bar_next_frame_at = None;
self.assistant_scroll_anchor = AssistantScrollAnchorState::Idle;
self.pending_assistant_stream_anchor = false;
self.pending_visual_overflow_bottom = None;
self.pending_transcript_top_anchor_sequence = None;
self.pending_submissions.cancel_reveals();
self.viewport.scroll_to_bottom(&mut self.older_history)
}
pub fn transition_transcript_to_bottom(&mut self) -> bool {
self.pending_transcript_top_anchor_sequence = None;
self.pending_submissions.cancel_reveals();
self.navigation_checkpoint = None;
let start_top_row = self
.viewport
.top_row(self.transcript_layout.total_rows(), self.viewport.height());
self.presented_transcript_anchor = None;
self.pending_stable_transcript_anchor = None;
self.manual_transcript_scroll_until = None;
self.submitted_user_message_following = SubmittedUserMessageFollowing::Idle;
self.viewport.scroll_to_bottom(&mut self.older_history);
self.assistant_scroll_anchor = AssistantScrollAnchorState::Idle;
self.pending_assistant_stream_anchor = false;
self.pending_visual_overflow_bottom = None;
let total_rows = self.transcript_layout.total_rows();
let viewport_height = self.viewport.height();
let target_top_row = total_rows.saturating_sub(usize::from(viewport_height));
if start_top_row == target_top_row {
return self.scroll_transcript_to_bottom();
}
let mut animation = TranscriptScrollAnimation::new(
start_top_row,
target_top_row,
self.transcript_scroll_animation_now(),
);
animation.target = TranscriptAnimationTarget::LatestBottom;
self.transcript_scroll_animation = Some(animation);
true
}
fn cancel_transcript_scroll_animation_for_manual_scroll(&mut self) {
self.submitted_user_message_following = SubmittedUserMessageFollowing::Idle;
self.pending_assistant_stream_anchor = false;
self.pending_visual_overflow_bottom = None;
self.interrupt_current_assistant_anchor();
let Some(animation) = self.transcript_scroll_animation.take() else {
return;
};
let top_row = animation.top_row_at(self.transcript_scroll_animation_now());
self.viewport.materialize_top_row(top_row);
}
fn mark_manual_transcript_scroll(&mut self) {
self.navigation_checkpoint = None;
self.pending_stable_transcript_anchor = None;
self.presented_transcript_anchor = None;
self.pending_transcript_top_anchor_sequence = None;
self.pending_submissions.cancel_reveals();
self.manual_transcript_scroll_until = Some(Instant::now() + MANUAL_TRANSCRIPT_SCROLL_GRACE);
}
#[cfg(test)]
pub const fn expire_manual_transcript_scroll_for_test(&mut self) {
self.manual_transcript_scroll_until = None;
}
fn manual_transcript_scroll_active(&self) -> bool {
self.manual_transcript_scroll_until
.is_some_and(|until| Instant::now() < until)
}
pub(crate) fn begin_transcript_geometry_update(&mut self) {
self.pending_visual_overflow_bottom = Some(
self.viewport
.bottom_row(self.transcript_layout.total_rows()),
);
}
pub fn sync_transcript_scroll_max(
&mut self,
max_scroll_offset: usize,
max_bottom_overscroll: usize,
total_rows: usize,
viewport_height: u16,
) {
let now = Instant::now();
let mut animated_top = None;
if let Some(mut animation) = self.transcript_scroll_animation {
let animation_now = self.transcript_scroll_animation_now();
if let TranscriptAnimationTarget::ItemTop(id) = animation.target
&& let Some(index) = self.transcript.presentation_index(id)
&& let Some(row) = self
.transcript_layout
.entry_start_row(VisibleTranscriptSource::Transcript, index)
{
animation.target_top_row = row;
} else if animation.target == TranscriptAnimationTarget::LatestBottom {
animation.target_top_row = total_rows.saturating_sub(usize::from(viewport_height));
}
let top_row = animation.top_row_at(animation_now);
if animation.finished(animation_now) {
self.transcript_scroll_animation = None;
let follows_tail = animation.target == TranscriptAnimationTarget::LatestBottom;
if follows_tail {
self.viewport.scroll_to_bottom(&mut self.older_history);
} else {
animated_top = Some(animation.target_top_row);
}
} else {
animated_top = Some(top_row);
self.transcript_scroll_animation = Some(animation);
}
}
let newer_page_rows = self.newer_history_page_rows();
let older_page_rows = self.older_history_page_rows();
let anchor = self.resolve_pending_transcript_anchor();
self.pending_stable_transcript_anchor = None;
self.viewport.sync_with_anchor(
(
max_scroll_offset,
max_bottom_overscroll,
total_rows,
viewport_height,
),
anchor,
older_page_rows,
newer_page_rows,
&mut self.older_history,
);
if let Some(top_row) = animated_top {
self.viewport.materialize_top_row(top_row);
}
self.resolve_visual_overflow_follow(total_rows, now);
}
fn older_history_page_rows(&self) -> usize {
let Some((first, last)) = self.older_history.ready_older_source_range() else {
return 0;
};
self.transcript
.iter()
.enumerate()
.filter(|(_, item)| {
item.event_sequence()
.is_some_and(|sequence| first <= sequence && sequence <= last)
})
.filter_map(|(index, _)| {
self.transcript_layout
.entry_row_count(VisibleTranscriptSource::Transcript, index)
})
.sum()
}
fn newer_history_page_rows(&self) -> usize {
let Some((first, last)) = self.older_history.ready_newer_source_range() else {
return 0;
};
self.transcript
.iter()
.enumerate()
.filter(|(_, item)| {
item.event_sequence()
.is_some_and(|sequence| first <= sequence && sequence <= last)
})
.filter_map(|(index, _)| {
self.transcript_layout
.entry_row_count(VisibleTranscriptSource::Transcript, index)
})
.sum()
}
fn resolve_visual_overflow_follow(&mut self, _total_rows: usize, now: Instant) {
let Some(previous_bottom) = self.pending_visual_overflow_bottom.take() else {
if !self.newer_transcript_content_below() {
self.latest_hidden_activity_at = None;
self.latest_hidden_activity_burst = 0;
self.latest_bar_next_frame_at = None;
}
return;
};
if self
.transcript_layout
.changed_content_below(previous_bottom)
{
self.record_latest_hidden_activity(now, 1);
}
let allowed = self.submitted_user_message_following
!= SubmittedUserMessageFollowing::PendingAnchor
&& !self.manual_transcript_scroll_active()
&& self.transcript_scroll_animation.is_none()
&& self.viewport.allows_overflow();
if self.viewport.reconcile_overflow(
self.viewport
.bottom_row(self.transcript_layout.total_rows()),
allowed,
&mut self.older_history,
) {
self.viewport.scroll_to_bottom(&mut self.older_history);
}
if !self.newer_transcript_content_below() {
self.latest_hidden_activity_at = None;
self.latest_hidden_activity_burst = 0;
self.latest_bar_next_frame_at = None;
}
}
fn record_latest_hidden_activity(&mut self, now: Instant, changed_rows: usize) {
let previous_activity_at = self.latest_hidden_activity_at;
if previous_activity_at
.is_none_or(|at| now.saturating_duration_since(at) >= LATEST_BAR_ACTIVE_WINDOW)
{
self.latest_hidden_activity_burst = 0;
}
let elapsed_ms = previous_activity_at
.map_or_else(
|| LATEST_BAR_ACTIVE_WINDOW.as_millis(),
|at| now.saturating_duration_since(at).as_millis(),
)
.max(1);
self.latest_hidden_activity_at = Some(now);
self.latest_bar_animation_started_at = now;
let frame_duration = latest_bar_active_frame_duration(self.latest_hidden_activity_burst);
let next_frame = next_latest_bar_frame_at(now, now, frame_duration);
self.latest_bar_next_frame_at = Some(next_frame);
let velocity_rows_per_second = u128::try_from(changed_rows)
.unwrap_or(u128::MAX)
.saturating_mul(1_000)
/ elapsed_ms;
let row_score = u8::try_from(changed_rows.min(8)).unwrap_or(8);
let velocity_score = u8::try_from(velocity_rows_per_second.min(8)).unwrap_or(8);
let activity = row_score.max(velocity_score).max(1);
self.latest_hidden_activity_burst = self
.latest_hidden_activity_burst
.saturating_add(activity)
.min(8);
}
pub fn sync_transcript_anchor_requests(&mut self) {
let resident = self
.transcript
.iter()
.map(super::transcript::TranscriptItem::id)
.collect::<BTreeSet<_>>();
self.admitted_items.retain(|id| resident.contains(id));
let newest = self
.transcript
.iter()
.enumerate()
.filter(|(_, item)| item.role() != "Assistant" || !item.text().is_empty())
.filter(|(_, item)| !self.admitted_items.contains(&item.id()))
.map(|(index, item)| (index, item.id(), item.role() == "Assistant"))
.next_back();
if self.automatic_item.is_none() || !self.viewport.allows_reveal() {
self.admitted_items.extend(
self.transcript
.iter()
.filter(|item| item.role() != "Assistant" || !item.text().is_empty())
.map(super::transcript::TranscriptItem::id),
);
}
if let Some((index, id, assistant)) = newest {
let changed = self.automatic_item.is_some();
if self.automatic_item.is_none() || !self.viewport.allows_reveal() {
self.automatic_item = Some(id);
}
if changed
&& self.viewport.allows_reveal()
&& !self.manual_transcript_scroll_active()
&& self.submitted_user_message_following
!= SubmittedUserMessageFollowing::PendingAnchor
{
self.pending_transcript_top_anchor_sequence = None;
self.submitted_user_message_following = SubmittedUserMessageFollowing::Idle;
self.pending_assistant_stream_anchor = false;
self.admitted_items.extend(
self.transcript
.iter()
.take(index + 1)
.filter(|item| item.role() != "Assistant" || !item.text().is_empty())
.map(super::transcript::TranscriptItem::id),
);
if assistant {
if let Some(top) = self
.transcript_layout
.entry_start_row(VisibleTranscriptSource::Transcript, index)
{
self.reveal_assistant_item(index, top);
}
} else {
self.automatic_item = Some(id);
self.release_item_hold_for_overflow();
}
return;
}
}
self.sync_submission_anchor_requests();
}
fn sync_submission_anchor_requests(&mut self) {
if self.manual_transcript_scroll_active() || self.transcript_scroll_animation.is_some() {
return;
}
if let Some(sequence) = self.pending_transcript_top_anchor_sequence {
if let Some(index) = self.transcript_index_for_sequence(sequence)
&& let Some(top_row) = self
.transcript_layout
.entry_start_row(VisibleTranscriptSource::Transcript, index)
{
self.pending_transcript_top_anchor_sequence = None;
self.transcript_scroll_animation = None;
if self.submitted_user_message_following
== SubmittedUserMessageFollowing::PendingAnchor
{
self.submitted_user_message_following = SubmittedUserMessageFollowing::Anchored;
self.viewport.reveal(false);
self.start_transcript_scroll_animation(top_row);
self.set_animation_item_target(index);
self.admitted_items.extend(
self.transcript
.iter()
.take(index + 1)
.map(super::transcript::TranscriptItem::id),
);
self.automatic_item = self
.transcript
.get(index)
.map(super::transcript::TranscriptItem::id);
} else {
self.viewport.detach();
self.viewport.follow_anchor(top_row);
}
self.presented_transcript_anchor = None;
self.pending_stable_transcript_anchor = None;
}
return;
}
if self.submitted_user_message_following == SubmittedUserMessageFollowing::PendingAnchor
&& let Some(top_row) = self.latest_user_message_start_row()
{
self.submitted_user_message_following = SubmittedUserMessageFollowing::Anchored;
self.viewport.reveal(false);
self.start_transcript_scroll_animation(top_row);
}
}
fn set_animation_item_target(&mut self, index: usize) {
if let Some(animation) = &mut self.transcript_scroll_animation {
animation.target = self.transcript.presentation_id(index).map_or(
TranscriptAnimationTarget::Row,
TranscriptAnimationTarget::ItemTop,
);
}
}
fn release_item_hold_for_overflow(&mut self) {
self.viewport.release_sticky_reveal();
let total = self.transcript_layout.total_rows();
if self.transcript_scroll_animation.is_none() && total > self.viewport.bottom_row(total) {
let checkpoint = self.navigation_checkpoint.take();
self.transition_transcript_to_bottom();
self.navigation_checkpoint = checkpoint;
}
}
fn reveal_assistant_item(&mut self, index: usize, top_row: usize) {
self.pending_transcript_top_anchor_sequence = None;
self.submitted_user_message_following = SubmittedUserMessageFollowing::Idle;
self.pending_assistant_stream_anchor = false;
self.automatic_item = self
.transcript
.get(index)
.map(super::transcript::TranscriptItem::id);
self.transcript_scroll_animation = None;
self.start_transcript_scroll_animation(top_row);
if let Some(animation) = &mut self.transcript_scroll_animation {
animation.target = self.transcript.presentation_id(index).map_or(
TranscriptAnimationTarget::Row,
TranscriptAnimationTarget::ItemTop,
);
}
self.viewport.reveal(true);
self.assistant_scroll_anchor = AssistantScrollAnchorState::Anchored { index };
}
const fn downgrade_sticky_entry_anchor(&mut self) {
self.viewport.release_sticky_reveal();
}
fn interrupt_current_assistant_anchor(&mut self) {
if let Some(index) = self.assistant_scroll_anchor.index()
&& self
.transcript
.get(index)
.is_some_and(|item| item.role() == "Assistant" && item.streaming())
{
self.assistant_scroll_anchor = AssistantScrollAnchorState::Interrupted { index };
} else {
self.assistant_scroll_anchor = AssistantScrollAnchorState::Idle;
}
}
fn finish_assistant_stream_anchor(&mut self) {
if let Some(index) = self.assistant_scroll_anchor.index()
&& self
.transcript
.get(index)
.is_some_and(|item| item.role() == "Assistant" && !item.streaming())
{
self.assistant_scroll_anchor = AssistantScrollAnchorState::Idle;
}
}
#[cfg(test)]
pub const fn has_assistant_stream_anchor(&self) -> bool {
self.assistant_scroll_anchor.index().is_some()
}
#[cfg(test)]
pub const fn assistant_stream_anchor_index(&self) -> Option<usize> {
self.assistant_scroll_anchor.index()
}
#[cfg(test)]
pub const fn manually_detached(&self) -> bool {
!self.viewport.allows_reveal()
}
fn active_tool_loop(&self) -> bool {
self.session_view.snapshot().tools.values().any(|tool| {
matches!(
tool.status,
bcode_session_view_models::ToolInvocationViewStatus::Requested
| bcode_session_view_models::ToolInvocationViewStatus::Running
| bcode_session_view_models::ToolInvocationViewStatus::Waiting
)
})
}
fn start_transcript_scroll_animation(&mut self, top_row: usize) {
self.presented_transcript_anchor = None;
self.pending_stable_transcript_anchor = None;
if let Some((start_top_row, target_top_row)) =
self.viewport.start_follow_anchor_animation(top_row)
{
let mut animation = TranscriptScrollAnimation::new(
start_top_row,
target_top_row,
self.transcript_scroll_animation_now(),
);
animation.target = self
.transcript_layout
.line_at_row(target_top_row)
.filter(|line| line.source == VisibleTranscriptSource::Transcript)
.and_then(|line| self.transcript.presentation_id(line.entry_index))
.map_or(
TranscriptAnimationTarget::Row,
TranscriptAnimationTarget::ItemTop,
);
self.transcript_scroll_animation = Some(animation);
}
}
fn latest_user_message_start_row(&self) -> Option<usize> {
if !self.pending_submissions().is_empty() {
return self.transcript_layout.entry_start_row(
VisibleTranscriptSource::Pending,
self.pending_submissions().len().saturating_sub(1),
);
}
let index = self
.transcript
.iter()
.rposition(|item| item.role() == "You")?;
self.transcript_layout
.entry_start_row(VisibleTranscriptSource::Transcript, index)
}
pub fn absorb_history(&mut self, events: &[SessionEvent]) {
self.latest_history_sequence = events.last().map(|event| event.sequence);
self.transcript_window.append_history(events);
for event in events {
self.session_view.apply_event(event);
self.apply_session_event(event, SessionEventApplication::Replay);
}
self.apply_session_view_terminal_adapter();
self.trim_resident_transcript_window_if_needed();
}
fn rebuild_transcript_from_history(&mut self, preserve_live: bool) {
let events = self.transcript_window.events().to_vec();
if preserve_live {
self.session_view.rebuild_history_window(&events);
} else {
self.session_view.clear_history_window();
self.session_view.apply_history(&events);
}
self.session_view_terminal_adapter.reset();
for event in &events {
self.apply_session_event(event, SessionEventApplication::Replay);
}
self.apply_session_view_terminal_adapter();
}
fn trim_offscreen_older_transcript_if_needed(&mut self) {
if self.transcript_window.events().len() <= RESIDENT_TRANSCRIPT_MAX_EVENTS
|| self.transcript_selection_pinned
|| self.transcript_scroll_animation.is_some()
|| self.older_history.loading()
|| self.viewport.follows_bottom()
{
return;
}
let top = self
.viewport
.top_row(self.transcript_layout.total_rows(), self.viewport.height());
let Some(first_visible_sequence) = self
.transcript_layout
.visible_lines_from_top(top, self.viewport.height().max(1))
.iter()
.filter(|line| line.source == VisibleTranscriptSource::Transcript)
.filter_map(|line| self.transcript.get(line.entry_index))
.filter_map(super::transcript::TranscriptItem::event_sequence)
.min()
else {
return;
};
if let Some(new_oldest_sequence) = self.transcript_window.trim_older_offscreen(
RESIDENT_TRANSCRIPT_MAX_EVENTS,
RESIDENT_TRANSCRIPT_TARGET_EVENTS,
first_visible_sequence,
usize::from(self.viewport.height()).saturating_mul(4),
) {
self.capture_stable_transcript_anchor();
self.older_history
.mark_dropped_history_before(new_oldest_sequence);
self.rebuild_transcript_from_history(true);
self.pending_visual_overflow_bottom = None;
}
}
fn trim_offscreen_newer_transcript_if_needed(&mut self) {
if self.transcript_window.events().len() <= RESIDENT_TRANSCRIPT_MAX_EVENTS
|| self.transcript_selection_pinned
|| self.transcript_scroll_animation.is_some()
|| self.older_history.loading_newer()
|| self.viewport.follows_bottom()
{
return;
}
let top = self
.viewport
.top_row(self.transcript_layout.total_rows(), self.viewport.height());
let visible = self
.transcript_layout
.visible_lines_from_top(top, self.viewport.height().max(1));
let Some(last_visible_sequence) = visible
.iter()
.filter(|line| line.source == VisibleTranscriptSource::Transcript)
.filter_map(|line| self.transcript.get(line.entry_index))
.filter_map(super::transcript::TranscriptItem::event_sequence)
.max()
else {
return;
};
let first_dropped = self.transcript_window.trim_newer_offscreen(
RESIDENT_TRANSCRIPT_MAX_EVENTS,
RESIDENT_TRANSCRIPT_TARGET_EVENTS,
last_visible_sequence,
usize::from(self.viewport.height()).saturating_mul(4),
);
if let Some(first_dropped) = first_dropped {
self.capture_stable_transcript_anchor();
self.older_history.mark_dropped_history_after(first_dropped);
self.rebuild_transcript_from_history(true);
self.pending_visual_overflow_bottom = None;
}
}
fn trim_resident_transcript_window_if_needed(&mut self) {
let trim = self
.transcript_window
.trim_if_allowed(self.resident_transcript_window_policy());
if !trim.trimmed() {
return;
}
if let Some(new_oldest_sequence) = trim.new_oldest_sequence {
self.older_history
.mark_dropped_history_before(new_oldest_sequence);
}
self.rebuild_transcript_from_history(true);
self.viewport.scroll_to_bottom(&mut self.older_history);
self.pending_visual_overflow_bottom = None;
}
fn resident_transcript_window_policy(&self) -> TranscriptWindowPolicy {
TranscriptWindowPolicy {
max_events: RESIDENT_TRANSCRIPT_MAX_EVENTS,
target_events: RESIDENT_TRANSCRIPT_TARGET_EVENTS,
allow_trim: self.can_trim_resident_transcript_window()
&& !self.older_history.has_newer_history(),
}
}
#[cfg(test)]
#[must_use]
pub fn can_trim_resident_transcript_window_for_test(&self) -> bool {
self.can_trim_resident_transcript_window()
}
fn can_trim_resident_transcript_window(&self) -> bool {
self.viewport.follows_bottom()
&& self.viewport.at_bottom_threshold()
&& self.transcript_scroll_animation.is_none()
&& !self.manual_transcript_scroll_active()
&& !self.transcript_selection_pinned
&& !self.active_tool_loop()
&& !self.pending_assistant_stream_anchor
&& !self.older_history.loading()
}
pub fn prepend_older_history(&mut self, events: &[SessionEvent], has_more: bool) {
self.restore_uncommitted_navigation();
if events.is_empty() {
self.older_history.update_cursor(&[], false);
self.older_history.set_loading(false);
"start of session history".clone_into(&mut self.status);
return;
}
let input_messages = events.iter().filter_map(|event| match &event.kind {
SessionEventKind::UserMessage { text, .. } => Some(SessionInputHistoryEntry {
sequence: event.sequence,
timestamp_ms: event.timestamp_ms,
text: text.clone(),
}),
_ => None,
});
self.input_history.prepend_committed(input_messages);
self.transcript_window.prepend_older_history(events);
if self.latest_history_sequence.is_none() {
self.latest_history_sequence = events.last().map(|event| event.sequence);
}
self.rebuild_transcript_from_history(true);
self.older_history.update_cursor(events, has_more);
self.older_history.set_loading(false);
self.trim_offscreen_newer_transcript_if_needed();
if self.older_history.has_older_history() {
"loaded older history".clone_into(&mut self.status);
} else {
"start of session history".clone_into(&mut self.status);
}
}
pub fn append_newer_history(&mut self, events: &[SessionEvent], has_more: bool) {
self.restore_uncommitted_navigation();
if events.is_empty() {
self.older_history.update_newer_cursor(&[], false);
self.older_history.set_loading_newer(false);
"latest session history".clone_into(&mut self.status);
return;
}
self.latest_history_sequence = events.last().map(|event| event.sequence);
self.transcript_window.append_history(events);
self.rebuild_transcript_from_history(true);
self.older_history.update_newer_cursor(events, has_more);
self.older_history.set_loading_newer(false);
self.trim_offscreen_older_transcript_if_needed();
if self.older_history.has_newer_history() {
"loaded newer history".clone_into(&mut self.status);
} else {
"latest session history".clone_into(&mut self.status);
}
self.trim_resident_transcript_window_if_needed();
}
#[allow(clippy::too_many_lines)]
pub fn absorb_session_event(&mut self, event: &SessionEvent) {
self.restore_uncommitted_navigation();
if event_affects_transcript_rows(event)
&& self
.latest_history_sequence
.is_some_and(|sequence| event.sequence <= sequence)
{
return;
}
if event_affects_transcript_rows(event) && self.older_history.has_newer_history() {
self.older_history
.mark_newer_available_after(self.transcript_window.newest_sequence());
self.latest_history_sequence = Some(event.sequence);
self.set_status("new activity below".to_owned());
return;
}
if event_affects_transcript_rows(event) {
self.transcript_window.append_live_event(event);
}
self.session_view.apply_event(event);
self.apply_session_event(event, SessionEventApplication::Live);
self.apply_session_view_terminal_adapter();
self.trim_resident_transcript_window_if_needed();
}
pub fn absorb_session_live_event(&mut self, event: &SessionLiveEvent) {
self.restore_uncommitted_navigation();
self.session_view.apply_live_event(event);
self.apply_session_view_terminal_adapter();
self.apply_session_live_event_side_effects(event);
}
fn apply_session_live_event_side_effects(&mut self, event: &SessionLiveEvent) {
match &event.kind {
SessionLiveEventKind::AssistantTextDelta { text, .. } => {
self.add_streaming_delta(text, SessionEventApplication::Live);
}
SessionLiveEventKind::UsageSummaryChanged { .. } => {
if let Some(usage) = &self.session_view.snapshot().runtime.latest_usage {
self.token_usage.absorb(usage);
}
}
SessionLiveEventKind::AssistantTextStreamUpdated { .. }
| SessionLiveEventKind::AssistantReasoningDelta { .. }
| SessionLiveEventKind::AssistantReasoningTextStreamUpdated { .. }
| SessionLiveEventKind::AssistantReasoningActivity { .. }
| SessionLiveEventKind::ToolContributionPlaced { .. }
| SessionLiveEventKind::ToolPresentationUpdated { .. }
| SessionLiveEventKind::RequestContextOccupancyChanged { .. }
| SessionLiveEventKind::ToolRequestDraft { .. } => {
}
SessionLiveEventKind::ToolInvocationProgress { .. } => {
self.apply_shared_runtime_work_activity();
}
SessionLiveEventKind::ProviderStreamProgress { event, .. } => {
self.apply_shared_provider_stream_progress(event);
}
}
}
#[allow(clippy::too_many_lines)]
fn apply_session_event(&mut self, event: &SessionEvent, application: SessionEventApplication) {
if bcode_session_view::submission_queue_needs_refresh(&event.kind) {
self.queue_recovery.generation = self.queue_recovery.generation.wrapping_add(1);
}
if event_breaks_sticky_entry_anchor(event) {
self.downgrade_sticky_entry_anchor();
}
match &event.kind {
SessionEventKind::SubmissionAccepted { submission } => {
self.pending_submissions.remove_id(&submission.id);
self.set_status(match submission.requested_placement {
bcode_session_models::SubmissionPlacement::Steer => {
"Steering pending".to_owned()
}
bcode_session_models::SubmissionPlacement::FollowUp => {
"Follow-up pending".to_owned()
}
});
}
SessionEventKind::SubmissionCancelled { .. } if application.live_activity() => {
self.set_status("Submission cancelled before activation".to_owned());
}
SessionEventKind::SubmissionActivated {
submission_id,
text,
..
} => {
self.pending_submissions.remove_id(submission_id);
self.input_history
.push_committed(event.sequence, event.timestamp_ms, text);
if application.live_activity() {
self.set_status("Submission activated".to_owned());
}
}
SessionEventKind::UserMessage { text, .. } => {
self.assistant_scroll_anchor = AssistantScrollAnchorState::Idle;
self.pending_assistant_stream_anchor = false;
self.apply_committed_user_message(
event.sequence,
text,
event.timestamp_ms,
application,
);
}
SessionEventKind::AssistantDelta { text } => {
self.add_streaming_delta(text, application);
}
SessionEventKind::AssistantMessage { .. }
| SessionEventKind::AssistantResponseSegment { .. } => {
if application.live_activity()
&& matches!(self.activity, ActivityState::Streaming { .. })
{
self.set_activity(ActivityState::FinalizingModelTurn);
}
self.finish_assistant_stream_anchor();
}
SessionEventKind::ToolCallRequested {
tool_call_id,
tool_name,
arguments_json,
..
} => {
self.apply_tool_request_side_effects(tool_call_id, tool_name, arguments_json);
}
SessionEventKind::ToolInvocationResultRecorded { record } => {
if application.live_activity() {
self.set_activity(ActivityState::PreparingFollowUpRequest);
}
self.push_tool_result(
&record.invocation_id,
&record.model_output,
record.is_error,
record.result.as_ref(),
application,
);
}
SessionEventKind::PermissionRequested {
permission_id,
tool_call_id,
tool_name,
arguments_json,
policy_source,
policy_reason,
..
} => {
self.push_permission_request(PermissionRequestInput {
event_sequence: event.sequence,
permission_id,
tool_call_id,
tool_name,
arguments_json,
policy_source: policy_source.as_deref(),
policy_reason: policy_reason.as_deref(),
application,
});
}
SessionEventKind::PermissionResolved {
permission_id,
approved: _,
} => {
if application.live_activity() {
self.set_activity(ActivityState::PreparingFollowUpRequest);
}
self.set_permission_status(permission_id);
}
SessionEventKind::ModelChanged {
provider, model, ..
} => {
self.apply_shared_model_changed(provider, model);
}
SessionEventKind::ReasoningChanged { effort, .. } => {
if let Some(effort) = effort {
self.reconcile_pending_reasoning_effort(effort);
}
self.refresh_thinking_label();
}
SessionEventKind::ModelTurnStarted { .. } if application.live_activity() => {
self.apply_shared_model_turn_started();
}
SessionEventKind::ModelTurnCancelRequested { .. } if application.live_activity() => {
self.apply_shared_model_turn_cancel_requested();
}
SessionEventKind::ModelTurnFinished { .. } => {
self.finish_shared_model_turn(application);
}
SessionEventKind::ModelUsage { turn_id, usage } => {
self.push_model_usage(event.sequence, turn_id, usage, application);
}
SessionEventKind::WorkingDirectoryChanged {
old_working_directory,
new_working_directory: _,
} => self.apply_working_directory_changed(event.sequence, old_working_directory),
SessionEventKind::SessionRenamed { .. }
| SessionEventKind::AutomaticTitleUpdated { .. } => self.apply_shared_session_renamed(),
SessionEventKind::SkillSuggested { skill_id, .. } => {
self.status = format!("suggested skill: {skill_id}");
}
SessionEventKind::SkillActivated { skill_id, .. } => {
self.apply_shared_skill_activated(skill_id);
}
SessionEventKind::SkillDeactivated { skill_id, .. } => {
self.apply_shared_skill_deactivated(skill_id);
}
SessionEventKind::SkillContextLoaded {
skill_id,
bytes_loaded,
truncated,
..
} => {
let suffix = if *truncated { " truncated" } else { "" };
self.status =
format!("loaded skill context: {skill_id} ({bytes_loaded} bytes{suffix})");
}
SessionEventKind::AssistantReasoningMessage { .. }
if application.live_activity()
&& matches!(self.activity, ActivityState::Streaming { .. }) =>
{
self.set_activity(ActivityState::FinalizingModelTurn);
}
SessionEventKind::AgentChanged { .. } => {
self.apply_shared_agent_changed();
}
SessionEventKind::TraceEvent { trace } if application.live_activity() => {
self.apply_trace_event(trace);
}
SessionEventKind::ToolInvocationLifecycle { event: lifecycle } => {
if lifecycle.stage == bcode_session_models::ToolInvocationLifecycleStage::Started
&& application.live_activity()
&& let Some(tool_name) = self
.session_view
.snapshot()
.tools
.get(&lifecycle.invocation_id)
.and_then(|tool| tool.tool_name.clone())
{
self.set_file_activity(&tool_name);
tool_name.clone_into(&mut self.status);
}
if application.live_activity() {
self.apply_shared_runtime_work_activity();
}
}
SessionEventKind::RuntimeWorkStarted { .. }
| SessionEventKind::RuntimeWorkCancelRequested { .. }
| SessionEventKind::RuntimeWorkProgress { .. }
if application.live_activity() =>
{
self.apply_shared_runtime_work_activity();
}
SessionEventKind::RuntimeWorkFinished {
work_id,
status,
message,
..
} => {
if application.live_activity() {
self.apply_shared_runtime_work_activity();
}
let _ = (work_id, status, message);
}
_ => {}
}
}
pub fn apply_runtime_work_snapshots(
&mut self,
snapshots: &[bcode_session_models::RuntimeWorkSnapshot],
) {
self.session_view.set_runtime_work_snapshots(snapshots);
self.apply_shared_runtime_work_activity();
}
#[must_use]
pub const fn cursor_visible(&self) -> bool {
self.cursor.visible()
}
pub fn wake_cursor(&mut self) {
self.cursor.wake();
}
#[must_use]
pub fn invalidation_requests(
&self,
now: Instant,
now_system: SystemTime,
) -> Vec<InvalidationRequest> {
let mut requests = vec![self.cursor.invalidation_request()];
if let Some(animation) = self.transcript_scroll_animation {
requests.push(InvalidationRequest::new(
InvalidationKey::new(TRANSCRIPT_SCROLL_ANIMATION_INVALIDATION_KEY),
animation.next_frame_at,
));
}
if self.newer_transcript_content_below()
&& let Some(next_frame_at) = self.latest_bar_next_frame_at
{
requests.push(InvalidationRequest::new(
InvalidationKey::new(LATEST_BAR_ANIMATION_INVALIDATION_KEY),
next_frame_at,
));
}
if let Some(next_frame_at) = self.theme_transition.next_frame_at {
requests.push(InvalidationRequest::new(
InvalidationKey::new(THEME_TRANSITION_INVALIDATION_KEY),
next_frame_at,
));
}
if self.activity_animation_active() {
requests.push(InvalidationRequest::new(
InvalidationKey::new(ACTIVITY_ANIMATION_INVALIDATION_KEY),
next_periodic_deadline(self.activity_started_at, now, ACTIVITY_ANIMATION_FRAME),
));
}
requests.extend(self.tool_elapsed_invalidation_requests(now, now_system));
requests
}
pub fn handle_invalidations(
&mut self,
keys: &[InvalidationKey],
now: Instant,
) -> UiInvalidation {
self.handle_invalidations_with_damage(keys, now).0
}
pub fn handle_invalidations_with_damage(
&mut self,
keys: &[InvalidationKey],
now: Instant,
) -> (UiInvalidation, Vec<TemporalDamage>) {
let mut damage = BTreeSet::new();
let invalidation = keys.iter().fold(UiInvalidation::None, |invalidation, key| {
if self.cursor.handle_invalidation(key, now) {
damage.insert(TemporalDamage::Composer);
invalidation.merge(UiInvalidation::Paint)
} else if is_transcript_scroll_animation_invalidation(key) {
if self.handle_transcript_scroll_animation(now) {
damage.insert(TemporalDamage::Transcript);
invalidation.merge(UiInvalidation::Paint)
} else {
invalidation
}
} else if is_latest_bar_animation_invalidation(key) {
let active = self.latest_bar_active(now);
self.latest_bar_next_frame_at = active.then(|| {
next_latest_bar_frame_at(
self.latest_bar_animation_started_at,
now,
latest_bar_active_frame_duration(self.latest_hidden_activity_burst),
)
});
damage.insert(TemporalDamage::LatestBar);
invalidation.merge(UiInvalidation::Paint)
} else if is_theme_transition_invalidation(key) {
self.update_theme_animation(now);
if !self.theme_transition_active(now) {
self.theme_transition.finish();
self.presented_theme = self.target_theme.presented(self.target_theme.accent);
}
damage.insert(TemporalDamage::Full);
invalidation.merge(UiInvalidation::Paint)
} else if is_activity_animation_invalidation(key) {
self.activity_frame = self.activity_frame.wrapping_add(1);
self.activity_elapsed = now.saturating_duration_since(self.activity_started_at);
if self.activity_animation_active() {
damage.insert(TemporalDamage::Status);
invalidation.merge(UiInvalidation::Paint)
} else {
invalidation
}
} else if let Some(invocation_id) = tool_elapsed_invalidation_invocation_id(key) {
self.elapsed_layout_revision = self.elapsed_layout_revision.saturating_add(1);
self.elapsed_dirty_visuals.insert(invocation_id.to_owned());
damage.insert(TemporalDamage::Transcript);
invalidation.merge(UiInvalidation::Paint)
} else {
invalidation
}
});
(invalidation, damage.into_iter().collect())
}
const fn activity_animation_active(&self) -> bool {
!matches!(self.activity, ActivityState::Idle)
|| matches!(
self.daemon_connection,
DaemonConnectionState::Connecting | DaemonConnectionState::Starting
)
}
fn latest_bar_active(&self, now: Instant) -> bool {
self.latest_hidden_activity_at
.is_some_and(|at| now.saturating_duration_since(at) < LATEST_BAR_ACTIVE_WINDOW)
}
fn handle_transcript_scroll_animation(&mut self, now: Instant) -> bool {
let Some(mut animation) = self.transcript_scroll_animation else {
return false;
};
animation.advance_frame(now);
self.transcript_scroll_animation = Some(animation);
true
}
#[must_use]
pub const fn should_exit(&self) -> bool {
self.exit.requested()
}
pub const fn request_exit(&mut self) {
self.exit.request();
}
pub fn replace_composer_with(&mut self, text: &str) {
self.replace_composer_with_policy(text, true);
}
fn replace_composer_from_history(&mut self, text: &str) {
self.replace_composer_with_policy(text, false);
}
fn replace_composer_with_policy(&mut self, text: &str, reset_history: bool) {
if reset_history {
self.input_history.reset_navigation();
}
self.composer.buffer_mut().clear();
self.composer.buffer_mut().insert_str(text);
self.wake_cursor();
}
pub fn apply_local_model_selection(&mut self, provider: Option<String>, model: &str) {
self.session_view.set_model_selection(
provider,
model_to_display_selection(model),
None,
None,
);
self.reasoning_support = ReasoningSupport::Unsupported;
self.reasoning_effort_values.clear();
self.pending_reasoning_effort = None;
self.token_usage.clear_model_info();
let provider_label = self.selected_provider_plugin_id().unwrap_or("auto");
self.status = format!("model selected for next session: {provider_label}/{model}");
}
fn apply_shared_model_changed(&mut self, provider: &str, model: &str) {
debug_assert_eq!(
self.selected_provider_plugin_id(),
provider_to_display_selection(provider).as_deref()
);
debug_assert_eq!(
self.selected_model_id(),
model_to_display_selection(model).as_deref()
);
self.reasoning_support = ReasoningSupport::Unsupported;
self.reasoning_effort_values.clear();
self.pending_reasoning_effort = None;
self.token_usage.clear_model_info();
self.status = format!("model: {provider}/{model}");
}
fn apply_shared_agent_changed(&mut self) {
debug_assert!(self.session_view.snapshot().runtime.agent_id.is_some());
self.current_agent_accent = None;
self.clear_pending_agent_fields();
self.sync_theme_target(Instant::now());
}
fn visible_transcript_entry_indexes(&self) -> BTreeSet<usize> {
let height = self.viewport.height();
if height == 0 {
return (0..self.transcript().len()).collect();
}
self.transcript_layout
.visible_transcript_entry_indexes(self.transcript_top_row(height), height)
}
fn tool_elapsed_invalidation_requests(
&self,
now: Instant,
now_system: SystemTime,
) -> impl Iterator<Item = InvalidationRequest> {
let visible = self.visible_transcript_entry_indexes();
self.transcript
.iter()
.enumerate()
.filter_map(move |(index, item)| {
if !visible.contains(&index) || !item.tool_is_active() {
return None;
}
let invocation_id = item.visual_invocation_id()?;
let timing = item.tool_timing()?;
let at = super::temporal::next_elapsed_invalidation_capped(
timing.started_at_ms?,
None,
now,
now_system,
TOOL_ELAPSED_INVALIDATION_MAX_INTERVAL,
)?;
Some(InvalidationRequest::new(
InvalidationKey::new(format!(
"{TOOL_ELAPSED_INVALIDATION_PREFIX}:{invocation_id}"
)),
at,
))
})
.collect::<Vec<_>>()
.into_iter()
}
fn extend_composer_selection_to_visual_delta(&mut self, width: usize, delta: isize) {
let layout = self.composer.buffer().wrapped_layout(width);
let target_row = if delta.is_negative() {
layout.cursor.row.saturating_sub(delta.unsigned_abs())
} else {
layout
.cursor
.row
.saturating_add(delta.unsigned_abs())
.min(layout.lines.len().saturating_sub(1))
};
self.composer
.buffer_mut()
.select_to_wrapped_position(width, target_row, layout.cursor.col);
}
fn apply_shared_session_renamed(&mut self) {
self.status = self.session_title().map_or_else(
|| "session renamed".to_owned(),
|name| format!("session: {name}"),
);
}
fn apply_shared_skill_activated(&mut self, skill_id: &impl std::fmt::Display) {
let skill_id = skill_id.to_string();
if self
.session_view
.snapshot()
.active_skills
.contains(&skill_id)
{
self.status = format!("activated skill: {skill_id}");
}
}
fn apply_shared_skill_deactivated(&mut self, skill_id: &impl std::fmt::Display) {
let skill_id = skill_id.to_string();
if !self
.session_view
.snapshot()
.active_skills
.contains(&skill_id)
{
self.status = format!("deactivated skill: {skill_id}");
}
}
fn remove_pending_submission(&mut self, text: &str) {
self.pending_submissions.remove(text);
}
fn apply_committed_user_message(
&mut self,
sequence: u64,
text: &str,
timestamp_ms: u64,
application: SessionEventApplication,
) {
let text = self
.shared_terminal_text_for_sequence(sequence, "You")
.unwrap_or_else(|| text.to_owned());
self.input_history
.push_committed(sequence, timestamp_ms, &text);
let accepted_pending_submission = self.pending_submissions.reveals_on_acceptance(&text);
self.remove_pending_submission(&text);
if accepted_pending_submission {
self.submitted_user_message_following = SubmittedUserMessageFollowing::PendingAnchor;
self.viewport.reveal(false);
self.pending_transcript_top_anchor_sequence = Some(sequence);
}
if application.live_activity() {
self.set_activity(ActivityState::PreparingModelRequest);
}
}
pub fn apply_submission_records(&mut self, records: &[bcode_session_models::SubmissionRecord]) {
for record in records {
self.pending_submissions.remove_id(&record.submission.id);
self.session_view.apply_submission_record(record);
}
let _ = self.apply_session_view_terminal_adapter();
}
fn restore_uncommitted_navigation(&mut self) {
if let Some(checkpoint) = self.navigation_checkpoint.take() {
self.viewport = checkpoint.viewport;
self.transcript_scroll_animation = checkpoint.animation;
self.pending_stable_transcript_anchor = checkpoint.anchor;
self.submitted_user_message_following = checkpoint.submitted;
self.assistant_scroll_anchor = checkpoint.assistant;
self.pending_assistant_stream_anchor = checkpoint.pending_assistant;
self.pending_transcript_top_anchor_sequence = checkpoint.pending_sequence;
self.automatic_item = checkpoint.automatic_item;
self.admitted_items = checkpoint.admitted_items;
self.older_history = checkpoint.history;
self.latest_hidden_activity_at = checkpoint.hidden_activity_at;
self.latest_hidden_activity_burst = checkpoint.hidden_activity_burst;
self.latest_bar_animation_started_at = checkpoint.bar_started_at;
self.latest_bar_next_frame_at = checkpoint.bar_next_frame_at;
}
}
pub(crate) fn begin_transcript_presentation(&mut self) {
self.restore_uncommitted_navigation();
self.capture_stable_transcript_anchor();
self.navigation_checkpoint = Some(TranscriptNavigationCheckpoint {
automatic_item: self.automatic_item,
admitted_items: self.admitted_items.clone(),
viewport: self.viewport,
animation: self.transcript_scroll_animation,
anchor: self.pending_stable_transcript_anchor.clone(),
submitted: self.submitted_user_message_following,
assistant: self.assistant_scroll_anchor,
pending_assistant: self.pending_assistant_stream_anchor,
pending_sequence: self.pending_transcript_top_anchor_sequence,
history: self.older_history.clone(),
hidden_activity_at: self.latest_hidden_activity_at,
hidden_activity_burst: self.latest_hidden_activity_burst,
bar_started_at: self.latest_bar_animation_started_at,
bar_next_frame_at: self.latest_bar_next_frame_at,
});
}
pub fn commit_transcript_presentation(&mut self) {
self.transcript_layout.clear_content_changes();
self.navigation_checkpoint = None;
let previous_anchor = self.presented_transcript_anchor.take();
self.pending_stable_transcript_anchor = None;
self.capture_stable_transcript_anchor();
let mut accepted = self.pending_stable_transcript_anchor.take();
if let (Some(previous), Some(current)) = (&previous_anchor, &mut accepted)
&& previous.item_id == current.item_id
&& previous.viewport_row_offset == current.viewport_row_offset
&& let Some(position) = previous.source_position
&& let Some(index) = self.transcript.presentation_index(current.item_id)
&& self.transcript_layout.source_row(index, position) == Some(current.row_in_item)
{
current.source_position = Some(position);
}
self.presented_transcript_anchor = accepted;
}
pub(crate) fn capture_stable_transcript_anchor(&mut self) {
if self.viewport.follows_bottom() {
if let Some(presentation) = &self.plugin_presentation {
presentation.clear_content_positions();
}
return;
}
if self.pending_stable_transcript_anchor.is_some()
|| self.transcript_scroll_animation.is_some()
{
return;
}
if let Some(anchor) = &self.presented_transcript_anchor
&& self.transcript.presentation_index(anchor.item_id).is_some()
{
self.pending_stable_transcript_anchor = Some(anchor.clone());
if let Some((identity, offset)) = &anchor.content
&& let Some(presentation) = &self.plugin_presentation
{
presentation.retain_content_position(identity, *offset);
}
return;
}
let top_row = self
.viewport
.top_row(self.transcript_layout.total_rows(), self.viewport.height());
let Some(line) = self
.transcript_layout
.visible_lines_from_top(top_row, self.viewport.height().max(1))
.into_iter()
.find(|line| line.source == VisibleTranscriptSource::Transcript)
else {
return;
};
let Some(item_id) = self.transcript.presentation_id(line.entry_index) else {
return;
};
self.pending_stable_transcript_anchor = Some(StableTranscriptAnchor {
item_id,
fallback_index: line.entry_index,
viewport_row_offset: line.row_index.saturating_sub(top_row),
source_position: self
.transcript_layout
.source_position(line.entry_index, line.row_in_entry),
row_in_item: line.row_in_entry,
content: self
.transcript_layout
.content_anchor(line.entry_index, line.row_in_entry)
.map(|(key, offset)| (key.to_owned(), offset)),
});
}
fn apply_session_view_terminal_adapter(&mut self) -> TranscriptDocumentDamage {
self.capture_stable_transcript_anchor();
let snapshot = self.session_view.snapshot();
let damage = self
.session_view_terminal_adapter
.apply(snapshot, &mut self.transcript);
if let TranscriptDocumentDamage::Items(ids) = &damage {
self.transcript_dirty_items
.extend(ids.iter().filter_map(|id| {
self.transcript
.source_index(id)
.and_then(|index| self.transcript.presentation_id(index))
}));
}
#[cfg(test)]
{
self.observations.last_transcript_damage.clone_from(&damage);
}
self.transcript_projection_revision = self.transcript_projection_revision.saturating_add(1);
damage
}
fn shared_terminal_item(&self, sequence: u64) -> Option<TranscriptItem> {
self.session_view
.snapshot()
.transcript
.items
.iter()
.rev()
.find(|item| item.sequence == Some(sequence))
.map(terminal_item_from_shared)
}
fn shared_terminal_text_for_sequence(
&self,
sequence: u64,
role: &'static str,
) -> Option<String> {
self.shared_terminal_item(sequence)
.filter(|item| item.role == role)
.map(|item| item.text)
}
#[cfg(test)]
fn latest_shared_terminal_text(&self, role: &'static str) -> Option<String> {
self.session_view
.snapshot()
.transcript
.items
.iter()
.rev()
.map(terminal_item_from_shared)
.find(|item| item.role == role)
.map(|item| item.text)
}
#[cfg(test)]
fn latest_shared_streaming_terminal_item(&self, role: &'static str) -> Option<TranscriptItem> {
self.session_view
.snapshot()
.transcript
.items
.iter()
.rev()
.map(terminal_item_from_shared)
.find(|item| item.role == role && item.streaming)
}
fn apply_working_directory_changed(
&mut self,
_event_sequence: u64,
old_working_directory: &std::path::Path,
) {
self.invalidate_theme_catalog();
if let Some(new_working_directory) = self.working_directory() {
self.status = format!(
"working directory: {}",
display(new_working_directory, old_working_directory)
);
}
}
fn apply_tool_request_side_effects(
&mut self,
tool_call_id: &str,
tool_name: &str,
arguments_json: &str,
) {
let shared_tool = self.session_view.snapshot().tools.get(tool_call_id);
let projected_tool_name = shared_tool
.and_then(|tool| tool.tool_name.clone())
.unwrap_or_else(|| tool_name.to_owned());
let projected_arguments = shared_tool
.and_then(|tool| tool.arguments_json.clone())
.unwrap_or_else(|| arguments_json.to_owned());
self.set_activity(ActivityState::RunningTool {
name: projected_tool_name,
});
self.status =
tool_request_status(&projected_arguments).unwrap_or_else(|| "started".to_owned());
}
fn push_permission_request(&mut self, input: PermissionRequestInput<'_>) {
let shared_permission = self.shared_permission_view(input.permission_id).cloned();
if input.application.live_activity() {
let tool_name = shared_permission
.as_ref()
.map_or(input.tool_name, |permission| permission.tool_name.as_str());
self.set_activity(ActivityState::WaitingPermission {
name: tool_name.to_owned(),
});
}
if input.application.live_activity() {
let tool_call_id = shared_permission
.as_ref()
.map_or(input.tool_call_id, |permission| {
permission.tool_call_id.as_str()
});
let arguments_json = shared_permission
.as_ref()
.map_or(input.arguments_json, |permission| {
permission.arguments_json.as_str()
});
let tool_name = shared_permission
.as_ref()
.map_or(input.tool_name, |permission| permission.tool_name.as_str());
self.status = Self::tool_call_file_status(tool_call_id).map_or_else(
|| tool_request_status(arguments_json).unwrap_or_else(|| tool_name.to_owned()),
|status| format!("waiting permission · {status}"),
);
}
}
fn shared_permission_view(
&self,
permission_id: &str,
) -> Option<&bcode_session_view_models::PermissionView> {
self.session_view
.snapshot()
.permissions
.iter()
.find(|permission| permission.permission_id == permission_id)
}
fn set_permission_status(&mut self, permission_id: &str) {
let approved = self
.session_view
.snapshot()
.transcript
.items
.iter()
.rev()
.find_map(|item| match &item.kind {
bcode_session_view_models::TranscriptViewItemKind::Permission { permission }
if permission.permission_id == permission_id && permission.resolved =>
{
permission.approved
}
_ => None,
})
.unwrap_or(false);
let status = if approved {
"permission approved"
} else {
"permission denied"
};
status.clone_into(&mut self.status);
}
fn set_file_activity(&mut self, tool_name: &str) {
self.set_activity(ActivityState::RunningTool {
name: tool_name.to_owned(),
});
}
const fn tool_call_file_status(_tool_call_id: &str) -> Option<String> {
None
}
fn update_tool_result_status(
&mut self,
tool_call_id: &str,
is_error: bool,
application: SessionEventApplication,
) {
if !application.live_activity() {
return;
}
let is_error = self
.session_view
.snapshot()
.tools
.get(tool_call_id)
.and_then(|tool| tool.is_error)
.unwrap_or(is_error);
if is_error {
"failed".clone_into(&mut self.status);
} else if let Some(status) = Self::tool_call_file_status(tool_call_id) {
self.status = format!("applied · {status}");
} else {
"finished".clone_into(&mut self.status);
}
}
fn push_tool_result(
&mut self,
tool_call_id: &str,
_result: &str,
is_error: bool,
_semantic_result: Option<&ToolInvocationResult>,
application: SessionEventApplication,
) {
self.update_tool_result_status(tool_call_id, is_error, application);
}
fn push_model_usage(
&mut self,
_event_sequence: u64,
_turn_id: &str,
usage: &bcode_session_models::SessionTokenUsage,
application: SessionEventApplication,
) {
let projected_usage = self
.session_view
.snapshot()
.runtime
.latest_usage
.clone()
.unwrap_or_else(|| usage.clone());
self.token_usage.absorb(&projected_usage);
if application.live_activity()
&& let Some(tokens) = projected_usage.metered_total_tokens()
{
self.status = format!("tokens: {tokens}");
}
}
fn apply_shared_model_turn_started(&mut self) {
let runtime = &self.session_view.snapshot().runtime;
debug_assert!(runtime.active_turn_id.is_some());
debug_assert!(!runtime.cancelling);
if runtime.active_turn_id.is_some() && !runtime.cancelling {
self.set_activity(ActivityState::PreparingModelRequest);
}
}
fn apply_shared_model_turn_cancel_requested(&mut self) {
let runtime = &self.session_view.snapshot().runtime;
debug_assert!(runtime.active_turn_id.is_some());
debug_assert!(runtime.cancelling);
if runtime.cancelling {
self.set_cancelling();
"cancellation requested".clone_into(&mut self.status);
}
}
fn finish_shared_model_turn(&mut self, application: SessionEventApplication) {
let runtime = &self.session_view.snapshot().runtime;
if application.live_activity()
&& let Some(outcome) = runtime.last_turn_outcome
{
self.status = runtime.last_turn_message.as_deref().map_or_else(
|| model_turn_outcome_label(outcome).to_owned(),
ToOwned::to_owned,
);
}
if application.live_activity() {
self.apply_shared_runtime_work_activity();
}
}
fn apply_shared_runtime_work_activity(&mut self) {
let snapshot = self.session_view.snapshot();
let runtime_work = &snapshot.runtime_work;
if runtime_work
.iter()
.any(|work| work.status == bcode_session_models::RuntimeWorkStatus::Cancelling)
{
self.set_cancelling();
} else if let Some(detail) = runtime_work_detail(runtime_work) {
self.set_activity(ActivityState::RuntimeWork { detail });
} else if !snapshot.active_invocations.is_empty() {
let count = snapshot.active_invocations.len();
let detail = if count == 1 {
"running tool".to_owned()
} else {
format!("running {count} tools")
};
self.set_activity(ActivityState::RuntimeWork { detail });
} else {
self.set_activity(ActivityState::Idle);
}
}
fn apply_context_occupancy(
&mut self,
occupancy: Option<bcode_session_models::RequestContextOccupancy>,
) {
self.session_view.set_context_occupancy(occupancy);
}
fn set_activity(&mut self, activity: ActivityState) {
if self.activity != activity {
if !self.activity.same_phase_as(&activity) {
self.activity_started_at = Instant::now();
self.activity_elapsed = Duration::ZERO;
}
self.activity = activity;
}
}
fn add_streaming_delta(&mut self, text: &str, application: SessionEventApplication) {
if !application.live_activity() {
return;
}
let delta = text.chars().count();
if let ActivityState::Streaming { chars } = &mut self.activity {
*chars = chars.saturating_add(delta);
} else {
self.set_activity(ActivityState::Streaming { chars: delta });
}
}
fn apply_provider_trace_event(&mut self, event_type: &str, detail: Option<&str>) {
if event_type == bcode_session_models::REQUEST_CONTEXT_OBSERVATION_EVENT_TYPE {
let valid = detail.is_some_and(|detail| {
bcode_session_models::RequestContextObservationReference::parse(event_type, detail)
.is_ok()
});
let summary = if valid {
"Request context observation: normalized metadata retained"
} else {
"Request context observation: invalid reference"
};
summary.clone_into(&mut self.status);
} else if matches!(event_type, "warning" | "error") {
let detail =
detail.map_or_else(|| format!("provider event: {event_type}"), str::to_owned);
self.set_activity(ActivityState::ProviderStream {
detail: detail.clone(),
});
self.status = detail;
}
}
fn apply_trace_event(&mut self, trace: &SessionTraceEvent) {
match &trace.payload {
SessionTracePayload::ProviderStreamEvent(event) => {
self.apply_provider_stream_event(event);
}
SessionTracePayload::ProviderEvent { event_type, detail } => {
self.apply_provider_trace_event(event_type, detail.as_deref());
}
SessionTracePayload::ContextCompaction { message, .. } => {
self.apply_compaction_trace(trace.phase, message.as_deref());
}
SessionTracePayload::ModelRequestBuilt {
provider,
uses_previous_provider_response,
message_count,
metadata,
..
} => {
self.token_usage.apply_model_request(
*uses_previous_provider_response,
*message_count,
metadata
.get("sent_message_count")
.and_then(|value| value.parse().ok()),
metadata
.get("prompt_cache_points")
.and_then(|value| value.parse().ok()),
);
self.set_activity(ActivityState::StartingProviderRequest {
provider: provider.clone(),
round: None,
});
}
SessionTracePayload::ProviderRound {
provider,
round,
stop_reason,
..
} => match trace.phase {
SessionTracePhase::ModelProviderRoundStarted => {
self.set_activity(ActivityState::WaitingForProvider {
provider: provider.clone(),
round: *round,
});
}
SessionTracePhase::ModelProviderRoundFinished => {
self.set_activity(
if stop_reason.as_deref().is_some_and(|reason| {
reason.eq_ignore_ascii_case("tool_call")
|| reason.eq_ignore_ascii_case("toolcall")
|| reason.eq_ignore_ascii_case("tool_calls")
}) {
ActivityState::PreparingToolExecution {
name: "provider tool call".to_owned(),
}
} else {
ActivityState::FinalizingModelTurn
},
);
}
_ => {}
},
SessionTracePayload::ToolInvocationStarted { tool_name, .. } => {
self.set_activity(ActivityState::RunningTool {
name: tool_name.clone(),
});
}
SessionTracePayload::ToolPermissionWait {
tool_call_id,
approved,
..
} => {
let tool_name = self
.session_view
.snapshot()
.tools
.get(tool_call_id)
.and_then(|tool| tool.tool_name.clone())
.unwrap_or_else(|| "unknown tool".to_owned());
self.set_activity(if approved.is_none() {
ActivityState::WaitingPermission { name: tool_name }
} else {
ActivityState::PreparingFollowUpRequest
});
}
SessionTracePayload::ToolInvocationFinished { .. } => {
self.set_activity(ActivityState::PreparingFollowUpRequest);
}
SessionTracePayload::ToolPolicyEvaluated { .. } => {}
}
}
fn apply_shared_provider_stream_progress(&mut self, event: &ProviderStreamEvent) {
let progress = self
.session_view
.snapshot()
.runtime
.provider_progress
.clone();
match event {
ProviderStreamEvent::ToolCallFinished { tool_name, .. } => {
if matches!(self.activity, ActivityState::ProviderStream { .. }) {
self.set_activity(ActivityState::PreparingToolExecution {
name: tool_name.clone(),
});
}
}
ProviderStreamEvent::RetryScheduled { .. } => {
if let Some(progress) = progress {
self.set_activity(ActivityState::RetryWait {
message: progress.detail,
retry_at_unix: progress.retry_at_unix.unwrap_or_default(),
});
}
}
_ => {
if let Some(progress) = progress {
self.set_activity(ActivityState::ProviderStream {
detail: progress.detail,
});
}
}
}
}
fn apply_provider_stream_event(&mut self, event: &ProviderStreamEvent) {
match event {
ProviderStreamEvent::TurnStarted => {
self.set_activity(ActivityState::ProviderStream {
detail: "provider stream started".to_owned(),
});
}
ProviderStreamEvent::ToolCallStarted { tool_name, .. } => {
let detail = format!("provider stream tool started: {tool_name}");
self.set_activity(ActivityState::ProviderStream { detail });
}
ProviderStreamEvent::ToolCallProgress {
tool_name,
argument_bytes,
..
} => {
let detail = format!(
"assembling {tool_name} arguments ({} received)",
format_provider_bytes(*argument_bytes)
);
self.set_activity(ActivityState::ProviderStream { detail });
}
ProviderStreamEvent::ToolCallFinished { tool_name, .. } => {
if matches!(self.activity, ActivityState::ProviderStream { .. }) {
self.set_activity(ActivityState::PreparingToolExecution {
name: tool_name.clone(),
});
}
}
ProviderStreamEvent::NoProgressWarning {
idle_seconds,
active_tool_call,
} => {
let detail = active_tool_call.as_ref().map_or_else(
|| format!("provider stream idle for {idle_seconds}s"),
|tool| {
format!(
"provider stream idle for {idle_seconds}s while assembling {}",
tool.tool_name
)
},
);
self.set_activity(ActivityState::ProviderStream { detail });
}
ProviderStreamEvent::RetryScheduled {
message,
retry_at_unix,
} => {
self.set_activity(ActivityState::RetryWait {
message: message.clone(),
retry_at_unix: *retry_at_unix,
});
}
}
}
fn apply_compaction_trace(&mut self, phase: SessionTracePhase, message: Option<&str>) {
match phase {
SessionTracePhase::ContextCompactionStarted => {
let detail = message.map_or_else(|| "older context".to_owned(), ToOwned::to_owned);
self.set_activity(ActivityState::Compacting { detail });
}
SessionTracePhase::ContextCompactionFinished => {
if matches!(self.activity, ActivityState::Compacting { .. }) {
self.set_activity(ActivityState::PreparingModelRequest);
}
}
SessionTracePhase::ContextCompactionSkipped => {
if matches!(self.activity, ActivityState::Compacting { .. }) {
self.set_activity(ActivityState::PreparingModelRequest);
}
}
SessionTracePhase::ContextCompactionDiagnostic
| SessionTracePhase::ModelRequestBuilt
| SessionTracePhase::ModelProviderRoundStarted
| SessionTracePhase::ModelProviderRoundFinished
| SessionTracePhase::ModelProviderEvent
| SessionTracePhase::ToolInvocationStarted
| SessionTracePhase::ToolPolicyEvaluated
| SessionTracePhase::ToolPermissionWaitStarted
| SessionTracePhase::ToolPermissionWaitFinished
| SessionTracePhase::ToolInvocationFinished
| SessionTracePhase::ToolInvocationOutput
| SessionTracePhase::SkillInvoked
| SessionTracePhase::SkillSuggested
| SessionTracePhase::SkillActivated
| SessionTracePhase::SkillDeactivated
| SessionTracePhase::SkillContextLoaded
| SessionTracePhase::SkillInvocationFailed => {}
}
}
}
pub const fn composer_policy() -> TextInputPolicy {
TextInputPolicy::chat_composer()
}
fn next_periodic_deadline(started_at: Instant, now: Instant, interval: Duration) -> Instant {
let interval_nanos = interval.as_nanos().max(1);
let period = now.saturating_duration_since(started_at).as_nanos() / interval_nanos;
let next_offset = interval_nanos.saturating_mul(period.saturating_add(1));
started_at + Duration::from_nanos(u64::try_from(next_offset).unwrap_or(u64::MAX))
}
fn latest_bar_active_frame_duration(burst: u8) -> Duration {
Duration::from_millis(
210_u64
.saturating_sub(u64::from(burst).saturating_mul(21))
.max(36),
)
}
fn next_latest_bar_frame_at(started_at: Instant, now: Instant, frame: Duration) -> Instant {
let frame_nanos = frame.as_nanos().max(1);
let frame_count = now.saturating_duration_since(started_at).as_nanos() / frame_nanos;
let next_offset = frame_nanos.saturating_mul(frame_count.saturating_add(1));
started_at + Duration::from_nanos(u64::try_from(next_offset).unwrap_or(u64::MAX))
}
fn tool_elapsed_invalidation_invocation_id(key: &InvalidationKey) -> Option<&str> {
key.as_str()
.strip_prefix(TOOL_ELAPSED_INVALIDATION_PREFIX)?
.strip_prefix(':')
.filter(|invocation_id| !invocation_id.is_empty())
}
fn is_latest_bar_animation_invalidation(key: &InvalidationKey) -> bool {
key.as_str() == LATEST_BAR_ANIMATION_INVALIDATION_KEY
}
fn is_theme_transition_invalidation(key: &InvalidationKey) -> bool {
key.as_str() == THEME_TRANSITION_INVALIDATION_KEY
}
fn is_activity_animation_invalidation(key: &InvalidationKey) -> bool {
key.as_str() == ACTIVITY_ANIMATION_INVALIDATION_KEY
}
fn is_transcript_scroll_animation_invalidation(key: &InvalidationKey) -> bool {
key.as_str() == TRANSCRIPT_SCROLL_ANIMATION_INVALIDATION_KEY
}
fn interpolate_color(
source: Color,
target: Color,
elapsed_ms: u64,
duration_ms: u64,
curve: TuiAccentTransitionCurve,
) -> Color {
let source = color_rgb(source);
let target = color_rgb(target);
Color::Rgb(
interpolate_channel(source[0], target[0], elapsed_ms, duration_ms, curve),
interpolate_channel(source[1], target[1], elapsed_ms, duration_ms, curve),
interpolate_channel(source[2], target[2], elapsed_ms, duration_ms, curve),
)
}
fn interpolate_channel(
source: u8,
target: u8,
elapsed_ms: u64,
duration_ms: u64,
curve: TuiAccentTransitionCurve,
) -> u8 {
let (eased_numerator, denominator) = transition_progress(elapsed_ms, duration_ms, curve);
let source = i128::from(source);
let delta = i128::from(target).saturating_sub(source);
let scaled_delta = delta.saturating_mul(i128::try_from(eased_numerator).unwrap_or(i128::MAX))
/ i128::try_from(denominator).unwrap_or(1);
u8::try_from(source.saturating_add(scaled_delta).clamp(0, 255)).unwrap_or(u8::MAX)
}
fn transition_progress(
elapsed_ms: u64,
duration_ms: u64,
curve: TuiAccentTransitionCurve,
) -> (u128, u128) {
let duration = u128::from(duration_ms).max(1);
let elapsed = u128::from(elapsed_ms).min(duration);
match curve {
TuiAccentTransitionCurve::Linear => (elapsed, duration),
TuiAccentTransitionCurve::EaseIn => {
(elapsed.saturating_pow(3), duration.saturating_pow(3).max(1))
}
TuiAccentTransitionCurve::EaseOut => ease_out_cubic_progress(elapsed, duration),
TuiAccentTransitionCurve::EaseInOut => ease_in_out_cubic_progress(elapsed, duration),
}
}
fn ease_out_cubic_progress(elapsed: u128, duration: u128) -> (u128, u128) {
let denominator = duration.saturating_pow(3).max(1);
let remaining = duration.saturating_sub(elapsed);
(
denominator.saturating_sub(remaining.saturating_pow(3)),
denominator,
)
}
fn ease_in_out_cubic_progress(elapsed: u128, duration: u128) -> (u128, u128) {
let denominator = duration.saturating_pow(3).saturating_mul(2).max(1);
let half_duration = duration / 2;
if elapsed <= half_duration {
(elapsed.saturating_pow(3).saturating_mul(4), denominator)
} else {
let remaining_twice = duration.saturating_sub(elapsed).saturating_mul(2);
(
denominator.saturating_sub(remaining_twice.saturating_pow(3)),
denominator,
)
}
}
const fn color_rgb(color: Color) -> [u8; 3] {
match color {
Color::Black => [0, 0, 0],
Color::Red => [205, 49, 49],
Color::Green => [13, 188, 121],
Color::Yellow => [229, 229, 16],
Color::Blue => [36, 114, 200],
Color::Magenta => [188, 63, 188],
Color::Cyan => [17, 168, 205],
Color::White => [229, 229, 229],
Color::BrightBlack => [102, 102, 102],
Color::BrightRed => [241, 76, 76],
Color::BrightGreen => [35, 209, 139],
Color::BrightYellow => [245, 245, 67],
Color::BrightBlue => [59, 142, 234],
Color::BrightMagenta => [214, 112, 214],
Color::BrightCyan => [41, 184, 219],
Color::BrightWhite => [255, 255, 255],
Color::Rgb(red, green, blue) => [red, green, blue],
Color::Indexed(_) | Color::Default => [100, 116, 139],
}
}
fn tool_request_status(arguments_json: &str) -> Option<String> {
let value = serde_json::from_str::<serde_json::Value>(arguments_json).ok()?;
value
.get("path")
.or_else(|| value.get("cwd"))
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct TokenUsageMeter {
latest_cached_input_tokens: Option<u32>,
latest_cache_write_input_tokens: Option<u32>,
provider_reuse_active: bool,
latest_sent_message_count: Option<usize>,
latest_total_message_count: Option<usize>,
latest_prompt_cache_points: Option<usize>,
context_window: Option<u32>,
proactive_compaction_target: Option<u64>,
}
impl TokenUsageMeter {
const fn absorb(&mut self, usage: &bcode_session_models::SessionTokenUsage) {
self.latest_cached_input_tokens = usage.cached_input_tokens;
self.latest_cache_write_input_tokens = usage.cache_write_input_tokens;
}
const fn apply_model_info(&mut self, model: Option<&bcode_model::ModelInfo>) {
if let Some(model) = model {
self.context_window = model.context_window;
}
}
const fn clear_model_info(&mut self) {
self.latest_cached_input_tokens = None;
self.latest_cache_write_input_tokens = None;
self.context_window = None;
self.proactive_compaction_target = None;
}
const fn apply_model_request(
&mut self,
uses_previous_provider_response: bool,
message_count: usize,
metadata_sent_message_count: Option<usize>,
metadata_prompt_cache_points: Option<usize>,
) {
self.provider_reuse_active = uses_previous_provider_response;
self.latest_total_message_count = Some(message_count);
self.latest_sent_message_count = metadata_sent_message_count;
self.latest_prompt_cache_points = metadata_prompt_cache_points;
}
fn footer_summary(
&self,
occupancy: Option<&bcode_session_models::RequestContextOccupancy>,
cumulative_metered_tokens: u64,
cost: &bcode_session_view_models::SessionCostSummary,
) -> String {
let mut parts = vec![self.context_summary(occupancy)];
if self.provider_reuse_active {
parts.push("reuse on".to_string());
}
if let Some(cached) = self.latest_cached_input_tokens
&& cached > 0
{
parts.push(format!("cache read {} tok", compact_u64(u64::from(cached))));
}
if let Some(written) = self.latest_cache_write_input_tokens
&& written > 0
{
parts.push(format!(
"cache write {} tok",
compact_u64(u64::from(written))
));
}
if let (Some(sent), Some(total)) = (
self.latest_sent_message_count,
self.latest_total_message_count,
) && sent < total
{
parts.push(format!("sent {sent}/{total} msgs"));
}
if let Some(points) = self.latest_prompt_cache_points
&& points > 0
{
parts.push(format!("cache points {points}"));
}
parts.push(format!(
"spent {} tok",
compact_u64(cumulative_metered_tokens)
));
if cost.original_usage_incomplete {
parts.push("usage capture partial".into());
}
if cost.revision > 0 {
parts.push(format!("cost rev {}", cost.revision));
}
for (currency, cost_micros) in &cost.totals_micros {
if currency == "USD" {
parts.push(format!(
"estimated {} (not billed)",
format_usd_micros(*cost_micros)
));
} else {
parts.push(format!(
"estimated {currency} {} (not billed)",
format_decimal_micros(*cost_micros)
));
}
}
if cost.unavailable_usage_count > 0
|| cost.observed_usage_count > cost.estimated_usage_count
{
parts.push(if cost.estimated_usage_count > 0 {
format!(
"cost partial: {}/{} requests priced",
cost.estimated_usage_count, cost.observed_usage_count
)
} else {
format!(
"cost unavailable: {} requests unpriced",
cost.observed_usage_count
)
});
}
parts.join(" · ")
}
fn context_summary(
&self,
occupancy: Option<&bcode_session_models::RequestContextOccupancy>,
) -> String {
let display_limit = self
.proactive_compaction_target
.or_else(|| self.context_window.map(u64::from));
match (occupancy, display_limit) {
(Some(occupancy), Some(window)) if window > 0 => {
let input = occupancy.observation.context_tokens.tokens();
let percentage = context_window_percentage(input, window);
format!(
"{}{}/{} {}",
if occupancy.observation.context_tokens.is_estimated() {
"~"
} else {
""
},
format_context_count(input),
compact_context_window(window),
if percentage > 100 {
"100%+".to_string()
} else {
format!("{percentage}%")
}
)
}
(None, Some(window)) if window > 0 => {
format!("—/{} —%", compact_context_window(window))
}
_ => "—/— —%".to_owned(),
}
}
}
fn provider_to_display_selection(provider: &str) -> Option<String> {
if provider == "<auto>" || provider.is_empty() {
None
} else {
Some(provider.to_owned())
}
}
fn model_to_display_selection(model: &str) -> Option<String> {
if model == "<default>" || model.is_empty() {
None
} else {
Some(model.to_owned())
}
}
fn format_context_count(value: u64) -> String {
let digits = value.to_string();
let mut output = String::with_capacity(digits.len() + digits.len() / 3);
for (index, byte) in digits.bytes().enumerate() {
if index > 0 && (digits.len() - index).is_multiple_of(3) {
output.push(',');
}
output.push(char::from(byte));
}
output
}
fn compact_context_window(value: u64) -> String {
if value >= 1_000_000 {
format!("{}m", value / 1_000_000)
} else if value >= 1_000 {
format!("{}k", value / 1_000)
} else {
value.to_string()
}
}
fn compact_u64(value: u64) -> String {
if value >= 1_000_000 {
compact_decimal(value, 1_000_000, 'm')
} else if value >= 1_000 {
compact_decimal(value, 1_000, 'k')
} else {
value.to_string()
}
}
fn compact_decimal(value: u64, unit: u64, suffix: char) -> String {
let hundredths = value.saturating_mul(100) / unit;
let whole = hundredths / 100;
let fraction = hundredths % 100;
if fraction == 0 {
format!("{whole}{suffix}")
} else if fraction.is_multiple_of(10) {
format!("{whole}.{}{suffix}", fraction / 10)
} else {
format!("{whole}.{fraction:02}{suffix}")
}
}
fn format_decimal_micros(micros: u64) -> String {
format!("{}.{:06}", micros / 1_000_000, micros % 1_000_000)
}
fn format_usd_micros(micros: u64) -> String {
let dollars = micros / 1_000_000;
let cents = (micros % 1_000_000) / 10_000;
if dollars == 0 && cents == 0 && micros > 0 {
"<$0.01".to_string()
} else {
format!("${dollars}.{cents:02}")
}
}
fn format_provider_bytes(bytes: usize) -> String {
const KIB: usize = 1024;
const MIB: usize = KIB * 1024;
if bytes >= MIB {
let whole = bytes / MIB;
let decimal = (bytes % MIB) * 10 / MIB;
format!("{whole}.{decimal} MiB")
} else if bytes >= KIB {
let whole = bytes / KIB;
let decimal = (bytes % KIB) * 10 / KIB;
format!("{whole}.{decimal} KiB")
} else {
format!("{bytes} B")
}
}
fn context_window_percentage(input_tokens: u64, context_window: u64) -> u32 {
let numerator = input_tokens.saturating_mul(100);
let denominator = context_window.max(1);
u32::try_from(numerator / denominator).unwrap_or(u32::MAX)
}
const fn event_breaks_sticky_entry_anchor(event: &SessionEvent) -> bool {
matches!(&event.kind, SessionEventKind::PermissionRequested { .. })
}
const fn event_affects_transcript_rows(event: &SessionEvent) -> bool {
match &event.kind {
SessionEventKind::SessionChildPublished { .. }
| SessionEventKind::ChildSessionCreated { .. }
| SessionEventKind::SubmissionAccepted { .. }
| SessionEventKind::SubmissionActivated { .. }
| SessionEventKind::SubmissionCancelled { .. }
| SessionEventKind::SubmissionPendingCancelled { .. }
| SessionEventKind::SubmissionContextObserved { .. }
| SessionEventKind::UserMessage { .. }
| SessionEventKind::AssistantDelta { .. }
| SessionEventKind::AssistantMessage { .. }
| SessionEventKind::AssistantResponseSegment { .. }
| SessionEventKind::PositionedAssistantResponseSegment { .. }
| SessionEventKind::PositionedAssistantReasoningActivity { .. }
| SessionEventKind::PositionedToolCallRequested { .. }
| SessionEventKind::SystemMessage { .. }
| SessionEventKind::ToolCallRequested { .. }
| SessionEventKind::ToolInvocationResultRecorded { .. }
| SessionEventKind::PermissionRequested { .. }
| SessionEventKind::PermissionResolved { .. }
| SessionEventKind::ModelUsage { .. }
| SessionEventKind::ContextCompacted { .. }
| SessionEventKind::ProviderContextCompacted { .. }
| SessionEventKind::RequestContextObserved { .. }
| SessionEventKind::WorkingDirectoryChanged { .. }
| SessionEventKind::SkillInvoked { .. }
| SessionEventKind::SkillInvocationFailed { .. }
| SessionEventKind::RuntimeWorkStarted { .. }
| SessionEventKind::RuntimeWorkCancelRequested { .. }
| SessionEventKind::RuntimeWorkProgress { .. }
| SessionEventKind::RuntimeWorkFinished { .. }
| SessionEventKind::ToolContribution { .. }
| SessionEventKind::ToolContributionPlaced { .. }
| SessionEventKind::ToolExchangeRequested { .. }
| SessionEventKind::ToolExchangeResolved { .. }
| SessionEventKind::ToolInvocationLifecycle { .. }
| SessionEventKind::RalphLifecycle { .. }
| SessionEventKind::PluginStatusNote { .. }
| SessionEventKind::AssistantReasoningDelta { .. }
| SessionEventKind::AssistantReasoningMessage { .. }
| SessionEventKind::AssistantReasoningActivity { .. }
| SessionEventKind::ModelTurnFinished { .. }
| SessionEventKind::InertHistory { .. } => true,
SessionEventKind::SkillSuggested { reason, .. } => reason.is_some(),
SessionEventKind::SubmissionRecoveryAuthorized { .. }
| SessionEventKind::SessionChildrenReconciled { .. }
| SessionEventKind::SessionChildLinked { .. }
| SessionEventKind::SessionCreated { .. }
| SessionEventKind::ClientAttached { .. }
| SessionEventKind::ClientDetached { .. }
| SessionEventKind::ModelChanged { .. }
| SessionEventKind::ReasoningChanged { .. }
| SessionEventKind::AgentChanged { .. }
| SessionEventKind::ModelTurnStarted { .. }
| SessionEventKind::ModelFeatureFidelityNegotiated { .. }
| SessionEventKind::ModelTurnCancelRequested { .. }
| SessionEventKind::MilestoneEvaluationRequested { .. }
| SessionEventKind::MilestonePublicationRequested { .. }
| SessionEventKind::MilestonePublished { .. }
| SessionEventKind::MilestoneEvaluationCompleted { .. }
| SessionEventKind::AutomaticTitleUpdated { .. }
| SessionEventKind::SessionRenamed { .. }
| SessionEventKind::SessionImported { .. }
| SessionEventKind::SessionDerived { .. }
| SessionEventKind::ExecutionSessionCreated { .. }
| SessionEventKind::SkillActivated { .. }
| SessionEventKind::SkillDeactivated { .. }
| SessionEventKind::SkillContextLoaded { .. }
| SessionEventKind::TraceEvent { .. } => false,
}
}
#[cfg(test)]
mod tests {
#[test]
fn plugin_policy_delivery_is_secret_safe_and_reload_invalidates_once() {
use bcode_plugin_sdk::tui::{
PluginTuiPresentationConfiguration, PluginTuiRegistry, PluginTuiVisualAdapter,
PluginTuiVisualRenderContext,
};
use std::sync::{Arc, Mutex};
struct Capture(Arc<Mutex<Vec<serde_json::Value>>>);
impl PluginTuiVisualAdapter for Capture {
fn supports(&self, _kind: &str) -> bool {
true
}
fn rows(
&self,
_kind: &str,
_payload: &serde_json::Value,
_context: &PluginTuiVisualRenderContext,
) -> Vec<bmux_tui::prelude::Line> {
Vec::new()
}
fn configure(
&self,
configuration: &PluginTuiPresentationConfiguration,
) -> Result<(), String> {
self.0.lock().unwrap().push(configuration.config.clone());
Ok(())
}
}
let host = bcode_plugin::PluginHost::load_static_plugins(&[]).unwrap();
let presentation = Arc::new(crate::plugin_tui::PluginTuiPresentation::new(host));
let calls = Arc::new(Mutex::new(Vec::new()));
let mut registry = PluginTuiRegistry::default();
registry.register_visual_adapter(["test"], Box::new(Capture(Arc::clone(&calls))));
presentation.install_registry_for_test("test", registry);
let mut app = super::BmuxApp::new_with_history(None, &[], &[], false);
app.set_plugin_presentation(Arc::clone(&presentation));
drop(presentation);
let mut config = bcode_config::BcodeConfig::default();
config
.tools
.shell
.output
.prelude_gates
.push(bcode_config::ShellToolPreludeGateConfig {
marker: "private-marker".to_owned(),
..bcode_config::ShellToolPreludeGateConfig::default()
});
let before = app.elapsed_layout_revision();
assert!(app.apply_plugin_presentation_config(&config).unwrap());
assert_eq!(app.elapsed_layout_revision(), before + 1);
assert!(!app.apply_plugin_presentation_config(&config).unwrap());
let payload = calls.lock().unwrap()[0].clone();
assert!(!payload.to_string().contains("private-marker"));
assert_eq!(payload.as_object().unwrap().len(), 1);
assert!(payload["shell"].get("env").is_none());
config.tools.shell.output.presentation.python = false;
assert!(app.apply_plugin_presentation_config(&config).unwrap());
assert_eq!(app.elapsed_layout_revision(), before + 2);
drop(app);
let updated = calls.lock().unwrap()[1].clone();
let output: bcode_config::ShellToolOutputConfig =
serde_json::from_value(updated["shell"]["output"].clone()).unwrap();
assert!(!output.presentation.python);
assert_eq!(
output.format_commands,
config.tools.shell.output.format_commands
);
}
#[test]
fn older_item_becoming_tail_cannot_supersede_assistant_hold() {
let mut app = super::BmuxApp::new_with_history(None, &[], &[], false);
let old = super::super::transcript::TranscriptItem::new("You", "old prompt".to_owned());
let assistant =
super::super::transcript::TranscriptItem::new("Assistant", "reply".to_owned());
app.transcript
.push_ephemeral("test".to_owned(), old.clone());
app.transcript
.push_ephemeral("test".to_owned(), assistant.clone());
app.automatic_item = Some(assistant.id());
app.admitted_items.extend([old.id(), assistant.id()]);
app.viewport
.sync_max(20, 9, 30, 10, false, &mut app.older_history);
app.viewport.reveal(true);
app.viewport.materialize_top_row(10);
app.transcript = super::super::transcript_document::TranscriptDocument::default();
app.transcript.push_ephemeral("test".to_owned(), old);
app.sync_transcript_anchor_requests();
assert!(app.transcript_scroll_animation.is_none());
assert!(!app.viewport.allows_overflow());
assert_eq!(app.viewport.top_row(30, 10), 10);
drop(app);
}
#[test]
fn completed_row_animation_preserves_hold_without_item_correspondence() {
let mut app = super::BmuxApp::new_with_history(None, &[], &[], false);
app.viewport
.sync_max(20, 9, 30, 10, false, &mut app.older_history);
app.viewport.reveal(true);
let now = std::time::Instant::now();
let mut animation = super::TranscriptScrollAnimation::new(0, 12, now);
animation.started_at = now
.checked_sub(std::time::Duration::from_secs(1))
.expect("clock permits test offset");
app.transcript_scroll_animation = Some(animation);
app.sync_transcript_scroll_max(20, 9, 30, 10);
assert_eq!(app.viewport.top_row(30, 10), 12);
assert!(!app.viewport.allows_overflow());
app.sync_transcript_scroll_max(25, 9, 35, 10);
assert_eq!(app.viewport.top_row(35, 10), 12);
drop(app);
}
#[test]
fn manual_scroll_cancels_deferred_sequence_and_submission_reveals() {
let mut app = super::BmuxApp::new_with_history(None, &[], &[], false);
app.viewport
.sync_max(20, 9, 30, 10, false, &mut app.older_history);
app.pending_submissions.stage("pending".to_owned());
app.request_transcript_top_anchor_sequence(42);
assert!(app.scroll_transcript_up(2));
app.expire_manual_transcript_scroll_for_test();
app.sync_transcript_anchor_requests();
assert!(app.pending_transcript_top_anchor_sequence.is_none());
assert!(!app.pending_submissions.reveals_on_acceptance("pending"));
assert_eq!(app.pending_submissions.items()[0].text(), "pending");
app.pending_submissions.stage("later".to_owned());
assert!(app.pending_submissions.reveals_on_acceptance("later"));
drop(app);
}
#[test]
fn pages_must_progress_without_overlapping_the_resident_window() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: format!("message {sequence}"),
},
};
let mut app = super::BmuxApp::new_with_history(Some(session_id), &[event(10)], &[], true);
let older = bcode_session_models::SessionHistoryCursor { sequence: 9 };
assert!(app.accepts_history_page_events(older, true, &[event(2), event(8)], true));
for invalid in [
vec![event(2), event(10)],
vec![event(8), event(2)],
vec![event(11)],
] {
assert!(!app.accepts_history_page_events(older, true, &invalid, true));
}
assert!(!app.accepts_history_page_events(older, true, &[], true));
assert!(app.accepts_history_page_events(older, true, &[], false));
let mut page = bcode_session_models::SessionHistoryPage {
session_id,
events: vec![event(8)],
compatibility_issues: Vec::new(),
next_cursor: Some(bcode_session_models::SessionHistoryCursor { sequence: 7 }),
has_more: true,
};
assert!(app.accepts_history_page_payload(older, true, &page));
page.next_cursor = None;
assert!(app.accepts_history_page_payload(older, true, &page));
page.next_cursor = Some(bcode_session_models::SessionHistoryCursor { sequence: 8 });
assert!(!app.accepts_history_page_payload(older, true, &page));
page.next_cursor = Some(bcode_session_models::SessionHistoryCursor { sequence: 7 });
page.has_more = false;
assert!(!app.accepts_history_page_payload(older, true, &page));
page.has_more = true;
page.session_id = bcode_session_models::SessionId::new();
assert!(!app.accepts_history_page_payload(older, true, &page));
page.session_id = session_id;
page.compatibility_issues
.push(bcode_session_models::SessionEventCompatibilityIssue {
sequence: 7,
event_kind: "future".to_owned(),
schema_version: u16::MAX,
compatibility: bcode_session_models::SessionEventCompatibilityKind::FutureSchema,
remediation: "upgrade Bcode".to_owned(),
});
assert!(!app.accepts_history_page_payload(older, true, &page));
app.replace_transcript_window(&[event(10)], true, true, 10);
let newer = bcode_session_models::SessionHistoryCursor { sequence: 11 };
assert!(app.accepts_history_page_events(newer, false, &[event(12), event(14)], false));
assert!(!app.accepts_history_page_events(newer, false, &[event(10)], true));
assert!(!app.accepts_history_page_events(newer, false, &[event(14), event(12)], true));
drop(app);
}
#[test]
fn replacement_rejects_in_flight_pages_for_same_session() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: format!("message {sequence}"),
},
};
let mut app = super::BmuxApp::new_with_history(Some(session_id), &[event(10)], &[], true);
let generation = app.history_window_generation();
let cursor = app.older_history_cursor().expect("older boundary");
app.set_loading_older_history(true);
assert!(app.accepts_history_page(generation, cursor, true));
app.replace_latest_transcript_window(&[event(10)], true);
app.set_loading_older_history(true);
assert!(!app.accepts_history_page(generation, cursor, true));
assert!(app.accepts_history_page(app.history_window_generation(), cursor, true));
app.replace_transcript_window(&[event(10)], true, true, 10);
assert!(!app.accepts_history_page(generation, cursor, true));
drop(app);
}
#[test]
fn newer_boundary_requests_survive_app_bottom_policy_and_page_arrival() {
let mut app = super::BmuxApp::new_with_history(None, &[], &[], false);
let session_id = bcode_session_models::SessionId::new();
let event = bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 10,
timestamp_ms: 10,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "history".to_owned(),
},
};
app.older_history.replace_centered(&[event], true, true, 10);
app.viewport
.sync_max(20, 9, 30, 10, false, &mut app.older_history);
app.viewport.follow_anchor(18);
assert!(app.scroll_transcript_down(3));
assert_eq!(app.viewport.top_row(30, 10), 20);
assert!(app.should_load_newer_history());
app.older_history.set_loading_newer(true);
assert!(app.scroll_transcript_down(2));
app.viewport
.sync_with_anchor((20, 9, 30, 10), Some(20), 10, 0, &mut app.older_history);
assert_eq!(app.older_history.newer_reveal_request(), Some(3));
app.older_history.update_newer_cursor(&[], false);
app.older_history.set_loading_newer(false);
app.viewport
.sync_with_anchor((30, 9, 40, 10), Some(20), 10, 0, &mut app.older_history);
assert_eq!(app.viewport.top_row(40, 10), 20);
assert_eq!(app.older_history.newer_reveal_request(), None);
drop(app);
}
#[test]
fn uncommitted_retries_do_not_accumulate_hidden_activity() {
let mut app = super::BmuxApp::new_with_history(None, &[], &[], false);
let initial = app.latest_hidden_activity_burst;
app.begin_transcript_presentation();
app.record_latest_hidden_activity(std::time::Instant::now(), 4);
assert!(app.latest_hidden_activity_burst > initial);
app.begin_transcript_presentation();
assert_eq!(app.latest_hidden_activity_burst, initial);
assert!(app.latest_hidden_activity_at.is_none());
drop(app);
}
#[test]
fn manual_scroll_supersedes_uncommitted_navigation() {
let mut app = super::BmuxApp::new_with_history(None, &[], &[], false);
app.viewport
.sync_max(20, 9, 30, 10, false, &mut app.older_history);
app.begin_transcript_presentation();
app.viewport.reveal(true);
assert!(app.scroll_transcript_up(2));
let manual = app.viewport;
app.begin_transcript_presentation();
assert_eq!(app.viewport, manual);
assert!(!app.viewport.allows_reveal());
drop(app);
}
#[test]
fn uncommitted_navigation_retries_do_not_consume_reveal_intent() {
let mut app = super::BmuxApp::new_with_history(None, &[], &[], false);
app.viewport
.sync_max(20, 9, 30, 10, false, &mut app.older_history);
app.viewport.scroll_down(4, &mut app.older_history);
let before = app.viewport;
app.begin_transcript_presentation();
app.viewport
.sync_with_anchor((30, 9, 40, 10), None, 10, 0, &mut app.older_history);
assert!(app.viewport.follows_bottom());
app.begin_transcript_presentation();
assert_eq!(app.viewport, before);
app.viewport
.sync_with_anchor((30, 9, 40, 10), None, 10, 0, &mut app.older_history);
app.commit_transcript_presentation();
app.begin_transcript_presentation();
assert!(app.viewport.follows_bottom());
drop(app);
}
use super::*;
#[test]
fn queue_confirmation_rejects_stale_prepare_and_clears_replaced_outside_window_head() {
use bcode_session_models::*;
let confirmation = SubmissionResumeConfirmation {
version: 1,
submission_id: SubmissionId("pending".to_owned()),
expected_accepted_sequence: 1,
descriptor: SubmissionExecutionDescriptor {
version: 2,
provider: "provider-界".to_owned(),
model: "model-👩💻-e\u{301}".to_owned(),
model_profile: Some("selected profile".to_owned()),
auth_profile: Some("account".to_owned()),
reasoning: None,
config_identity: "digest".to_owned(),
recovery_recipe: None,
},
};
let id = confirmation.submission_id.clone();
let record = SubmissionRecord {
receipt: SubmissionReceipt {
submission_id: id.clone(),
accepted_event_sequence: 1,
},
submission: PromptSubmission {
id,
client_id: ClientId::new(),
text: "not in history".to_owned(),
admission: TurnAdmissionMetadata::default(),
requested_placement: SubmissionPlacement::FollowUp,
target: SubmissionTarget::OwnTurn {
turn_id: TurnId("target".to_owned()),
},
execution_descriptor: None,
},
status: SubmissionStatus::Pending,
context_observation: None,
recovery_authorization: None,
};
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.prepare_queue_confirmation(confirmation.clone());
assert!(app.queue_recovery.confirmation.is_none());
app.set_submission_queue(SubmissionQueueStatus {
oldest: Some(record.clone()),
blocker: Some(SubmissionQueueBlocker::MissingContext),
});
assert!(app.session_view_snapshot().transcript.items.is_empty());
app.prepare_queue_confirmation(confirmation.clone());
assert_eq!(app.queue_recovery.confirmation, Some(confirmation.clone()));
assert!(!app.queue_recovery.settings_visible);
app.queue_recovery.settings_visible = true;
assert_queue_completion_fences(&mut app, &record, &confirmation);
let mut changed = record;
changed.receipt.accepted_event_sequence = 2;
app.set_submission_queue(SubmissionQueueStatus {
oldest: Some(changed),
blocker: Some(SubmissionQueueBlocker::MissingContext),
});
assert!(app.queue_recovery.confirmation.is_none());
assert!(!app.queue_recovery.settings_visible);
app.prepare_queue_confirmation(confirmation);
assert!(app.queue_recovery.confirmation.is_none());
app.set_submission_queue(SubmissionQueueStatus {
oldest: None,
blocker: None,
});
let recovery_absent = app
.session_view_snapshot()
.runtime
.queue_recovery()
.is_none();
drop(app);
assert!(recovery_absent);
}
fn assert_queue_completion_fences(
app: &mut BmuxApp,
record: &bcode_session_models::SubmissionRecord,
confirmation: &bcode_session_models::SubmissionResumeConfirmation,
) {
use bcode_session_models::*;
let before_refresh = app.queue_recovery.generation;
app.set_submission_queue(SubmissionQueueStatus {
oldest: Some(record.clone()),
blocker: Some(SubmissionQueueBlocker::MissingContext),
});
assert!(!app.apply_queue_recovery_result(
before_refresh,
bcode_session_view_models::SessionViewActionOutcome::None,
SubmissionQueueStatus {
oldest: None,
blocker: None
}
));
assert!(
app.session_view_snapshot()
.runtime
.queue_recovery()
.is_some()
);
let generation = app.queue_recovery.generation;
app.dismiss_queue_confirmation();
assert!(!app.apply_queue_recovery_result(
generation,
bcode_session_view_models::SessionViewActionOutcome::SubmissionResumePrepared {
confirmation: confirmation.clone()
},
SubmissionQueueStatus {
oldest: Some(record.clone()),
blocker: Some(SubmissionQueueBlocker::MissingContext)
}
));
assert!(app.queue_recovery.confirmation.is_none());
let generation = app.queue_recovery.generation;
app.set_submission_queue(SubmissionQueueStatus {
oldest: Some(record.clone()),
blocker: Some(SubmissionQueueBlocker::WorkflowAuthorityRequired),
});
assert!(!app.apply_queue_recovery_result(
generation,
bcode_session_view_models::SessionViewActionOutcome::SubmissionResumePrepared {
confirmation: confirmation.clone()
},
SubmissionQueueStatus {
oldest: Some(record.clone()),
blocker: Some(SubmissionQueueBlocker::MissingContext)
}
));
assert!(
!app.session_view_snapshot()
.runtime
.queue_recovery()
.unwrap()
.can_resume
);
app.set_submission_queue_unavailable();
assert!(
app.session_view_snapshot()
.runtime
.submission_queue_unavailable
);
assert!(
app.session_view_snapshot()
.runtime
.queue_recovery()
.is_none()
);
}
use crate::transcript::TranscriptItemKind;
use crate::transcript_layout::{TranscriptLayoutInvalidation, VisibleTranscriptSource};
fn invalidation_deadline(app: &BmuxApp, key: &str, now: Instant) -> Option<Instant> {
app.invalidation_requests(now, SystemTime::UNIX_EPOCH)
.into_iter()
.find(|request| request.key.as_str() == key)
.map(|request| request.at)
}
#[test]
fn local_system_notice_stays_at_its_chronological_insertion_point() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence, text: &str| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: text.to_owned(),
},
};
let mut app =
BmuxApp::new_with_history(Some(session_id), &[event(1, "before")], &[], false);
app.push_ephemeral_system_plain("send failed".to_owned());
app.absorb_session_event(&event(2, "after"));
let transcript = app
.transcript()
.iter()
.map(|item| item.text().to_owned())
.collect::<Vec<_>>();
drop(app);
assert_eq!(transcript, ["before", "send failed", "after"]);
}
#[test]
fn multiple_local_system_notices_keep_chronological_order() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence, text: &str| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: text.to_owned(),
},
};
let mut app = BmuxApp::new_with_history(Some(session_id), &[event(1, "first")], &[], false);
app.push_ephemeral_system_plain("notice one".to_owned());
app.absorb_session_event(&event(2, "second"));
app.push_ephemeral_system_plain("notice two".to_owned());
app.absorb_session_event(&event(3, "third"));
let transcript = app
.transcript()
.iter()
.map(|item| item.text().to_owned())
.collect::<Vec<_>>();
drop(app);
assert_eq!(
transcript,
["first", "notice one", "second", "notice two", "third"]
);
}
#[test]
fn live_plugin_activity_updates_in_place_and_is_removed_when_absent() {
let mut app = BmuxApp::new_with_history(None, &[], &[], true);
let mut status = bcode_session_view_models::PluginStatusView {
plugin_id: "example".into(),
note_id: "execution".into(),
text: "Implementation running".into(),
priority: 20,
metadata: std::collections::BTreeMap::new(),
};
app.set_plugin_status(vec![status.clone()]);
let first = app.transcript.iter().last().unwrap().id();
status.text = "Evaluation waiting".into();
app.set_plugin_status(vec![status]);
assert_eq!(app.transcript.iter().last().unwrap().id(), first);
assert_eq!(
app.transcript.iter().last().unwrap().text(),
"Evaluation waiting"
);
assert_eq!(
app.transcript
.iter()
.filter(|item| item.text() == "Implementation running")
.count(),
0
);
app.set_plugin_status(Vec::new());
let removed = !app.transcript.iter().any(|item| item.id() == first);
drop(app);
assert!(removed);
}
#[test]
fn ephemeral_notice_keeps_chronology_when_older_history_is_prepended() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence, text: &str| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: text.to_owned(),
},
};
let later = event(2, "later");
let mut app =
BmuxApp::new_with_history(Some(session_id), std::slice::from_ref(&later), &[], true);
app.push_ephemeral_system_plain("local issue".to_owned());
app.replace_latest_transcript_window(&[event(1, "earlier"), later], false);
let transcript = app
.transcript()
.iter()
.map(|item| item.text().to_owned())
.collect::<Vec<_>>();
drop(app);
assert_eq!(transcript, ["earlier", "later", "local issue"]);
}
#[test]
fn ephemeral_notice_has_deterministic_placement_outside_resident_window() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence, text: &str| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: text.to_owned(),
},
};
let mut before =
BmuxApp::new_with_history(Some(session_id), &[event(5, "five")], &[], false);
before.push_ephemeral_system_plain("local issue".to_owned());
before.replace_transcript_window(&[event(8, "eight")], true, true, 8);
let transcript = before
.transcript()
.iter()
.map(|item| item.text().to_owned())
.collect::<Vec<_>>();
drop(before);
assert_eq!(transcript, ["local issue", "eight"]);
let mut after =
BmuxApp::new_with_history(Some(session_id), &[event(5, "five")], &[], false);
after.push_ephemeral_system_plain("local issue".to_owned());
after.replace_transcript_window(&[event(2, "two")], true, true, 2);
let transcript = after
.transcript()
.iter()
.map(|item| item.text().to_owned())
.collect::<Vec<_>>();
drop(after);
assert_eq!(transcript, ["two", "local issue"]);
}
#[test]
fn ephemeral_notice_uses_sequence_fallback_when_exact_anchor_leaves_window() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence, text: &str| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: text.to_owned(),
},
};
let mut app = BmuxApp::new_with_history(
Some(session_id),
&[event(1, "one"), event(2, "two")],
&[],
false,
);
app.push_ephemeral_system_plain("local issue".to_owned());
app.replace_latest_transcript_window(&[event(1, "one"), event(3, "three")], false);
let transcript = app
.transcript()
.iter()
.map(|item| item.text().to_owned())
.collect::<Vec<_>>();
drop(app);
assert_eq!(transcript, ["one", "local issue", "three"]);
}
#[test]
fn sequence_lookup_returns_visible_index_across_ephemeral_notice() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence, text: &str| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: text.to_owned(),
},
};
let mut app = BmuxApp::new_with_history(Some(session_id), &[event(1, "one")], &[], false);
app.push_ephemeral_system_plain("local issue".to_owned());
app.absorb_session_event(&event(2, "two"));
let index = app.transcript_index_for_sequence(2);
drop(app);
assert_eq!(index, Some(2));
}
#[test]
fn ephemeral_notice_has_explicit_local_provenance() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.push_ephemeral_system_plain("local issue".to_owned());
let has_local_provenance = matches!(
app.transcript.origin(0),
Some(TranscriptPresentationOrigin::Ephemeral { source, .. })
if source == "bcode.tui"
);
let canonical_transcript_is_empty = app.session_view_snapshot().transcript.items.is_empty();
drop(app);
assert!(has_local_provenance);
assert!(canonical_transcript_is_empty);
}
#[test]
fn same_session_reconstruction_preserves_ephemeral_notices() {
let session_id = bcode_session_models::SessionId::new();
let history = [bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 1,
timestamp_ms: 1,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "before".to_owned(),
},
}];
let mut source = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
source.push_ephemeral_system_plain("local issue".to_owned());
let mut reconstructed = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
reconstructed.take_same_session_transcript_state_from(&source);
drop(source);
let transcript = reconstructed
.transcript()
.iter()
.map(|item| item.text().to_owned())
.collect::<Vec<_>>();
drop(reconstructed);
assert_eq!(transcript, ["before", "local issue"]);
}
#[test]
fn cross_session_reconstruction_does_not_transfer_ephemeral_notices() {
let mut source = BmuxApp::new_with_history(
Some(bcode_session_models::SessionId::new()),
&[],
&[],
false,
);
source.push_ephemeral_system_plain("local issue".to_owned());
let mut reconstructed = BmuxApp::new_with_history(
Some(bcode_session_models::SessionId::new()),
&[],
&[],
false,
);
reconstructed.take_same_session_transcript_state_from(&source);
drop(source);
let is_empty = reconstructed.transcript().is_empty();
drop(reconstructed);
assert!(is_empty);
}
#[test]
fn sessionless_draft_notice_does_not_transfer_into_opened_session() {
let mut draft = BmuxApp::new_with_history(None, &[], &[], false);
draft.push_ephemeral_system_plain("draft issue".to_owned());
let mut opened = BmuxApp::new_with_history(
Some(bcode_session_models::SessionId::new()),
&[],
&[],
false,
);
opened.take_same_session_transcript_state_from(&draft);
drop(draft);
let is_empty = opened.transcript().is_empty();
drop(opened);
assert!(is_empty);
}
#[test]
fn reconstructed_canonical_history_does_not_recreate_ephemeral_notice() {
let session_id = bcode_session_models::SessionId::new();
let history = [bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 1,
timestamp_ms: 1,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "canonical".to_owned(),
},
}];
let mut live = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
live.push_ephemeral_system_plain("local issue".to_owned());
let live_transcript = live
.transcript()
.iter()
.map(|item| item.text().to_owned())
.collect::<Vec<_>>();
drop(live);
assert_eq!(live_transcript, ["canonical", "local issue"]);
let reconstructed = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
let transcript = reconstructed
.transcript()
.iter()
.map(|item| item.text().to_owned())
.collect::<Vec<_>>();
drop(reconstructed);
assert_eq!(transcript, ["canonical"]);
}
#[test]
fn reopening_the_attached_session_preserves_notices_through_the_intermediate_app() {
let session_id = bcode_session_models::SessionId::new();
let history = [bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 1,
timestamp_ms: 1,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "canonical".to_owned(),
},
}];
let mut attached = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
attached.push_ephemeral_system_plain("local issue".to_owned());
let mut intermediate = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
intermediate.take_same_session_transcript_state_from(&attached);
drop(attached);
assert_eq!(
intermediate
.transcript()
.iter()
.map(TranscriptItem::text)
.collect::<Vec<_>>(),
["local issue"]
);
let mut completed = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
completed.take_same_session_transcript_state_from(&intermediate);
drop(intermediate);
let transcript = completed
.transcript()
.iter()
.map(|item| item.text().to_owned())
.collect::<Vec<_>>();
drop(completed);
assert_eq!(
transcript,
["canonical", "local issue"],
"the notice survives the full reopen chain and reanchors to canonical history"
);
}
#[test]
fn ephemeral_notices_stay_out_of_every_canonical_and_context_bearing_projection() {
let session_id = bcode_session_models::SessionId::new();
let history = [bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 1,
timestamp_ms: 1,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "canonical".to_owned(),
},
}];
let mut app = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
app.push_ephemeral_system_plain("local issue".to_owned());
assert_eq!(app.transcript().len(), 2);
let snapshot = app.session_view_snapshot();
assert_eq!(snapshot.transcript.items.len(), 1);
assert!(
!snapshot.transcript.items.iter().any(|item| matches!(
&item.kind,
bcode_session_view_models::TranscriptViewItemKind::SystemMessage { message }
if message.text.contains("local issue")
)),
"no canonical transcript item carries the ephemeral notice text"
);
let notice = app
.transcript()
.iter()
.find(|item| item.text() == "local issue")
.expect("notice");
assert!(notice.source_view_item_id().is_none());
assert!(notice.event_sequence().is_none());
let canonical_index = app.transcript_index_for_sequence(1);
drop(app);
assert_eq!(canonical_index, Some(0));
}
#[test]
fn durable_presentation_notes_replay_as_canonical_entries_beside_ephemeral_notices() {
let session_id = bcode_session_models::SessionId::new();
let durable_note = bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 1,
timestamp_ms: 1,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::PluginStatusNote {
plugin_id: "bcode.tui".to_owned(),
note_id: "note-1".to_owned(),
text: "durable note".to_owned(),
metadata: std::collections::BTreeMap::from([(
"presentation_only".to_owned(),
serde_json::Value::Bool(true),
)]),
},
};
let mut app = BmuxApp::new_with_history(
Some(session_id),
std::slice::from_ref(&durable_note),
&[],
false,
);
app.push_ephemeral_system_plain("local issue".to_owned());
assert_eq!(
app.transcript()
.iter()
.map(TranscriptItem::text)
.collect::<Vec<_>>(),
["durable note", "local issue"]
);
assert!(matches!(
app.transcript.origin(0),
Some(TranscriptPresentationOrigin::Canonical { .. })
));
assert_eq!(app.transcript()[0].event_sequence(), Some(1));
assert!(matches!(
app.transcript.origin(1),
Some(TranscriptPresentationOrigin::Ephemeral { .. })
));
assert_eq!(app.session_view_snapshot().transcript.items.len(), 1);
drop(app);
let replayed = BmuxApp::new_with_history(
Some(session_id),
std::slice::from_ref(&durable_note),
&[],
false,
);
let transcript = replayed
.transcript()
.iter()
.map(|item| item.text().to_owned())
.collect::<Vec<_>>();
drop(replayed);
assert_eq!(transcript, ["durable note"]);
}
#[test]
fn theme_changes_invalidate_presentation_without_mutating_session_projection() {
let session_id = bcode_session_models::SessionId::new();
let history = [bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 1,
timestamp_ms: 1,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "# Stable history".to_owned(),
},
}];
let mut app = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
let transcript_revision = app.transcript_projection_revision();
let markdown_revision = app.markdown_presentation_revision();
let transcript_before = app
.transcript()
.iter()
.map(|item| (item.id(), item.text().to_owned()))
.collect::<Vec<_>>();
let fingerprint_before = app.presented_theme().fingerprint;
assert!(app.apply_theme("bcode-dark"));
assert_ne!(app.presented_theme().fingerprint, fingerprint_before);
assert_eq!(app.transcript_projection_revision(), transcript_revision);
assert_eq!(app.markdown_presentation_revision(), markdown_revision);
let transcript_after = app
.transcript()
.iter()
.map(|item| (item.id(), item.text().to_owned()))
.collect::<Vec<_>>();
drop(app);
assert_eq!(transcript_after, transcript_before);
}
#[test]
fn runtime_refresh_preserves_unsent_agent_selection() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_current_agent_id("plan");
app.set_pending_agent("build", None);
app.apply_runtime_selection(
bcode_ipc::SessionRuntimeSelection {
agent_id: Some("plan".to_owned()),
..Default::default()
},
Some("#6b7280".to_owned()),
);
assert_eq!(app.current_agent_accent(), Some("#6b7280"));
assert_eq!(app.current_agent_id(), "plan");
assert_eq!(app.display_agent_id(), "build");
app.set_pending_agent("review", None);
app.commit_submitted_agent("build");
assert_eq!(app.current_agent_id(), "build");
assert_eq!(app.display_agent_id(), "review");
app.commit_submitted_agent("review");
assert_eq!(app.current_agent_id(), "review");
assert_eq!(app.pending_agent_id(), None);
drop(app);
}
#[test]
fn theme_transition_deadline_is_stable_until_the_frame_is_handled() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let immediate = TuiConfig {
theme: TuiThemeConfig {
accent_transition: bcode_config::TuiAccentTransitionMode::Immediate,
..TuiThemeConfig::default()
},
..TuiConfig::default()
};
app.apply_tui_config(immediate);
app.set_current_agent("plan", Some("#6b7280".to_owned()));
app.apply_tui_config(TuiConfig {
theme: TuiThemeConfig {
accent_transition: bcode_config::TuiAccentTransitionMode::Transition,
accent_transition_ms: 100,
..TuiThemeConfig::default()
},
..TuiConfig::default()
});
app.set_current_agent("build", Some("#22d3ee".to_owned()));
let queried_at = Instant::now();
let first_deadline =
invalidation_deadline(&app, THEME_TRANSITION_INVALIDATION_KEY, queried_at)
.expect("transition should request a frame");
assert_eq!(
invalidation_deadline(
&app,
THEME_TRANSITION_INVALIDATION_KEY,
queried_at + Duration::from_millis(10),
),
Some(first_deadline),
"unrelated scheduling passes must not postpone the pending frame"
);
app.handle_invalidations(
&[InvalidationKey::new(THEME_TRANSITION_INVALIDATION_KEY)],
first_deadline,
);
let next_deadline =
invalidation_deadline(&app, THEME_TRANSITION_INVALIDATION_KEY, first_deadline)
.expect("active transition should request its next frame");
assert!(next_deadline > first_deadline);
app.handle_invalidations(
&[InvalidationKey::new(THEME_TRANSITION_INVALIDATION_KEY)],
queried_at + Duration::from_millis(200),
);
assert_eq!(app.display_agent_id(), "build");
assert_eq!(app.presented_theme().accent, Color::Rgb(34, 211, 238));
let final_deadline = invalidation_deadline(
&app,
THEME_TRANSITION_INVALIDATION_KEY,
queried_at + Duration::from_millis(200),
);
drop(app);
assert_eq!(final_deadline, None);
}
#[test]
fn external_schema_v1_theme_lifecycle_remains_compatible() {
let temp = tempfile::tempdir().expect("theme root");
let path = temp.path().join("external.toml");
std::fs::write(
&path,
"schema_version = 1\nid = \"external\"\nextends = [\"terminal-native\"]\n[palette]\naccent = \"#112233\"\n[styles.\"surface.raised\"]\nbg = \"#223344\"\n",
)
.expect("external theme");
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let mut config = app.tui_config().clone();
config.theme.paths = vec![temp.path().to_path_buf()];
config.theme.accent_transition = bcode_config::TuiAccentTransitionMode::Immediate;
app.apply_tui_config(config);
let configured = app.presented_theme();
let catalog = crate::theme::catalog_view(&mut app);
let external = catalog
.entries
.iter()
.find(|entry| entry.id == "external")
.expect("external theme discovered");
assert_eq!(external.source, "explicit");
assert_eq!(external.validation, "valid");
assert!(app.preview_theme("external"));
assert_ne!(app.presented_theme().fingerprint, configured.fingerprint);
app.cancel_theme_preview();
assert_eq!(app.presented_theme().fingerprint, configured.fingerprint);
assert!(app.apply_theme("external"));
let valid = app.presented_theme();
assert_eq!(app.tui_config().theme.name, "external");
std::fs::write(&path, "schema_version = 1\nid = [broken").expect("invalid revision");
assert!(app.reload_theme_if_valid().is_none());
assert_eq!(app.presented_theme(), valid);
let diagnostics = crate::theme::catalog_view(&mut app).diagnostics;
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].contains("external.toml"));
std::fs::write(
&path,
"schema_version = 1\nid = \"external\"\nextends = [\"terminal-native\"]\n[palette]\naccent = \"#445566\"\n[styles.\"surface.raised\"]\nbg = \"#334455\"\n",
)
.expect("corrected revision");
assert_eq!(app.reload_theme_if_valid(), Some("external"));
let fingerprint = app.presented_theme().fingerprint;
drop(app);
assert_ne!(fingerprint, valid.fingerprint);
}
#[test]
fn invalidated_preview_can_restore_configured_theme() {
let temp = tempfile::tempdir().expect("theme root");
let path = temp.path().join("preview.toml");
std::fs::write(
&path,
"schema_version = 1\nid = \"preview\"\n[palette]\naccent = \"#112233\"\n",
)
.expect("preview theme");
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let mut config = app.tui_config().clone();
config.theme.paths = vec![temp.path().to_path_buf()];
app.apply_tui_config(config);
let configured = app.presented_theme();
assert!(app.preview_theme("preview"));
assert_ne!(app.presented_theme(), configured);
std::fs::remove_file(path).expect("preview disappears");
assert!(app.reload_theme_if_valid().is_none());
app.cancel_theme_preview();
let fingerprint = app.presented_theme().fingerprint;
drop(app);
assert_eq!(fingerprint, configured.fingerprint);
}
#[test]
fn theme_reload_retains_last_valid_then_applies_next_valid_revision() {
let temp = tempfile::tempdir().expect("theme root");
let path = temp.path().join("live.toml");
std::fs::write(
&path,
"schema_version = 1\nid = \"live\"\n[palette]\naccent = \"#112233\"\n[styles.\"text.primary\"]\nfg = \"#112233\"\n",
)
.expect("initial theme");
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let mut config = app.tui_config().clone();
config.theme.name = "live".to_owned();
config.theme.paths = vec![temp.path().to_path_buf()];
app.apply_tui_config(config);
let valid = app.presented_theme();
std::fs::write(&path, "not = [valid").expect("invalid transient edit");
assert!(app.reload_theme_if_valid().is_none());
assert_eq!(app.presented_theme(), valid);
std::fs::write(
&path,
"schema_version = 1\nid = \"live\"\n[palette]\naccent = \"#445566\"\n[styles.\"text.primary\"]\nfg = \"#445566\"\n",
)
.expect("next valid theme");
assert_eq!(app.reload_theme_if_valid(), Some("live"));
let fingerprint = app.presented_theme().fingerprint;
drop(app);
assert_ne!(fingerprint, valid.fingerprint);
}
#[test]
fn catalog_view_preserves_rejected_candidate_diagnostics() {
let temp = tempfile::tempdir().expect("theme root");
std::fs::write(temp.path().join("broken.toml"), "not = [valid").expect("broken theme");
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let mut config = app.tui_config().clone();
config.theme.paths = vec![temp.path().to_path_buf()];
app.apply_tui_config(config);
let catalog = crate::theme::catalog_view(&mut app);
drop(app);
assert!(!catalog.entries.is_empty());
assert_eq!(catalog.diagnostics.len(), 1);
assert!(catalog.diagnostics[0].contains("broken.toml"));
}
#[test]
fn theme_catalog_entries_include_external_origin_and_current_selection() {
let temp = tempfile::tempdir().expect("theme root");
std::fs::write(
temp.path().join("custom.toml"),
"schema_version = 1\nid = \"custom\"\ndisplay_name = \"Custom\"\n[palette]\naccent = \"#112233\"\n",
)
.expect("external theme");
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let mut config = app.tui_config().clone();
config.theme.name = "custom".to_owned();
config.theme.paths = vec![temp.path().to_path_buf()];
app.apply_tui_config(config);
let entries = crate::theme::catalog_view(&mut app).entries;
drop(app);
let custom = entries
.iter()
.find(|entry| entry.id == "custom")
.expect("custom catalog entry");
assert_eq!(custom.source, "explicit");
assert!(custom.selected);
assert_eq!(custom.display_name, "Custom");
}
#[test]
fn repeated_theme_previews_reuse_one_discovered_catalog() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
assert!(app.preview_theme("bcode-dark"));
assert!(app.preview_theme("nord"));
assert!(app.preview_theme("terminal-native"));
let discovery_count = app.theme_catalog_discovery_count();
drop(app);
assert_eq!(discovery_count, 1);
}
#[test]
fn theme_preview_apply_and_cancel_are_in_memory_transitions() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let configured = app.tui_config().theme.name.clone();
let initial = app.presented_theme();
assert!(app.preview_theme("bcode-dark"));
assert_eq!(app.tui_config().theme.name, configured);
assert_ne!(app.presented_theme(), initial);
app.cancel_theme_preview();
assert_eq!(app.presented_theme(), initial);
assert_eq!(app.tui_config().theme.name, configured);
assert!(app.apply_theme("bcode-light"));
assert_eq!(app.tui_config().theme.name, "bcode-light");
let unknown_theme_accepted = app.preview_theme("does-not-exist");
drop(app);
assert!(!unknown_theme_accepted);
}
#[test]
fn effective_keymap_replacement_invalidates_cached_rows_once() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_pending_interactions(vec![interaction("keymap")]);
crate::transcript_projection::sync_layout_for_test(&mut app, 80);
assert!(app.transcript_layout().total_rows() > 0);
let before = app.elapsed_layout_revision();
let registry = crate::keymap::BmuxKeyMap::from_config(&bcode_config::TuiConfig::default())
.registry()
.clone();
app.set_effective_keymap(registry.clone());
assert_eq!(app.transcript_layout().total_rows(), 0);
assert_eq!(app.elapsed_layout_revision(), before + 1);
crate::transcript_projection::sync_layout_for_test(&mut app, 80);
let rows = app.transcript_layout().total_rows();
assert!(rows > 0);
app.set_effective_keymap(registry);
assert_eq!(app.transcript_layout().total_rows(), rows);
assert_eq!(app.elapsed_layout_revision(), before + 1);
let mut config = bcode_config::TuiConfig::default();
config.keybindings.scopes.insert(
"chat".into(),
std::collections::BTreeMap::from([("not-a-valid-key".into(), "app.exit".into())]),
);
let invalid = crate::keymap::BmuxKeyMap::from_config(&config);
assert!(invalid.error().is_some());
app.set_effective_keymap(invalid.registry().clone());
assert_eq!(app.transcript_layout().total_rows(), 0);
assert_eq!(
app.key_hints,
"unbound send · unbound interrupt · unbound exit · unbound palette"
);
assert_eq!(
app.effective_keymap()
.action_for_key("chat", bmux_keyboard::parse_key_stroke("enter").unwrap(),),
None,
);
for width in [0, 1, 2, 80] {
crate::transcript_projection::sync_layout_for_test(&mut app, width);
}
drop(app);
}
#[test]
fn active_interaction_row_range_tracks_lifecycle_height_changes() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_pending_interactions(vec![interaction("first")]);
let index = app.interaction_transcript_index("first").expect("first");
crate::transcript_projection::sync_layout_for_test(&mut app, 80);
let fallback = app
.transcript_layout()
.entry_row_count(VisibleTranscriptSource::Transcript, index)
.expect("fallback rows");
for height in [9, 10, 14, 21, 7] {
app.set_active_interaction_layout(Some(("first".to_owned(), height)));
crate::transcript_projection::sync_layout_for_test(&mut app, 80);
assert_eq!(
app.transcript_item_row_range(index)
.expect("interaction row range")
.len(),
usize::from(height)
);
}
app.set_active_interaction_layout(None);
crate::transcript_projection::sync_layout_for_test(&mut app, 80);
let restored_height = app
.transcript_item_row_range(index)
.expect("restored fallback range")
.len();
drop(app);
assert_eq!(restored_height, fallback);
}
#[test]
fn active_interaction_layout_replaces_cached_fallback_rows_incrementally() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_pending_interactions(vec![interaction("first"), interaction("second")]);
let first = app.interaction_transcript_index("first").expect("first");
let second = app.interaction_transcript_index("second").expect("second");
crate::transcript_projection::sync_layout_for_test(&mut app, 80);
let fallback_first = app
.transcript_layout()
.entry_row_count(VisibleTranscriptSource::Transcript, first)
.expect("fallback first rows");
let fallback_second = app
.transcript_layout()
.entry_row_count(VisibleTranscriptSource::Transcript, second)
.expect("fallback second rows");
app.transcript_layout_mut().drain_sync_stats();
app.set_active_interaction_layout(Some(("first".to_owned(), 17)));
crate::transcript_projection::sync_layout_for_test(&mut app, 80);
assert_eq!(
app.transcript_layout()
.entry_row_count(VisibleTranscriptSource::Transcript, first),
Some(17)
);
assert_eq!(
app.transcript_layout()
.entry_row_count(VisibleTranscriptSource::Transcript, second),
Some(fallback_second)
);
assert_ne!(fallback_first, 17);
let stats = app.transcript_layout_mut().drain_sync_stats();
drop(app);
assert_eq!(stats.len(), 1);
assert_eq!(
stats[0].invalidation,
TranscriptLayoutInvalidation::Incremental
);
assert_eq!(stats[0].entries_scanned, 1);
assert_eq!(stats[0].entries_rebuilt, 1);
assert_eq!(stats[0].rows_regenerated, 17);
}
#[test]
fn active_interaction_layout_marks_only_previous_and_next_entries_dirty() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_pending_interactions(vec![interaction("first"), interaction("second")]);
let first = app.interaction_transcript_index("first").expect("first");
let second = app.interaction_transcript_index("second").expect("second");
let first_id = app.transcript.presentation_id(first).expect("first id");
let second_id = app.transcript.presentation_id(second).expect("second id");
app.set_active_interaction_layout(Some(("first".to_owned(), 5)));
assert_eq!(
app.transcript_dirty_items_for_test(),
&BTreeSet::from([first_id])
);
app.drain_transcript_dirty_items();
app.set_active_interaction_layout(Some(("second".to_owned(), 7)));
let dirty_items = app.transcript_dirty_items_for_test().clone();
drop(app);
assert_eq!(dirty_items, BTreeSet::from([first_id, second_id]));
}
#[test]
fn dirty_presentation_identity_resolves_after_earlier_insertion() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence, text: &str| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: text.to_owned(),
},
};
let later = event(2, "later");
let mut app =
BmuxApp::new_with_history(Some(session_id), std::slice::from_ref(&later), &[], true);
app.mark_transcript_item_dirty(0);
app.replace_latest_transcript_window(&[event(1, "earlier"), later], false);
assert_eq!(app.drain_transcript_dirty_items(), BTreeSet::from([1]));
let text = app.transcript()[1].text().to_owned();
drop(app);
assert_eq!(text, "later");
}
fn interaction(id: &str) -> bcode_session_view_models::InteractionViewSummary {
bcode_session_view_models::InteractionViewSummary {
interaction_id: id.to_owned(),
producer_id: Some("example.plugin".to_owned()),
exchange_schema: Some("example.request".to_owned()),
exchange_schema_version: Some(1),
kind: "example.interaction".to_owned(),
tool_call_id: Some(format!("call-{id}")),
title: Some(id.to_owned()),
required: true,
snapshot: None,
state: bcode_session_view_models::InteractionViewState::Pending,
status_detail: None,
resolved: false,
resolution: None,
}
}
fn snapshot(
estimated: bool,
input_tokens: u64,
) -> bcode_session_models::RequestContextObservation {
bcode_session_models::RequestContextObservation {
request: bcode_session_models::ModelRequestIdentity {
provider_plugin_id: "provider".to_string(),
requested_model_id: None,
effective_model_id: "effective-model".to_string(),
request_id: "request".to_string(),
model_turn_id: "turn".to_string(),
round: 0,
request_fingerprint: "fingerprint".to_string(),
effective_auth_profile: Some("routed-profile".to_string()),
context_format_version: None,
compatibility_key: None,
context_epoch: 3,
},
context_through_sequence: 1,
context_tokens: if estimated {
bcode_session_models::RequestContextTokenCount::Estimated(input_tokens)
} else {
bcode_session_models::RequestContextTokenCount::ProviderExact(input_tokens)
},
local_estimate: bcode_session_models::LocalContextEstimate {
tokens: input_tokens,
algorithm_version: 1,
},
}
}
fn markdown_details(
id: &str,
default_open: bool,
) -> crate::markdown_interaction::VisibleMarkdownContribution {
crate::markdown_interaction::VisibleMarkdownContribution {
id: id.to_owned(),
kind: bcode_markdown_render::MarkdownContributionKind::Details {
summary: "More".to_owned(),
body: "Body".to_owned(),
default_open,
},
}
}
#[test]
fn fragment_activation_navigates_to_typed_resident_anchor() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_transcript_viewport_for_details_test(80, 10, 0);
app.reconcile_markdown_fragments(BTreeMap::from([("target".to_owned(), 24)]));
app.reconcile_markdown_interactions(vec![
crate::markdown_interaction::VisibleMarkdownContribution {
id: "fragment-link".to_owned(),
kind: bcode_markdown_render::MarkdownContributionKind::Link {
link: bcode_markdown_render::MarkdownLink {
destination: "#target".to_owned(),
title: None,
reference_id: None,
kind: bcode_markdown_render::MarkdownLinkKind::Inline,
},
label: "Target".to_owned(),
destination: bcode_markdown_render::MarkdownDestination::Fragment(
"target".to_owned(),
),
},
},
]);
assert!(app.activate_markdown_contribution("fragment-link"));
assert_eq!(app.transcript_viewport_top_row_for_test(80, 10), 24);
let status = app.status().to_owned();
drop(app);
assert_eq!(status, "Navigated to Markdown section");
}
#[test]
fn fragment_navigation_remains_stable_after_resize_and_scroll() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_transcript_viewport_for_details_test(100, 12, 20);
app.reconcile_markdown_fragments(BTreeMap::from([("target".to_owned(), 38)]));
app.reconcile_markdown_interactions(vec![
crate::markdown_interaction::VisibleMarkdownContribution {
id: "fragment-link".to_owned(),
kind: bcode_markdown_render::MarkdownContributionKind::Link {
link: bcode_markdown_render::MarkdownLink {
destination: "#target".to_owned(),
title: None,
reference_id: None,
kind: bcode_markdown_render::MarkdownLinkKind::Inline,
},
label: "Target".to_owned(),
destination: bcode_markdown_render::MarkdownDestination::Fragment(
"target".to_owned(),
),
},
},
]);
assert!(app.activate_markdown_contribution("fragment-link"));
assert_eq!(app.transcript_viewport_top_row_for_test(100, 12), 38);
app.sync_transcript_scroll_max(94, 0, 100, 6);
assert_eq!(app.transcript_viewport_top_row_for_test(100, 6), 38);
assert!(app.scroll_transcript_down(3));
assert_eq!(app.transcript_viewport_top_row_for_test(100, 6), 41);
assert!(app.activate_markdown_contribution("fragment-link"));
let top_row = app.transcript_viewport_top_row_for_test(100, 6);
drop(app);
assert_eq!(top_row, 38);
}
#[test]
fn missing_fragment_remains_safe_and_reports_failure() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.reconcile_markdown_fragments(BTreeMap::new());
app.reconcile_markdown_interactions(vec![
crate::markdown_interaction::VisibleMarkdownContribution {
id: "fragment-link".to_owned(),
kind: bcode_markdown_render::MarkdownContributionKind::Link {
link: bcode_markdown_render::MarkdownLink {
destination: "#missing".to_owned(),
title: None,
reference_id: None,
kind: bcode_markdown_render::MarkdownLinkKind::Inline,
},
label: "Missing".to_owned(),
destination: bcode_markdown_render::MarkdownDestination::Fragment(
"missing".to_owned(),
),
},
},
]);
assert!(app.activate_markdown_contribution("fragment-link"));
let status = app.status().to_owned();
drop(app);
assert_eq!(status, "Markdown fragment target was not found");
}
#[test]
fn details_activation_preserves_detached_transcript_top_row_across_height_change() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_transcript_viewport_for_details_test(40, 10, 12);
let top_row = app.transcript_top_row(10);
app.reconcile_markdown_interactions(vec![markdown_details("details", false)]);
assert!(app.activate_markdown_contribution("details"));
app.sync_transcript_scroll_max(37, 0, 47, 10);
assert_eq!(app.transcript_top_row(10), top_row);
let scroll_offset = app.scroll_offset();
drop(app);
assert_eq!(scroll_offset, 47 - top_row - 10);
}
#[test]
fn markdown_capability_changes_invalidate_presentation_layout() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let initial = app.markdown_presentation_revision();
let mut config = app.tui_config().clone();
config.markdown.network_images = true;
app.apply_tui_config(config.clone());
assert_eq!(app.markdown_presentation_revision(), initial + 1);
app.apply_tui_config(config);
let revision = app.markdown_presentation_revision();
drop(app);
assert_eq!(revision, initial + 1);
}
#[test]
fn details_activation_toggles_from_source_default_and_updates_layout_revision() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.reconcile_markdown_interactions(vec![markdown_details("details", true)]);
assert_eq!(app.markdown_details_open().get("details"), None);
assert_eq!(app.markdown_presentation_revision(), 0);
assert!(app.activate_markdown_contribution("details"));
assert_eq!(app.markdown_details_open().get("details"), Some(&false));
assert_eq!(app.markdown_presentation_revision(), 1);
assert!(app.focus_markdown_contribution("details"));
assert!(app.activate_focused_markdown_contribution());
assert_eq!(app.markdown_details_open().get("details"), Some(&true));
let revision = app.markdown_presentation_revision();
drop(app);
assert_eq!(revision, 2);
}
#[test]
fn details_state_survives_visibility_changes_and_is_removed_with_resident_owner() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.reconcile_markdown_interactions(vec![markdown_details("details", false)]);
assert!(app.activate_markdown_contribution("details"));
app.reconcile_markdown_interactions(Vec::new());
assert_eq!(app.markdown_details_open().get("details"), Some(&true));
app.reconcile_markdown_details(&BTreeSet::from(["details".to_owned()]));
assert_eq!(app.markdown_details_open().get("details"), Some(&true));
app.reconcile_markdown_details(&BTreeSet::new());
assert!(app.markdown_details_open().is_empty());
let revision = app.markdown_presentation_revision();
drop(app);
assert_eq!(revision, 2);
}
fn markdown_footnote_reference(
id: &str,
target_id: &str,
) -> crate::markdown_interaction::VisibleMarkdownContribution {
crate::markdown_interaction::VisibleMarkdownContribution {
id: id.to_owned(),
kind: bcode_markdown_render::MarkdownContributionKind::FootnoteReference {
label: "note".to_owned(),
target_id: target_id.to_owned(),
},
}
}
fn markdown_footnote_definition(
id: &str,
reference_ids: &[&str],
) -> crate::markdown_interaction::VisibleMarkdownContribution {
crate::markdown_interaction::VisibleMarkdownContribution {
id: id.to_owned(),
kind: bcode_markdown_render::MarkdownContributionKind::FootnoteDefinition {
label: "note".to_owned(),
reference_ids: reference_ids
.iter()
.map(|reference| (*reference).to_owned())
.collect(),
},
}
}
#[test]
fn offscreen_footnote_navigation_scrolls_and_focuses_after_reconciliation() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_transcript_viewport_for_details_test(80, 10, 0);
app.reconcile_markdown_footnote_rows(BTreeMap::from([
("reference".to_owned(), 4),
("definition".to_owned(), 30),
]));
app.reconcile_markdown_interactions(vec![markdown_footnote_reference(
"reference",
"definition",
)]);
assert!(app.activate_markdown_contribution("reference"));
assert_eq!(app.transcript_viewport_top_row_for_test(80, 10), 30);
assert_eq!(app.focused_markdown_contribution(), None);
app.reconcile_markdown_interactions(vec![markdown_footnote_definition(
"definition",
&["reference"],
)]);
assert_eq!(app.focused_markdown_contribution(), Some("definition"));
assert!(app.activate_focused_markdown_contribution());
assert_eq!(app.transcript_viewport_top_row_for_test(80, 10), 4);
app.reconcile_markdown_interactions(vec![markdown_footnote_reference(
"reference",
"definition",
)]);
let focused = app.focused_markdown_contribution().map(str::to_owned);
drop(app);
assert_eq!(focused.as_deref(), Some("reference"));
}
#[test]
fn footnote_activation_returns_to_the_reference_that_navigated_to_definition() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.reconcile_markdown_interactions(vec![
markdown_footnote_reference("reference-1", "definition"),
markdown_footnote_reference("reference-2", "definition"),
markdown_footnote_definition("definition", &["reference-1", "reference-2"]),
]);
assert!(app.activate_markdown_contribution("reference-2"));
assert_eq!(app.focused_markdown_contribution(), Some("definition"));
assert!(app.activate_focused_markdown_contribution());
assert_eq!(app.focused_markdown_contribution(), Some("reference-2"));
let return_targets_empty = app.footnote_return_targets.is_empty();
drop(app);
assert!(return_targets_empty);
}
#[test]
fn footnote_definition_uses_first_reference_without_a_navigation_origin() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.reconcile_markdown_interactions(vec![
markdown_footnote_reference("reference-1", "definition"),
markdown_footnote_reference("reference-2", "definition"),
markdown_footnote_definition("definition", &["reference-1", "reference-2"]),
]);
assert!(app.activate_markdown_contribution("definition"));
let focused = app.focused_markdown_contribution().map(str::to_owned);
drop(app);
assert_eq!(focused.as_deref(), Some("reference-1"));
}
#[test]
fn footnote_return_origin_is_removed_with_invisible_contributions() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.reconcile_markdown_interactions(vec![
markdown_footnote_reference("reference", "definition"),
markdown_footnote_definition("definition", &["reference"]),
]);
assert!(app.activate_markdown_contribution("reference"));
assert_eq!(app.footnote_return_targets.len(), 1);
app.reconcile_markdown_interactions(Vec::new());
assert!(app.footnote_return_targets.is_empty());
let focused = app.focused_markdown_contribution().map(str::to_owned);
drop(app);
assert_eq!(focused, None);
}
#[test]
fn animation_deadlines_catch_up_from_monotonic_origin() {
let started_at = Instant::now();
let mut scroll = TranscriptScrollAnimation::new(0, 10, started_at);
let delayed = started_at + Duration::from_millis(73);
scroll.advance_frame(delayed);
assert_eq!(scroll.next_frame_at, started_at + Duration::from_millis(80));
assert!(scroll.next_frame_at > delayed);
assert_eq!(
next_latest_bar_frame_at(started_at, delayed, Duration::from_millis(40)),
started_at + Duration::from_millis(80)
);
}
#[test]
fn activity_presentation_changes_only_on_accepted_ticks() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_activity(ActivityState::PreparingModelRequest);
let now = app.activity_started_at + Duration::from_millis(250);
assert_eq!(app.activity_elapsed(), Duration::ZERO);
let frame = app.activity_frame();
app.handle_invalidations_with_damage(
&[InvalidationKey::new(ACTIVITY_ANIMATION_INVALIDATION_KEY)],
now,
);
assert_eq!(app.activity_elapsed(), Duration::from_millis(250));
assert_eq!(app.activity_frame(), frame + 1);
app.set_activity(ActivityState::Idle);
app.set_activity(ActivityState::PreparingModelRequest);
assert_eq!(app.activity_elapsed(), Duration::ZERO);
drop(app);
}
#[test]
fn temporal_invalidations_report_precise_animation_damage() {
let now = Instant::now();
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_activity(ActivityState::PreparingModelRequest);
let (activity, damage) = app.handle_invalidations_with_damage(
&[InvalidationKey::new(ACTIVITY_ANIMATION_INVALIDATION_KEY)],
now,
);
assert_eq!(activity, UiInvalidation::Paint);
assert_eq!(damage, [TemporalDamage::Status]);
app.latest_hidden_activity_at = Some(now);
app.latest_hidden_activity_burst = 4;
app.latest_bar_animation_started_at = now;
app.latest_bar_next_frame_at = Some(now);
let (latest, damage) = app.handle_invalidations_with_damage(
&[InvalidationKey::new(LATEST_BAR_ANIMATION_INVALIDATION_KEY)],
now + Duration::from_millis(50),
);
assert_eq!(latest, UiInvalidation::Paint);
assert_eq!(damage, [TemporalDamage::LatestBar]);
assert!(app.latest_bar_next_frame_at.is_some());
let (finished, damage) = app.handle_invalidations_with_damage(
&[InvalidationKey::new(LATEST_BAR_ANIMATION_INVALIDATION_KEY)],
now + LATEST_BAR_ACTIVE_WINDOW,
);
let deadline_cleared = app.latest_bar_next_frame_at.is_none();
drop(app);
assert_eq!(finished, UiInvalidation::Paint);
assert_eq!(damage, [TemporalDamage::LatestBar]);
assert!(deadline_cleared);
}
#[test]
fn tool_elapsed_invalidation_targets_visual_projection_entry() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let before = app.elapsed_layout_revision;
let invalidation = app.handle_invalidations(
&[InvalidationKey::new(format!(
"{TOOL_ELAPSED_INVALIDATION_PREFIX}:call-item"
))],
Instant::now(),
);
assert_eq!(invalidation, UiInvalidation::Paint);
assert_eq!(app.elapsed_layout_revision, before.saturating_add(1));
assert_eq!(
app.drain_elapsed_dirty_visuals(),
BTreeSet::from(["call-item".to_owned()])
);
let remaining_dirty_visuals = app.drain_elapsed_dirty_visuals();
drop(app);
assert!(remaining_dirty_visuals.is_empty());
}
#[test]
fn tool_elapsed_deadline_is_not_starved_by_unrelated_reconciliation() {
let now = Instant::now();
let now_system = std::time::UNIX_EPOCH + Duration::from_secs(3);
let mut registry = super::super::invalidation::TemporalRegistry::default();
let app = running_tool_app_for_elapsed_test();
let initial = app.invalidation_requests(now, now_system);
let elapsed = initial
.iter()
.find(|request| tool_elapsed_invalidation_invocation_id(&request.key) == Some("call-1"))
.expect("running tool schedules elapsed invalidation");
let deadline = elapsed.at;
registry.reconcile(initial);
for offset in 1..=50 {
let advanced = Duration::from_millis(offset);
registry.reconcile(app.invalidation_requests(now + advanced, now_system + advanced));
}
drop(app);
assert!(
registry
.take_due(deadline)
.iter()
.any(|key| { tool_elapsed_invalidation_invocation_id(key) == Some("call-1") })
);
}
fn running_tool_app_for_elapsed_test() -> BmuxApp {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence, timestamp_ms, kind| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms,
session_id,
provenance: None,
kind,
};
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
app.absorb_session_event(&event(
1,
1_000,
bcode_session_models::SessionEventKind::ToolCallRequested {
tool_call_id: "call-1".to_owned(),
producer_plugin_id: Some("test.plugin".to_owned()),
tool_name: "test.tool".to_owned(),
arguments_json: "{}".to_owned(),
working_directory: None,
},
));
app.absorb_session_event(&event(
2,
2_000,
bcode_session_models::SessionEventKind::ToolInvocationLifecycle {
event: bcode_session_models::ToolInvocationLifecycleEvent {
invocation_id: "call-1".to_owned(),
sequence: 1,
stage: bcode_session_models::ToolInvocationLifecycleStage::Started,
message: None,
metadata: serde_json::Value::Null,
},
},
));
app
}
#[test]
fn tool_elapsed_invalidation_requires_authoritative_running_status() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence, timestamp_ms, kind| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms,
session_id,
provenance: None,
kind,
};
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
app.absorb_session_event(&event(
1,
1_000,
bcode_session_models::SessionEventKind::ToolCallRequested {
tool_call_id: "call-1".to_owned(),
producer_plugin_id: Some("test.plugin".to_owned()),
tool_name: "test.tool".to_owned(),
arguments_json: "{}".to_owned(),
working_directory: None,
},
));
app.absorb_session_event(&event(
2,
2_000,
bcode_session_models::SessionEventKind::ToolInvocationLifecycle {
event: bcode_session_models::ToolInvocationLifecycleEvent {
invocation_id: "call-1".to_owned(),
sequence: 1,
stage: bcode_session_models::ToolInvocationLifecycleStage::Started,
message: None,
metadata: serde_json::Value::Null,
},
},
));
app.absorb_session_live_event(&bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::ToolContributionPlaced {
envelope: bcode_session_models::ToolContributionEnvelope::new(
bcode_session_models::ToolContributionPlacement::Progress,
bcode_session_models::ToolContributionEvent {
invocation_id: "call-1".to_owned(),
contribution_id: "progress".to_owned(),
sequence: 1,
producer_id: "test.plugin".to_owned(),
schema: "test.progress".to_owned(),
schema_version: 1,
operation: bcode_session_models::ToolContributionOperation::Upsert,
persistence: bcode_session_models::ToolContributionPersistence::Transient,
artifact: None,
payload: serde_json::json!({"progress": true}),
},
),
},
});
let now = Instant::now();
let now_system = std::time::UNIX_EPOCH + Duration::from_secs(3);
assert!(
app.tool_elapsed_invalidation_requests(now, now_system)
.any(|request| {
tool_elapsed_invalidation_invocation_id(&request.key) == Some("call-1")
})
);
app.absorb_session_event(&event(
3,
2_500,
bcode_session_models::SessionEventKind::ToolInvocationLifecycle {
event: bcode_session_models::ToolInvocationLifecycleEvent {
invocation_id: "call-1".to_owned(),
sequence: 2,
stage: bcode_session_models::ToolInvocationLifecycleStage::Completed,
message: None,
metadata: serde_json::Value::Null,
},
},
));
let elapsed_requests_cleared = app
.tool_elapsed_invalidation_requests(now, now_system)
.next()
.is_none();
drop(app);
assert!(elapsed_requests_cleared);
}
#[test]
fn raw_durable_handlers_cannot_create_or_finalize_terminal_rows() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind,
};
app.apply_session_event(
&event(
0,
bcode_session_models::SessionEventKind::UserMessage {
client_id: bcode_session_models::ClientId::new(),
text: "raw user".to_owned(),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
),
SessionEventApplication::Live,
);
assert!(app.transcript.items().is_empty());
app.session_view
.apply_live_event(&bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::AssistantTextDelta {
turn_id: "turn-1".to_owned(),
segment_id: "segment-0".to_owned(),
segment_order: 0,
text: "streaming".to_owned(),
},
});
app.apply_session_view_terminal_adapter();
let before = app.transcript.clone();
app.apply_session_event(
&event(
1,
bcode_session_models::SessionEventKind::ModelTurnFinished {
turn_id: "turn-1".to_owned(),
outcome: bcode_session_models::ModelTurnOutcome::Completed,
message: None,
},
),
SessionEventApplication::Live,
);
let transcript = std::mem::take(&mut app.transcript);
drop(app);
assert_eq!(transcript, before);
}
#[test]
fn tui_stream_presentation_advances_only_shared_adapter_item_and_preserves_viewport() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
assert!(!app.apply_presentation_config(bcode_config::PresentationConfig::default()));
let live = SessionLiveEvent {
session_id,
kind: SessionLiveEventKind::AssistantTextStreamUpdated {
output_position: None,
turn_id: "turn-1".to_owned(),
segment_id: "segment-1".to_owned(),
segment_order: 0,
update: bcode_session_models::TextStreamUpdate {
generation: 0,
first_revision: 1,
revision: 1,
operation: bcode_session_models::TextStreamOperation::Append {
expected_offset: 0,
text: "smooth output".to_owned(),
},
},
},
};
app.absorb_session_live_event(&live);
let item_id = bcode_session_view_models::TranscriptViewItemId::new(
"assistant-turn:turn-1:segment:segment-1",
);
assert_eq!(app.transcript().len(), 1);
let before_scroll = app.scroll_offset();
let deadline = app
.next_streaming_presentation_deadline(Instant::now())
.expect("pending presentation deadline");
assert!(app.advance_streaming_presentation(deadline + Duration::from_millis(50)));
assert_eq!(app.transcript().len(), 1);
assert!(app.transcript().iter().any(|item| {
item.text().contains("smooth output")
&& matches!(item.kind(), &TranscriptItemKind::AssistantMessage)
}));
assert_eq!(app.scroll_offset(), before_scroll);
let targeted_item = matches!(
app.observations.last_transcript_damage,
TranscriptDocumentDamage::Items(ref ids) if ids.contains(&item_id)
);
drop(app);
assert!(targeted_item);
}
#[test]
fn tui_disabling_stream_presentation_flushes_through_shared_adapter() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
assert!(!app.apply_presentation_config(bcode_config::PresentationConfig::default()));
app.absorb_session_live_event(&SessionLiveEvent {
session_id,
kind: SessionLiveEventKind::AssistantTextDelta {
turn_id: "turn-1".to_owned(),
segment_id: "segment-1".to_owned(),
segment_order: 0,
text: "pending output".to_owned(),
},
});
let mut presentation = bcode_config::PresentationConfig::default();
presentation.streaming.enabled = false;
assert!(app.apply_presentation_config(presentation));
assert!(
app.next_streaming_presentation_deadline(Instant::now())
.is_none()
);
let item_damage = matches!(
app.observations.last_transcript_damage,
TranscriptDocumentDamage::Items(_)
);
drop(app);
assert!(item_damage);
}
#[test]
fn raw_live_handlers_cannot_create_or_finalize_terminal_rows() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let live = bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::AssistantTextDelta {
turn_id: "turn-1".to_owned(),
segment_id: "segment-0".to_owned(),
segment_order: 0,
text: "raw live assistant".to_owned(),
},
};
app.apply_session_live_event_side_effects(&live);
assert!(app.transcript.items().is_empty());
app.session_view.apply_live_event(&live);
app.apply_session_view_terminal_adapter();
let before = app.transcript.clone();
app.apply_session_live_event_side_effects(&bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::AssistantReasoningDelta {
turn_id: "turn-1".to_owned(),
text: "raw reasoning".to_owned(),
},
});
let transcript = std::mem::take(&mut app.transcript);
drop(app);
assert_eq!(transcript, before);
}
#[test]
fn adapter_reports_item_damage_and_escalates_index_mismatch_to_full_reset() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let live = |text: &str| bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::AssistantTextDelta {
turn_id: "turn-1".to_owned(),
segment_id: "segment-0".to_owned(),
segment_order: 0,
text: text.to_owned(),
},
};
app.session_view.apply_live_event(&live("first"));
assert_eq!(
app.apply_session_view_terminal_adapter(),
TranscriptDocumentDamage::Structural
);
app.session_view.apply_live_event(&live(" second"));
assert!(matches!(
app.apply_session_view_terminal_adapter(),
TranscriptDocumentDamage::Items(ids) if ids.len() == 1
));
let id = bcode_session_view_models::TranscriptViewItemId::new(
"assistant-turn:turn-1:segment:segment-0",
);
app.transcript.corrupt_source_index_for_test(id, usize::MAX);
assert_eq!(
app.apply_session_view_terminal_adapter(),
TranscriptDocumentDamage::FullReset
);
let index_is_consistent = app.transcript.source_index_is_consistent();
drop(app);
assert!(index_is_consistent);
}
#[test]
fn compatible_shared_replacement_preserves_terminal_identity() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let live = |text: &str| bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::AssistantTextDelta {
turn_id: "turn-1".to_owned(),
segment_id: "segment-0".to_owned(),
segment_order: 0,
text: text.to_owned(),
},
};
app.absorb_session_live_event(&live("first"));
let render_id = app.transcript()[0].id();
app.absorb_session_live_event(&live(" second"));
let transcript = std::mem::take(&mut app.transcript);
drop(app);
assert_eq!(transcript.items()[0].id(), render_id);
assert_eq!(transcript.items()[0].text(), "first second");
}
#[test]
fn session_view_terminal_adapter_reconciles_replaced_and_removed_message_items_by_stable_id() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let live = |text: &str| bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::AssistantTextDelta {
turn_id: "turn-1".to_owned(),
segment_id: "segment-0".to_owned(),
segment_order: 0,
text: text.to_owned(),
},
};
app.absorb_session_live_event(&live("first"));
let id = bcode_session_view_models::TranscriptViewItemId::new(
"assistant-turn:turn-1:segment:segment-0",
);
let first_revision = app
.transcript()
.iter()
.find(|item| item.source_view_item_id() == Some(&id))
.expect("primary item")
.source_view_item_revision()
.expect("primary revision");
app.absorb_session_live_event(&live(" second"));
let replacement = app
.transcript()
.iter()
.find(|item| item.source_view_item_id() == Some(&id))
.expect("replacement item");
assert!(
replacement
.source_view_item_revision()
.is_some_and(|revision| revision > first_revision)
);
assert_eq!(
app.transcript()
.iter()
.filter(|item| item.source_view_item_id() == Some(&id))
.count(),
1
);
app.session_view.clear_history_window();
app.apply_session_view_terminal_adapter();
let removed = app
.transcript()
.iter()
.all(|item| item.source_view_item_id() != Some(&id));
drop(app);
assert!(removed);
}
#[test]
fn elapsed_dirty_visual_drain_is_bounded_and_retains_overflow() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.elapsed_dirty_visuals = (0..100).map(|index| format!("call-{index:03}")).collect();
let first = app.drain_elapsed_dirty_visuals_bounded(64);
assert_eq!(first.len(), 64);
assert_eq!(first.first().map(String::as_str), Some("call-000"));
assert_eq!(first.last().map(String::as_str), Some("call-063"));
let second = app.drain_elapsed_dirty_visuals_bounded(64);
let remaining = app.drain_elapsed_dirty_visuals_bounded(64);
drop(app);
assert_eq!(second.len(), 36);
assert!(remaining.is_empty());
}
#[test]
fn replacing_latest_window_discards_stale_live_state_and_preserves_input_history() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence, kind| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind,
};
let initial = vec![event(
1,
bcode_session_models::SessionEventKind::UserMessage {
client_id: bcode_session_models::ClientId::new(),
text: "prompt".to_owned(),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
)];
let mut app = BmuxApp::new_with_history(Some(session_id), &initial, &[], false);
app.absorb_session_live_event(&bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::AssistantTextDelta {
turn_id: "turn-1".to_owned(),
segment_id: "segment-0".to_owned(),
segment_order: 0,
text: "stale partial".to_owned(),
},
});
let replacement = vec![
initial[0].clone(),
event(
2,
bcode_session_models::SessionEventKind::AssistantMessage {
text: "durable answer".to_owned(),
},
),
];
app.replace_latest_transcript_window(&replacement, true);
let transcript = &app.session_view_snapshot().transcript.items;
assert_eq!(transcript.len(), 2);
assert!(transcript.iter().any(|item| matches!(
&item.kind,
bcode_session_view_models::TranscriptViewItemKind::AssistantMessage { message }
if message.text == "durable answer"
)));
assert!(!transcript.iter().any(|item| matches!(
&item.kind,
bcode_session_view_models::TranscriptViewItemKind::AssistantMessage { message }
if message.text.contains("stale partial")
)));
assert_eq!(app.timeline_entries().len(), 1);
let has_older_history = app.has_older_history();
drop(app);
assert!(has_older_history);
}
#[test]
fn older_page_keeps_terminal_live_stream_and_blocks_stale_updates() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence, text: &str| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: text.to_owned(),
},
};
let stream = |revision, operation| bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::AssistantTextStreamUpdated {
output_position: None,
turn_id: "active-turn".to_owned(),
segment_id: "segment-0".to_owned(),
segment_order: 0,
update: bcode_session_models::TextStreamUpdate {
generation: 0,
first_revision: revision,
revision,
operation,
},
},
};
let mut app = BmuxApp::new_with_history(Some(session_id), &[event(2, "recent")], &[], true);
app.absorb_session_live_event(&stream(
1,
bcode_session_models::TextStreamOperation::Append {
expected_offset: 0,
text: "finished live text".to_owned(),
},
));
app.absorb_session_live_event(&stream(
2,
bcode_session_models::TextStreamOperation::Terminal {
status: bcode_session_models::TextStreamTerminalStatus::Completed,
},
));
app.prepend_older_history(&[event(1, "older")], false);
app.absorb_session_live_event(&stream(
3,
bcode_session_models::TextStreamOperation::Append {
expected_offset: "finished live text".len(),
text: " stale".to_owned(),
},
));
assert_eq!(
app.transcript()
.iter()
.filter(|item| item.text() == "finished live text")
.count(),
1
);
assert!(
!app.transcript()
.iter()
.any(|item| item.text().contains("stale"))
);
drop(app);
}
#[test]
fn older_history_page_preserves_live_stream_in_shared_and_terminal_views() {
let session_id = bcode_session_models::SessionId::new();
let event = |sequence, text: &str| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: text.to_owned(),
},
};
let mut app = BmuxApp::new_with_history(Some(session_id), &[event(2, "recent")], &[], true);
app.absorb_session_live_event(&bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::AssistantTextDelta {
turn_id: "active-turn".to_owned(),
segment_id: "segment-0".to_owned(),
segment_order: 0,
text: "still streaming".to_owned(),
},
});
assert!(
app.transcript()
.iter()
.any(|item| item.text() == "still streaming")
);
app.prepend_older_history(&[event(1, "older")], false);
assert!(app.session_view_snapshot().transcript.items.iter().any(|item| {
matches!(&item.kind, bcode_session_view_models::TranscriptViewItemKind::AssistantMessage { message }
if message.text == "still streaming")
}));
assert!(
app.transcript()
.iter()
.any(|item| item.text() == "still streaming")
);
drop(app);
}
#[test]
fn shared_session_view_tracks_tui_history_and_live_events() {
let session_id = bcode_session_models::SessionId::new();
let history = vec![
bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 1,
timestamp_ms: 1,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::UserMessage {
client_id: bcode_session_models::ClientId::new(),
text: "hello".to_owned(),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
},
bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 2,
timestamp_ms: 2,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "hi".to_owned(),
},
},
];
let mut app = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
assert_eq!(app.session_view_snapshot().transcript.items.len(), 2);
assert!(matches!(
&app.session_view_snapshot().transcript.items[0].kind,
bcode_session_view_models::TranscriptViewItemKind::UserMessage { message }
if message.text == "hello"
));
assert!(matches!(
&app.session_view_snapshot().transcript.items[1].kind,
bcode_session_view_models::TranscriptViewItemKind::AssistantMessage { message }
if message.text == "hi"
));
app.absorb_session_live_event(&bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::AssistantTextDelta {
turn_id: "turn-2".to_owned(),
segment_id: "segment-0".to_owned(),
segment_order: 0,
text: "live".to_owned(),
},
});
let has_live_message = matches!(
&app.session_view_snapshot().transcript.items[2].kind,
bcode_session_view_models::TranscriptViewItemKind::AssistantMessage { message }
if message.text == "live"
);
drop(app);
assert!(has_live_message);
}
#[test]
fn provider_compaction_tui_hides_opaque_payloads() {
let secret = "secret-opaque-tui-value";
let session_id = bcode_session_models::SessionId::new();
let history = [bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 1,
timestamp_ms: 1,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::ProviderContextCompacted {
compacted_through_sequence: 0,
snapshot: bcode_session_models::ProviderContextSnapshot {
format_version: 1,
request_fingerprint: None,
request_id: None,
provider_plugin_id: "provider".to_owned(),
model_id: "model".to_owned(),
compatibility_key: "surface".to_owned(),
auth_profile: None,
origin: bcode_session_models::ProviderContextSnapshotOrigin::Explicit,
messages_json: format!(r#"[{{"encrypted":"{secret}"}}]"#),
portable_summary: "portable summary".to_owned(),
},
},
}];
let mut app = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
let transcript = std::mem::take(&mut app.transcript);
drop(app);
assert_eq!(transcript.len(), 1);
let item = &transcript.items()[0];
assert!(item.text().contains("context compaction"));
assert!(!item.text().contains(secret));
assert!(!item.text().contains("portable summary"));
}
#[test]
fn authoritative_active_skill_snapshot_drives_shared_tui_state() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_active_skills(&[bcode_skill_models::SkillContextResponse {
skill_id: bcode_skill_models::SkillId::new("review"),
context: String::new(),
source: bcode_skill_models::SkillSource {
kind: bcode_skill_models::SkillSourceKind::Plugin,
label: "test".to_owned(),
path: None,
precedence: 0,
},
bytes_loaded: 0,
truncated: false,
model_policy: None,
}]);
assert_eq!(app.active_skill_count(), 1);
let has_review = app.session_view_snapshot().active_skills.contains("review");
drop(app);
assert!(has_review);
}
#[test]
fn skill_activation_status_consumes_shared_projection() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let skill_id = bcode_skill_models::SkillId::new("event-skill");
let event = |sequence, kind| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind,
};
app.absorb_session_event(&event(
1,
bcode_session_models::SessionEventKind::SkillActivated {
skill_id: skill_id.clone(),
source: None,
mode: bcode_skill_models::SkillActivationMode::Explicit,
activated_at_ms: 1,
},
));
assert!(
app.session_view_snapshot()
.active_skills
.contains("event-skill")
);
assert_eq!(app.status(), "activated skill: event-skill");
app.absorb_session_event(&event(
2,
bcode_session_models::SessionEventKind::SkillDeactivated {
skill_id,
deactivated_at_ms: 2,
},
));
assert!(
!app.session_view_snapshot()
.active_skills
.contains("event-skill")
);
let status = app.status().to_owned();
drop(app);
assert_eq!(status, "deactivated skill: event-skill");
}
#[test]
fn terminal_runtime_failure_does_not_create_transcript_content() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
app.absorb_session_event(&bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 1,
timestamp_ms: 1,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::RuntimeWorkFinished {
work_id: bcode_session_models::WorkId::new("ralph:loop"),
status: RuntimeWorkStatus::Failed,
finished_at_ms: Some(1),
message: Some("boom".to_owned()),
},
});
assert!(app.session_view_snapshot().runtime_work.is_empty());
assert!(app.session_view_snapshot().transcript.items.is_empty());
let transcript_is_empty = app.transcript().is_empty();
drop(app);
assert!(transcript_is_empty);
}
#[test]
fn generic_lifecycle_drives_tui_activity_until_terminal_event() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, stage| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::ToolInvocationLifecycle {
event: bcode_session_models::ToolInvocationLifecycleEvent {
invocation_id: "call-1".to_owned(),
sequence,
stage,
message: None,
metadata: serde_json::Value::Null,
},
},
};
app.absorb_session_event(&event(
1,
bcode_session_models::ToolInvocationLifecycleStage::Started,
));
assert!(matches!(
app.activity(),
ActivityState::RuntimeWork { detail } if detail == "running tool"
));
app.absorb_session_event(&event(
2,
bcode_session_models::ToolInvocationLifecycleStage::Completed,
));
let is_idle = matches!(app.activity(), ActivityState::Idle);
drop(app);
assert!(is_idle);
}
#[test]
fn authoritative_runtime_work_snapshot_drives_tui_activity() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.apply_runtime_work_snapshots(&[bcode_session_models::RuntimeWorkSnapshot {
work_id: bcode_session_models::WorkId::new("tool-1"),
kind: bcode_session_models::RuntimeWorkKind::Tool,
label: "shell".to_owned(),
tool_call_id: Some("call-1".to_owned()),
status: bcode_session_models::RuntimeWorkStatus::Running,
cancellable: true,
observation: None,
}]);
assert!(matches!(
app.activity(),
ActivityState::RuntimeWork { detail } if detail == "running tool: shell"
));
let work = &app.session_view_snapshot().runtime_work[0];
assert_eq!(work.label, "shell");
assert!(work.cancellable);
app.apply_runtime_work_snapshots(&[]);
assert!(matches!(app.activity(), ActivityState::Idle));
let work_is_empty = app.session_view_snapshot().runtime_work.is_empty();
drop(app);
assert!(work_is_empty);
}
#[test]
fn history_with_reasoning_change_builds_without_recursive_rebuild() {
let session_id = bcode_session_models::SessionId::new();
let history = vec![bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 1,
timestamp_ms: 1,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::ReasoningChanged {
effort: Some("high".to_owned()),
summary: Some("detailed".to_owned()),
model_scope: None,
},
}];
let app = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
assert_eq!(app.reasoning_effort(), Some("high"));
let summary = app.reasoning_summary().map(str::to_owned);
drop(app);
assert_eq!(summary.as_deref(), Some("detailed"));
}
#[test]
fn reasoning_and_skill_projections_are_event_driven() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let skill_id = bcode_skill_models::SkillId::new("event-skill");
let event = |sequence, kind| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind,
};
app.absorb_session_event(&event(
1,
bcode_session_models::SessionEventKind::ReasoningChanged {
effort: Some("high".to_owned()),
summary: Some("detailed".to_owned()),
model_scope: None,
},
));
app.absorb_session_event(&event(
2,
bcode_session_models::SessionEventKind::SkillActivated {
skill_id: skill_id.clone(),
source: None,
mode: bcode_skill_models::SkillActivationMode::Explicit,
activated_at_ms: 2,
},
));
assert_eq!(app.reasoning_effort(), Some("high"));
assert_eq!(app.reasoning_summary(), Some("detailed"));
assert_eq!(app.active_skill_count(), 1);
app.absorb_session_event(&event(
3,
bcode_session_models::SessionEventKind::SkillDeactivated {
skill_id,
deactivated_at_ms: 3,
},
));
let active_skill_count = app.active_skill_count();
drop(app);
assert_eq!(active_skill_count, 0);
}
#[test]
fn ralph_lifecycle_consumes_shared_projection() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.absorb_session_event(&event(
1,
SessionEventKind::SessionCreated {
name: None,
working_directory: std::path::PathBuf::from("/tmp/project"),
},
));
app.absorb_session_event(&event(
2,
SessionEventKind::RalphLifecycle {
loop_name: "loop".to_owned(),
state_dir: std::path::PathBuf::from("/tmp/project/.bcode/ralph/loop"),
kind: "started".to_owned(),
message: "running".to_owned(),
occurred_at_ms: 2,
},
));
let shared = app
.session_view_snapshot()
.transcript
.items
.iter()
.find(|item| item.sequence == Some(2))
.expect("shared ralph lifecycle item");
let expected = terminal_item_from_shared(shared);
let transcript = std::mem::take(&mut app.transcript);
drop(app);
let actual = transcript
.items()
.iter()
.last()
.expect("ralph lifecycle item");
assert_eq!(actual.role(), expected.role());
assert_eq!(actual.text(), expected.text());
assert_eq!(actual.kind(), expected.kind());
assert_eq!(
actual.text(),
"Ralph started\n* Loop: loop\n* running\n* State: .bcode/ralph/loop"
);
}
#[test]
fn skill_transcript_rows_consume_shared_projection() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind,
};
app.absorb_session_event(&event(
1,
bcode_session_models::SessionEventKind::SkillInvoked {
skill_id: bcode_skill_models::SkillId::new("review"),
arguments: "{}".to_owned(),
source: None,
invoked_at_ms: 1,
},
));
app.absorb_session_event(&event(
2,
bcode_session_models::SessionEventKind::SkillContextLoaded {
skill_id: bcode_skill_models::SkillId::new("review"),
bytes_loaded: 42,
truncated: true,
source: Some(bcode_skill_models::SkillSource {
kind: bcode_skill_models::SkillSourceKind::User,
label: "user skills".to_owned(),
path: Some("/skills/review/SKILL.md".to_owned()),
precedence: 10,
}),
preview: Some("preview".to_owned()),
loaded_at_ms: 2,
},
));
app.absorb_session_event(&event(
3,
bcode_session_models::SessionEventKind::SkillInvocationFailed {
skill_id: bcode_skill_models::SkillId::new("review"),
error: "boom".to_owned(),
failed_at_ms: 3,
},
));
let transcript = std::mem::take(&mut app.transcript);
let status = app.status().to_owned();
let shared_items = app
.session_view_snapshot()
.transcript
.items
.iter()
.map(terminal_item_from_shared)
.map(|item| (item.role(), item.text().to_owned(), item.kind().clone()))
.collect::<Vec<_>>();
drop(app);
let terminal = transcript.items().iter().collect::<Vec<_>>();
assert_eq!(terminal.len(), 3);
assert_eq!(terminal[0].role(), "Skill");
assert_eq!(terminal[0].text(), "invoked review\nArguments: {}");
assert_eq!(terminal[0].kind(), &TranscriptItemKind::Skill);
assert_eq!(terminal[1].role(), "Skill context");
assert_eq!(
terminal[1].text(),
"loaded review\nSource: user skills\nFile: /skills/review/SKILL.md\nBytes: 42 truncated\n\nPreview:\npreview"
);
assert_eq!(terminal[1].kind(), &TranscriptItemKind::Generic);
assert_eq!(status, "loaded skill context: review (42 bytes truncated)");
assert_eq!(terminal[2].role(), "Skill error");
assert_eq!(terminal[2].text(), "review: boom");
assert_eq!(terminal[2].kind(), &TranscriptItemKind::SkillError);
assert_eq!(
shared_items,
terminal
.iter()
.map(|item| (item.role(), item.text().to_owned(), item.kind().clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn model_change_preserves_accumulated_shared_cost() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind,
};
app.absorb_session_event(&event(
1,
bcode_session_models::SessionEventKind::ModelUsage {
turn_id: "turn-1".to_owned(),
usage: bcode_session_models::SessionTokenUsage {
request_id: Some("request-1".to_owned()),
observation_id: Some("request-1:usage".to_owned()),
terminal: true,
cost: Some(bcode_session_models::SessionCostEstimate::Estimated {
currency: "USD".to_owned(),
total_micros: 25_000,
components: Vec::new(),
source: "fixture".to_owned(),
revision: Some("v1".to_owned()),
}),
..bcode_session_models::SessionTokenUsage::default()
},
},
));
let before = app.session_view_snapshot().runtime.cost.clone();
app.absorb_session_event(&event(
2,
bcode_session_models::SessionEventKind::ModelChanged {
provider: "provider-2".to_owned(),
model: "model-2".to_owned(),
selection_source: bcode_session_models::ModelSelectionSource::UserExplicit,
},
));
let cost = app.session_view_snapshot().runtime.cost.clone();
drop(app);
assert_eq!(cost, before);
assert!(
TokenUsageMeter::default()
.footer_summary(None, 0, &cost)
.ends_with("spent 0 tok · estimated $0.02 (not billed)")
);
}
#[test]
fn model_reasoning_and_agent_events_consume_shared_runtime_projection() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind,
};
app.absorb_session_event(&event(
1,
bcode_session_models::SessionEventKind::ModelChanged {
provider: "<auto>".to_owned(),
model: "<default>".to_owned(),
selection_source: bcode_session_models::ModelSelectionSource::UserExplicit,
},
));
assert_eq!(app.selected_provider_plugin_id(), None);
assert_eq!(app.selected_model_id(), None);
assert_eq!(app.status(), "model: <auto>/<default>");
app.set_pending_agent("review", Some("#ff00ff".to_owned()));
app.absorb_session_event(&event(
2,
bcode_session_models::SessionEventKind::AgentChanged {
agent_id: "build".to_owned(),
},
));
assert_eq!(app.current_agent_id(), "build");
assert_eq!(app.display_agent_accent(), None);
app.absorb_session_event(&event(
3,
bcode_session_models::SessionEventKind::ReasoningChanged {
effort: Some("high".to_owned()),
summary: Some("detailed".to_owned()),
model_scope: None,
},
));
assert_eq!(app.reasoning_effort(), Some("high"));
assert_eq!(app.reasoning_summary(), Some("detailed"));
assert_eq!(
app.session_view_snapshot()
.runtime
.reasoning_effort
.as_deref(),
Some("high")
);
let summary = app
.session_view_snapshot()
.runtime
.reasoning_summary
.clone();
drop(app);
assert_eq!(summary.as_deref(), Some("detailed"));
}
#[test]
fn context_summary_uses_effective_proactive_compaction_target() {
let meter = TokenUsageMeter {
context_window: Some(1_050_000),
proactive_compaction_target: Some(272_000),
..TokenUsageMeter::default()
};
let occupancy = bcode_session_models::RequestContextOccupancy {
context_epoch: 3,
observation_sequence: 7,
observation: snapshot(true, 136_000),
};
assert_eq!(meter.context_summary(Some(&occupancy)), "~136,000/272k 50%");
}
#[test]
fn context_summary_marks_estimated_overflow_without_exceeding_one_hundred_percent() {
let meter = TokenUsageMeter {
context_window: Some(372_000),
..TokenUsageMeter::default()
};
let occupancy = bcode_session_models::RequestContextOccupancy {
context_epoch: 3,
observation_sequence: 7,
observation: snapshot(true, 407_295),
};
assert_eq!(
meter.context_summary(Some(&occupancy)),
"~407,295/372k 100%+"
);
}
#[test]
fn hydrated_estimated_occupancy_preserves_source_and_approximate_rendering() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let occupancy = bcode_session_models::RequestContextOccupancy {
context_epoch: 3,
observation_sequence: 7,
observation: snapshot(true, 2_500),
};
app.apply_context_occupancy(Some(occupancy));
let hydrated = app
.session_view_snapshot()
.runtime
.context_occupancy
.clone()
.expect("hydrated occupancy");
drop(app);
let meter = TokenUsageMeter {
context_window: Some(10_000),
..TokenUsageMeter::default()
};
assert!(hydrated.observation.context_tokens.is_estimated());
assert_eq!(meter.context_summary(Some(&hydrated)), "~2,500/10k 25%");
}
#[test]
fn live_exact_occupancy_preserves_exact_source_and_rendering() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let occupancy = bcode_session_models::RequestContextOccupancy {
context_epoch: 3,
observation_sequence: 7,
observation: snapshot(false, 2_500),
};
app.absorb_session_live_event(&SessionLiveEvent {
session_id: SessionId::new(),
kind: SessionLiveEventKind::RequestContextOccupancyChanged {
occupancy: Box::new(Some(occupancy)),
},
});
let live = app
.session_view_snapshot()
.runtime
.context_occupancy
.clone()
.expect("live occupancy");
drop(app);
let meter = TokenUsageMeter {
context_window: Some(10_000),
..TokenUsageMeter::default()
};
assert!(!live.observation.context_tokens.is_estimated());
assert_eq!(meter.context_summary(Some(&live)), "2,500/10k 25%");
}
#[test]
fn subscription_and_api_estimates_are_never_presented_as_billed_charges() {
let usages = ["chatgpt_subscription", "api_key"].map(|auth_mode| {
bcode_session_models::SessionTokenUsage {
pricing_target: Some(Box::new(bcode_session_models::SessionPricingTarget {
provider: "openai".into(),
auth_mode: auth_mode.into(),
api_surface: if auth_mode == "chatgpt_subscription" {
"chatgpt_codex"
} else {
"responses"
}
.into(),
integration: Some("bcode".into()),
})),
cost: Some(bcode_session_models::SessionCostEstimate::Estimated {
currency: "USD".into(),
total_micros: 1_115_980_451,
components: Vec::new(),
source: "remote_catalog".into(),
revision: None,
}),
..bcode_session_models::SessionTokenUsage::default()
}
});
let meter = TokenUsageMeter::default();
for usage in &usages {
let cost =
bcode_session_view_models::SessionCostSummary::rebuild(std::iter::once(usage));
assert!(cost.has_complete_coverage());
assert!(
meter
.footer_summary(None, 0, &cost)
.contains("estimated $1115.98 (not billed)")
);
}
let mixed = bcode_session_view_models::SessionCostSummary::rebuild(usages.iter());
assert!(
meter
.footer_summary(None, 0, &mixed)
.contains("estimated $2231.96 (not billed)")
);
}
#[test]
fn footer_renders_cost_currency_coverage_and_zero_semantics() {
let meter = TokenUsageMeter::default();
let complete = bcode_session_view_models::SessionCostSummary {
original_usage_incomplete: false,
revision: 0,
totals_micros: BTreeMap::from([("USD".to_string(), 0), ("EUR".to_string(), 2_000_000)]),
estimated_usage_count: 2,
unavailable_usage_count: 0,
observed_usage_count: 2,
};
let partial = bcode_session_view_models::SessionCostSummary {
unavailable_usage_count: 1,
observed_usage_count: 3,
..complete.clone()
};
let unavailable = bcode_session_view_models::SessionCostSummary {
unavailable_usage_count: 1,
observed_usage_count: 1,
..bcode_session_view_models::SessionCostSummary::default()
};
let complete_text = meter.footer_summary(None, 0, &complete);
assert!(complete_text.contains("estimated $0.00 (not billed)"));
assert!(complete_text.contains("estimated EUR 2.000000 (not billed)"));
assert!(!complete_text.contains('~'));
assert!(
meter
.footer_summary(None, 0, &partial)
.contains("cost partial: 2/3 requests priced")
);
assert!(
meter
.footer_summary(None, 0, &unavailable)
.contains("cost unavailable: 1 requests unpriced")
);
}
#[test]
fn authoritative_live_occupancy_drives_and_clears_footer_state() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let occupancy = bcode_session_models::RequestContextOccupancy {
context_epoch: 3,
observation_sequence: 7,
observation: snapshot(true, 2_500),
};
app.absorb_session_live_event(&SessionLiveEvent {
session_id: SessionId::new(),
kind: SessionLiveEventKind::RequestContextOccupancyChanged {
occupancy: Box::new(Some(occupancy)),
},
});
assert_eq!(
app.session_view_snapshot()
.runtime
.context_occupancy
.as_ref()
.map(|value| value.observation.context_tokens.tokens()),
Some(2_500)
);
app.absorb_session_live_event(&SessionLiveEvent {
session_id: SessionId::new(),
kind: SessionLiveEventKind::RequestContextOccupancyChanged {
occupancy: Box::new(None),
},
});
let occupancy_cleared = app
.session_view_snapshot()
.runtime
.context_occupancy
.is_none();
drop(app);
assert!(occupancy_cleared);
}
#[test]
fn effort_selection_does_not_rebuild_or_change_transcript_presentation() {
let session_id = SessionId::new();
let history = [shared_projection_adapter_event(
session_id,
1,
SessionEventKind::AssistantMessage {
text: "stable transcript".to_owned(),
},
)];
let mut app = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
let before_items = app.transcript().iter().cloned().collect::<Vec<_>>();
let before_frame = app.frame_observation();
let before_scroll = app.scroll_offset();
let before_visible = app.reasoning_visible();
app.apply_reasoning_selection(Some("high".to_owned()), None);
assert_eq!(
app.transcript().iter().cloned().collect::<Vec<_>>(),
before_items
);
assert_eq!(app.frame_observation(), before_frame);
assert_eq!(app.scroll_offset(), before_scroll);
assert_eq!(app.reasoning_visible(), before_visible);
let effort = app.reasoning_effort().map(str::to_owned);
drop(app);
assert_eq!(effort.as_deref(), Some("high"));
}
#[test]
fn reasoning_changed_event_preserves_transcript_frame() {
let session_id = SessionId::new();
let history = [shared_projection_adapter_event(
session_id,
1,
SessionEventKind::AssistantMessage {
text: "stable transcript".to_owned(),
},
)];
let mut app = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
app.observations.last_transcript_damage = TranscriptDocumentDamage::None;
let before = app.frame_observation();
app.absorb_session_event(&shared_projection_adapter_event(
session_id,
2,
SessionEventKind::ReasoningChanged {
effort: Some("high".to_owned()),
summary: None,
model_scope: None,
},
));
assert_eq!(app.frame_observation(), before);
let effort = app.reasoning_effort().map(str::to_owned);
drop(app);
assert_eq!(effort.as_deref(), Some("high"));
}
#[test]
fn model_status_reasoning_update_preserves_transcript_frame() {
let session_id = SessionId::new();
let history = [shared_projection_adapter_event(
session_id,
1,
SessionEventKind::AssistantMessage {
text: "stable transcript".to_owned(),
},
)];
let mut app = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
app.observations.last_transcript_damage = TranscriptDocumentDamage::None;
let before = app.frame_observation();
app.apply_model_status(bcode_model::SessionModelStatus {
provider_plugin_id: None,
requested_model_id: None,
effective_model_id: None,
model_id: None,
display_name: None,
context_window: None,
context_occupancy: None,
request_context_error: None,
auth_profile: None,
context_format_version: None,
compatibility_key: None,
max_output_tokens: None,
reasoning: Some(bcode_model::ModelReasoningInfo {
effort_values: vec!["low".to_owned(), "high".to_owned()],
..bcode_model::ModelReasoningInfo::default()
}),
reasoning_effort: Some("high".to_owned()),
reasoning_summary: None,
prompt_cache_mode: None,
conversation_reuse_mode: None,
compaction_mode: None,
compaction_backend: None,
proactive_compaction_threshold_percent: None,
proactive_compaction_threshold_tokens: None,
proactive_compaction_effective_threshold_tokens: None,
proactive_compaction_threshold_source: None,
cache: None,
metadata_source: None,
pricing: None,
});
assert_eq!(app.frame_observation(), before);
let effort = app.reasoning_effort().map(str::to_owned);
drop(app);
assert_eq!(effort.as_deref(), Some("high"));
}
#[test]
fn unchanged_reasoning_visibility_is_a_strict_presentation_noop() {
let session_id = SessionId::new();
let history = [shared_projection_adapter_event(
session_id,
1,
SessionEventKind::AssistantReasoningMessage {
text: "stable reasoning".to_owned(),
},
)];
let mut app = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
let before_items = app.transcript().iter().cloned().collect::<Vec<_>>();
app.set_reasoning_visible(app.reasoning_visible());
let after_items = app.transcript().iter().cloned().collect::<Vec<_>>();
drop(app);
assert_eq!(after_items, before_items);
}
#[test]
fn full_effort_range_cycles_two_wraps_without_frame_changes() {
let session_id = SessionId::new();
let history = [shared_projection_adapter_event(
session_id,
1,
SessionEventKind::AssistantMessage {
text: "stable transcript".to_owned(),
},
)];
let mut app = BmuxApp::new_with_history(Some(session_id), &history, &[], false);
app.reasoning_effort_values = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
.into_iter()
.map(ToOwned::to_owned)
.collect();
app.apply_reasoning_selection(Some("none".to_owned()), None);
app.observations.last_transcript_damage = TranscriptDocumentDamage::None;
let frame = app.frame_observation();
let expected = [
"minimal", "low", "medium", "high", "xhigh", "max", "none", "minimal", "low", "medium",
"high", "xhigh", "max", "none",
];
let observed = expected.map(|_| {
let effort = app.cycle_pending_reasoning_effort().map(str::to_owned);
(effort, app.frame_observation())
});
drop(app);
for ((effort, observed_frame), expected) in observed.into_iter().zip(expected) {
assert_eq!(effort.as_deref(), Some(expected));
assert_eq!(observed_frame, frame);
}
}
#[test]
fn subset_effort_range_skips_unsupported_values_and_wraps() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.reasoning_effort_values = vec!["low".to_owned(), "high".to_owned()];
app.apply_reasoning_selection(Some("low".to_owned()), None);
assert_eq!(app.cycle_pending_reasoning_effort(), Some("high"));
assert_eq!(app.cycle_pending_reasoning_effort(), Some("low"));
let effort = app.cycle_pending_reasoning_effort().map(str::to_owned);
drop(app);
assert_eq!(effort.as_deref(), Some("high"));
}
#[test]
fn pending_reasoning_cycle_advances_every_rapid_call_and_wraps() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.reasoning_effort_values = vec!["none".to_owned(), "low".to_owned(), "high".to_owned()];
app.apply_reasoning_selection(Some("none".to_owned()), None);
assert_eq!(app.cycle_pending_reasoning_effort(), Some("low"));
assert_eq!(app.cycle_pending_reasoning_effort(), Some("high"));
assert_eq!(app.cycle_pending_reasoning_effort(), Some("none"));
assert_eq!(app.cycle_pending_reasoning_effort(), Some("low"));
let generation = app.reasoning_effort_generation;
drop(app);
assert_eq!(generation, 4);
}
#[test]
fn session_change_explicitly_clears_pending_reasoning() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_pending_reasoning_effort("high".to_owned());
app.clear_pending_reasoning_effort_for_session_change();
assert_eq!(app.pending_reasoning_effort_generation(), None);
let effort = app.reasoning_effort().map(str::to_owned);
drop(app);
assert_eq!(effort, None);
}
#[test]
fn canonical_status_clears_only_matching_pending_reasoning() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.set_pending_reasoning_effort("high".to_owned());
app.reconcile_pending_reasoning_effort("low");
assert_eq!(app.reasoning_effort(), Some("high"));
app.reconcile_pending_reasoning_effort("high");
let effort = app.reasoning_effort().map(str::to_owned);
drop(app);
assert_eq!(effort, None);
}
#[test]
fn pending_reasoning_completion_does_not_clear_newer_selection() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let older = app.set_pending_reasoning_effort("low".to_owned());
let newer = app.set_pending_reasoning_effort("high".to_owned());
app.clear_pending_reasoning_effort(older);
assert_eq!(app.reasoning_effort(), Some("high"));
assert_eq!(app.pending_reasoning_effort_generation(), Some(newer));
app.clear_pending_reasoning_effort(newer);
let effort = app.reasoning_effort().map(str::to_owned);
drop(app);
assert_eq!(effort, None);
}
#[test]
fn model_turn_start_and_cancel_consume_shared_runtime_projection() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
app.absorb_session_event(&shared_projection_adapter_event(
session_id,
1,
SessionEventKind::ModelTurnStarted {
turn_id: "turn-shared".to_owned(),
},
));
let runtime = &app.session_view_snapshot().runtime;
assert_eq!(runtime.active_turn_id.as_deref(), Some("turn-shared"));
assert!(!runtime.cancelling);
assert!(matches!(
app.activity(),
ActivityState::PreparingModelRequest
));
app.absorb_session_event(&shared_projection_adapter_event(
session_id,
2,
SessionEventKind::ModelTurnCancelRequested {
turn_id: "turn-shared".to_owned(),
requested_at_ms: Some(2),
client_id: Some(bcode_session_models::ClientId::new()),
},
));
let runtime = &app.session_view_snapshot().runtime;
assert_eq!(runtime.active_turn_id.as_deref(), Some("turn-shared"));
assert!(runtime.cancelling);
assert!(matches!(app.activity(), ActivityState::Cancelling));
let status = app.status().to_owned();
drop(app);
assert_eq!(status, "cancellation requested");
}
#[test]
fn workflow_remains_visible_after_model_turn_finishes() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
app.absorb_session_event(&shared_projection_adapter_event(
session_id,
1,
SessionEventKind::RuntimeWorkStarted {
work_id: bcode_session_models::WorkId::new("workflow-live"),
kind: bcode_session_models::RuntimeWorkKind::Workflow,
label: "Checking completion".into(),
tool_call_id: None,
plugin_id: None,
service_interface: None,
operation: None,
parent_work_id: None,
started_at_ms: Some(1),
cancellable: true,
},
));
app.absorb_session_event(&shared_projection_adapter_event(
session_id,
2,
SessionEventKind::ModelTurnFinished {
turn_id: "evaluation".into(),
outcome: ModelTurnOutcome::Completed,
message: None,
},
));
let active = matches!(app.activity(), ActivityState::RuntimeWork { .. });
drop(app);
assert!(active);
}
#[test]
fn model_turn_finish_consumes_shared_runtime_projection() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
app.absorb_session_event(&shared_projection_adapter_event(
session_id,
1,
SessionEventKind::ModelTurnStarted {
turn_id: "turn-shared".to_owned(),
},
));
assert!(matches!(
app.activity(),
ActivityState::PreparingModelRequest
));
app.absorb_session_event(&shared_projection_adapter_event(
session_id,
2,
SessionEventKind::ModelTurnFinished {
turn_id: "turn-shared".to_owned(),
outcome: ModelTurnOutcome::Error,
message: Some("provider failed".to_owned()),
},
));
let runtime = &app.session_view_snapshot().runtime;
assert_eq!(runtime.active_turn_id, None);
assert_eq!(runtime.last_turn_outcome, Some(ModelTurnOutcome::Error));
assert_eq!(
runtime.last_turn_message.as_deref(),
Some("provider failed")
);
assert_eq!(app.status(), "provider failed");
assert!(matches!(app.activity(), ActivityState::Idle));
let shared = app
.session_view_snapshot()
.transcript
.items
.iter()
.find(|item| item.sequence == Some(2))
.expect("shared model-turn error item");
let expected = terminal_item_from_shared(shared);
let actual = app
.transcript()
.iter()
.last()
.expect("terminal error item")
.clone();
drop(app);
assert_eq!(actual.role(), expected.role());
assert_eq!(actual.text(), expected.text());
assert_eq!(actual.kind(), expected.kind());
}
#[test]
fn session_rename_status_consumes_shared_projection() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
app.absorb_session_event(&shared_projection_adapter_event(
session_id,
1,
SessionEventKind::SessionRenamed {
name: Some("renamed session".to_owned()),
},
));
assert_eq!(app.session_title(), Some("renamed session"));
let status = app.status().to_owned();
drop(app);
assert_eq!(status, "session: renamed session");
}
#[test]
fn working_directory_status_consumes_shared_projection() {
let session_id = SessionId::new();
let old_working_directory = std::path::PathBuf::from("/tmp/bcode-old");
let new_working_directory = std::path::PathBuf::from("/tmp/bcode-old/subdir");
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
app.absorb_session_event(&shared_projection_adapter_event(
session_id,
1,
SessionEventKind::WorkingDirectoryChanged {
old_working_directory: old_working_directory.clone(),
new_working_directory: new_working_directory.clone(),
},
));
assert_eq!(
app.working_directory(),
Some(new_working_directory.as_path())
);
assert_eq!(
app.status(),
format!(
"working directory: {}",
display(&new_working_directory, &old_working_directory)
)
);
let shared = app
.session_view_snapshot()
.transcript
.items
.iter()
.find(|item| item.sequence == Some(1))
.expect("shared working-directory item");
let expected = terminal_item_from_shared(shared);
let actual = app
.transcript()
.iter()
.last()
.expect("terminal item")
.clone();
drop(app);
assert_eq!(actual.role(), expected.role());
assert_eq!(actual.text(), expected.text());
assert_eq!(actual.kind(), expected.kind());
}
#[test]
fn context_compaction_clears_occupancy_through_shared_projection() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let occupancy = bcode_session_models::RequestContextOccupancy {
context_epoch: 3,
observation_sequence: 7,
observation: snapshot(true, 2_500),
};
app.apply_context_occupancy(Some(occupancy));
app.absorb_session_event(&bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 8,
timestamp_ms: 8,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::ContextCompacted {
compacted_through_sequence: 7,
summary: "summary".to_owned(),
},
});
assert!(
app.session_view_snapshot()
.runtime
.context_occupancy
.is_none()
);
let terminal = app
.transcript()
.iter()
.last()
.expect("compaction item")
.clone();
let shared = app
.session_view_snapshot()
.transcript
.items
.iter()
.find(|item| item.sequence == Some(8))
.expect("shared compaction item");
let expected = terminal_item_from_shared(shared);
drop(app);
assert_eq!(terminal.role(), "Compaction");
assert_eq!(terminal.text(), "local context compaction: summary");
assert_eq!(terminal.role(), expected.role());
assert_eq!(terminal.text(), expected.text());
assert_eq!(terminal.kind(), expected.kind());
}
#[test]
fn provider_context_compaction_consumes_shared_projection() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
app.absorb_session_event(&bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 9,
timestamp_ms: 9,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::ProviderContextCompacted {
compacted_through_sequence: 8,
snapshot: bcode_session_models::ProviderContextSnapshot {
format_version: 1,
request_fingerprint: None,
request_id: None,
provider_plugin_id: "provider".to_owned(),
model_id: "model".to_owned(),
compatibility_key: "compat".to_owned(),
auth_profile: None,
origin: bcode_session_models::ProviderContextSnapshotOrigin::Explicit,
messages_json: "[]".to_owned(),
portable_summary: "summary".to_owned(),
},
},
});
let terminal = app
.transcript()
.iter()
.last()
.expect("provider compaction item")
.clone();
let shared = app
.session_view_snapshot()
.transcript
.items
.iter()
.find(|item| item.sequence == Some(9))
.expect("shared provider compaction item");
let expected = terminal_item_from_shared(shared);
drop(app);
assert_eq!(terminal.role(), "Compaction");
assert_eq!(
terminal.text(),
"explicit provider-native context compaction (provider)"
);
assert_eq!(terminal.role(), expected.role());
assert_eq!(terminal.text(), expected.text());
assert_eq!(terminal.kind(), expected.kind());
}
#[test]
fn user_message_commit_consumes_shared_projection_text() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.replace_composer_with("shared user");
app.stage_submission();
assert_eq!(app.pending_submissions().len(), 1);
app.absorb_session_event(&event(
1,
SessionEventKind::UserMessage {
client_id: bcode_session_models::ClientId::new(),
text: "shared user".to_owned(),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
));
assert!(app.pending_submissions().is_empty());
assert_eq!(
app.shared_terminal_text_for_sequence(1, "You").as_deref(),
Some("shared user")
);
let entry = app
.timeline_entries()
.into_iter()
.next()
.expect("timeline entry");
assert_eq!(entry.text(), "shared user");
let actual = app.transcript().iter().last().expect("user item").clone();
drop(app);
assert_eq!(actual.role(), "You");
assert_eq!(actual.text(), "shared user");
assert_eq!(actual.event_sequence(), Some(1));
}
#[test]
fn assistant_streaming_delta_consumes_shared_projection_text() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.absorb_session_event(&event(
1,
SessionEventKind::AssistantDelta {
text: "shared ".to_owned(),
},
));
app.absorb_session_event(&event(
2,
SessionEventKind::AssistantDelta {
text: "stream".to_owned(),
},
));
assert_eq!(
app.latest_shared_streaming_terminal_item("Assistant")
.as_ref()
.map(TranscriptItem::text),
Some("shared stream")
);
let actual = app.transcript().iter().last().expect("assistant item");
assert_eq!(actual.role(), "Assistant");
assert_eq!(actual.text(), "shared stream");
assert!(actual.streaming());
let item_count = app.transcript().len();
drop(app);
assert_eq!(item_count, 1);
}
#[test]
fn assistant_final_message_consumes_shared_projection_text() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.absorb_session_event(&event(
1,
SessionEventKind::AssistantDelta {
text: "draft".to_owned(),
},
));
app.absorb_session_event(&event(
2,
SessionEventKind::AssistantMessage {
text: "final shared".to_owned(),
},
));
assert_eq!(
app.latest_shared_terminal_text("Assistant").as_deref(),
Some("final shared")
);
let actual = app
.transcript()
.iter()
.last()
.expect("assistant item")
.clone();
drop(app);
assert_eq!(actual.role(), "Assistant");
assert_eq!(actual.text(), "final shared");
assert!(!actual.streaming());
}
#[test]
fn assistant_stream_identity_survives_interleaved_usage_and_finalization() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let live = |text: &str| SessionLiveEvent {
session_id,
kind: SessionLiveEventKind::AssistantTextDelta {
turn_id: "turn-1".to_owned(),
segment_id: "segment-0".to_owned(),
segment_order: 0,
text: text.to_owned(),
},
};
app.absorb_session_live_event(&live("cargo check passed "));
let initial = app
.transcript()
.iter()
.find(|item| item.role() == "Assistant")
.expect("streaming assistant item");
let render_id = initial.id();
let source_id = initial
.source_view_item_id()
.expect("shared source identity")
.clone();
app.absorb_session_event(&shared_projection_adapter_event(
session_id,
1,
SessionEventKind::ModelUsage {
turn_id: "turn-1".to_owned(),
usage: bcode_session_models::SessionTokenUsage {
input_tokens: Some(6_154),
output_tokens: Some(22),
..bcode_session_models::SessionTokenUsage::default()
},
},
));
app.absorb_session_live_event(&live("successfully"));
app.absorb_session_event(&shared_projection_adapter_event(
session_id,
2,
SessionEventKind::AssistantMessage {
text: "cargo check passed successfully".to_owned(),
},
));
let assistants = app
.transcript()
.iter()
.filter(|item| item.role() == "Assistant")
.collect::<Vec<_>>();
assert_eq!(assistants.len(), 1);
assert_eq!(assistants[0].text(), "cargo check passed successfully");
assert!(!assistants[0].streaming());
assert_eq!(assistants[0].id(), render_id);
assert_eq!(assistants[0].source_view_item_id(), Some(&source_id));
let metered_total = app
.session_view_snapshot()
.runtime
.latest_usage
.as_ref()
.and_then(bcode_session_models::SessionTokenUsage::metered_total_tokens);
drop(app);
assert_eq!(metered_total, Some(6_176));
}
#[test]
fn live_reasoning_rich_updates_preserve_markdown_projection_and_finalize_once() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
app.set_reasoning_visible(true);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.absorb_session_event(&event(
1,
SessionEventKind::AssistantReasoningDelta {
text: "## Why\n\n- **safe**".to_owned(),
},
));
app.absorb_session_event(&event(
2,
SessionEventKind::AssistantReasoningDelta {
text: "\n- fast".to_owned(),
},
));
let reasoning = app
.transcript()
.iter()
.filter(|item| item.role().starts_with("Reasoning"))
.collect::<Vec<_>>();
assert_eq!(reasoning.len(), 1);
assert!(reasoning[0].streaming());
assert_eq!(
reasoning[0].text_format(),
bcode_session_view_models::TextFormat::Markdown
);
let streaming_rows = crate::render::transcript_item_rows_from_item(
reasoning[0],
48,
None,
bcode_config::TuiDiffViewerConfig::default(),
);
let streaming_body = streaming_rows[1..].to_vec();
let text = streaming_rows
.iter()
.flat_map(|line| &line.spans)
.map(|span| span.content.as_str())
.collect::<String>();
assert!(text.contains("Why"));
assert!(text.contains("safe"));
assert!(text.contains("fast"));
app.absorb_session_event(&event(
3,
SessionEventKind::AssistantReasoningMessage {
text: "## Why\n\n- **safe**\n- fast".to_owned(),
},
));
let reasoning = app
.transcript()
.iter()
.filter(|item| item.role().starts_with("Reasoning"))
.collect::<Vec<_>>();
assert_eq!(reasoning.len(), 1);
assert!(!reasoning[0].streaming());
let finalized_rows = crate::render::transcript_item_rows_from_item(
reasoning[0],
48,
None,
bcode_config::TuiDiffViewerConfig::default(),
);
drop(app);
assert_eq!(streaming_body, finalized_rows[1..]);
}
#[test]
fn reasoning_final_message_consumes_shared_projection_text() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.absorb_session_event(&event(
1,
SessionEventKind::AssistantReasoningDelta {
text: "draft reasoning".to_owned(),
},
));
app.absorb_session_event(&event(
2,
SessionEventKind::AssistantReasoningMessage {
text: "final reasoning".to_owned(),
},
));
assert_eq!(
app.latest_shared_terminal_text("Reasoning summary")
.as_deref(),
Some("final reasoning")
);
let actual = app
.transcript()
.iter()
.last()
.expect("reasoning item")
.clone();
drop(app);
assert_eq!(actual.role(), "Reasoning summary");
assert_eq!(actual.text(), "final reasoning");
assert!(!actual.streaming());
}
#[test]
fn split_reasoning_stream_consumes_shared_projection_without_aggregate_overwrite() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.absorb_session_event(&event(
1,
SessionEventKind::AssistantReasoningDelta {
text: "first thought".to_owned(),
},
));
app.absorb_session_event(&event(
2,
SessionEventKind::SystemMessage {
text: "tool output".to_owned(),
},
));
app.absorb_session_event(&event(
3,
SessionEventKind::AssistantReasoningDelta {
text: "second thought".to_owned(),
},
));
app.absorb_session_event(&event(
4,
SessionEventKind::AssistantReasoningMessage {
text: "first thought second thought final aggregate".to_owned(),
},
));
let terminal = app.transcript().iter().collect::<Vec<_>>();
assert_eq!(terminal.len(), 3);
assert_eq!(terminal[0].role(), "Reasoning summary");
assert_eq!(terminal[0].text(), "first thought");
assert!(!terminal[0].streaming());
assert_eq!(terminal[1].role(), "System");
assert_eq!(terminal[1].text(), "tool output");
assert_eq!(terminal[2].role(), "Reasoning summary");
assert_eq!(terminal[2].text(), "second thought");
assert!(!terminal[2].streaming());
let shared_items = app
.session_view_snapshot()
.transcript
.items
.iter()
.map(terminal_item_from_shared)
.map(|item| (item.role(), item.text().to_owned(), item.streaming()))
.collect::<Vec<_>>();
let terminal_items = terminal
.iter()
.map(|item| (item.role(), item.text().to_owned(), item.streaming()))
.collect::<Vec<_>>();
drop(app);
assert_eq!(shared_items, terminal_items);
}
#[test]
fn environment_context_is_hidden_in_terminal_updates_and_reopen() {
let session_id = SessionId::new();
let events = [
shared_projection_adapter_event(
session_id,
1,
SessionEventKind::SystemMessage {
text: format!(
"{}Current directory: /workspace",
bcode_session_models::TURN_ENVIRONMENT_SNAPSHOT_PREFIX
),
},
),
shared_projection_adapter_event(
session_id,
2,
SessionEventKind::SystemMessage {
text: "ordinary visible status".into(),
},
),
];
let mut live = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
for event in &events {
live.absorb_session_event(event);
}
let replayed = BmuxApp::new_with_history(Some(session_id), &events, &[], false);
for app in [&live, &replayed] {
let items = app.transcript().iter().collect::<Vec<_>>();
assert_eq!(items.len(), 1);
assert_eq!(items[0].text(), "ordinary visible status");
}
drop(live);
drop(replayed);
}
#[test]
fn generic_terminal_items_are_adapted_from_shared_projection() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let events = shared_projection_terminal_adapter_events(SessionId::new());
for event in &events {
app.absorb_session_event(event);
}
let terminal = app.transcript().iter().collect::<Vec<_>>();
assert_eq!(
terminal.len(),
app.session_view_snapshot().transcript.items.len()
);
for (terminal, shared) in terminal
.iter()
.zip(&app.session_view_snapshot().transcript.items)
{
let expected = terminal_item_from_shared(shared);
assert_eq!(terminal.role(), expected.role());
assert_eq!(terminal.text(), expected.text());
assert_eq!(terminal.kind(), expected.kind());
assert_eq!(terminal.event_sequence(), shared.sequence);
}
}
#[test]
fn active_tool_loop_follows_shared_tool_status() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.absorb_session_event(&event(1, shared_tool_call_request_kind()));
assert!(app.active_tool_loop());
assert!(matches!(
app.session_view_snapshot()
.tools
.get("tool-shared")
.expect("shared requested tool")
.status,
bcode_session_view_models::ToolInvocationViewStatus::Requested
));
app.absorb_session_event(&event(
2,
SessionEventKind::ToolInvocationResultRecorded {
record: bcode_session_models::ToolInvocationResultRecord {
invocation_id: "tool-shared".to_owned(),
model_output: "shared result".to_owned(),
is_error: false,
presentation: None,
result: Some(ToolInvocationResult::Text {
text: "shared result".to_owned(),
}),
content: Vec::new(),
},
},
));
assert!(!app.active_tool_loop());
let finished = matches!(
app.session_view_snapshot()
.tools
.get("tool-shared")
.expect("shared finished tool")
.status,
bcode_session_view_models::ToolInvocationViewStatus::Finished
);
drop(app);
assert!(finished);
}
#[test]
fn tool_request_state_consumes_shared_projection() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.absorb_session_event(&event(1, shared_tool_call_request_kind()));
let tool = app
.session_view_snapshot()
.tools
.get("tool-shared")
.expect("shared tool view");
assert_eq!(tool.tool_name.as_deref(), Some("shell.run"));
assert_eq!(
app.activity(),
&ActivityState::RunningTool {
name: tool.tool_name.clone().expect("tool name")
}
);
assert_eq!(
app.status(),
tool_request_status(tool.arguments_json.as_deref().expect("arguments"))
.unwrap_or_else(|| "started".to_owned())
);
let shared = app
.session_view_snapshot()
.transcript
.items
.iter()
.find(|item| item.sequence == Some(1))
.expect("shared tool item");
let expected = terminal_item_from_shared(shared);
let actual = app
.transcript()
.iter()
.last()
.expect("terminal tool item")
.clone();
drop(app);
assert_eq!(actual.role(), expected.role());
assert_eq!(actual.text(), expected.text());
assert_eq!(actual.kind(), expected.kind());
}
#[test]
fn tool_result_status_consumes_shared_projection() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.absorb_session_event(&event(1, shared_tool_call_request_kind()));
app.absorb_session_event(&event(
2,
SessionEventKind::ToolInvocationResultRecorded {
record: bcode_session_models::ToolInvocationResultRecord {
invocation_id: "tool-shared".to_owned(),
model_output: "shared failure".to_owned(),
is_error: true,
presentation: None,
result: Some(ToolInvocationResult::Text {
text: "shared failure".to_owned(),
}),
content: Vec::new(),
},
},
));
let tool = app
.session_view_snapshot()
.tools
.get("tool-shared")
.expect("shared tool view");
assert_eq!(tool.is_error, Some(true));
assert_eq!(app.status(), "failed");
let shared = app
.session_view_snapshot()
.transcript
.items
.iter()
.rev()
.find(|item| {
matches!(
&item.kind,
bcode_session_view_models::TranscriptViewItemKind::ToolInvocation { tool }
if tool.tool_call_id == "tool-shared"
)
})
.expect("shared tool item");
let expected = terminal_item_from_shared(shared);
let actual = app
.transcript()
.iter()
.last()
.expect("terminal tool item")
.clone();
drop(app);
assert_eq!(actual.role(), expected.role());
assert_eq!(actual.text(), expected.text());
assert_eq!(actual.kind(), expected.kind());
}
#[test]
fn finished_tool_result_uses_shared_terminal_projection() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.absorb_session_event(&event(1, shared_tool_call_request_kind()));
app.absorb_session_event(&event(
2,
SessionEventKind::ToolInvocationResultRecorded {
record: bcode_session_models::ToolInvocationResultRecord {
invocation_id: "tool-shared".to_owned(),
model_output: "shared result".to_owned(),
is_error: false,
presentation: None,
result: Some(ToolInvocationResult::Text {
text: "shared result".to_owned(),
}),
content: Vec::new(),
},
},
));
let terminal = app.transcript().iter().collect::<Vec<_>>();
let shared = app
.session_view_snapshot()
.transcript
.items
.iter()
.rev()
.find(|item| {
matches!(
&item.kind,
bcode_session_view_models::TranscriptViewItemKind::ToolInvocation { tool }
if tool.tool_call_id == "tool-shared"
&& matches!(
tool.status,
bcode_session_view_models::ToolInvocationViewStatus::Finished
| bcode_session_view_models::ToolInvocationViewStatus::Cancelled
| bcode_session_view_models::ToolInvocationViewStatus::Failed
)
)
})
.expect("shared finished tool item");
let expected = terminal_item_from_shared(shared);
let actual = (*terminal.last().expect("terminal result item")).clone();
let sequence = shared.sequence;
drop(app);
assert_eq!(actual.role(), expected.role());
assert_eq!(actual.text(), expected.text());
assert_eq!(actual.kind(), expected.kind());
assert_eq!(actual.event_sequence(), sequence);
}
#[test]
fn permission_request_activity_consumes_shared_projection() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.absorb_session_event(&event(1, shared_permission_request_kind()));
let permission = app
.shared_permission_view("permission-shared")
.expect("shared permission view");
assert_eq!(permission.tool_name, "shell.run");
assert_eq!(
app.activity(),
&ActivityState::WaitingPermission {
name: permission.tool_name.clone()
}
);
assert_eq!(
app.status(),
tool_request_status(&permission.arguments_json)
.unwrap_or_else(|| permission.tool_name.clone())
);
let shared = app
.session_view_snapshot()
.transcript
.items
.iter()
.find(|item| item.sequence == Some(1))
.expect("shared permission item");
let expected = terminal_item_from_shared(shared);
let actual = app
.transcript()
.iter()
.last()
.expect("terminal item")
.clone();
drop(app);
assert_eq!(actual.role(), expected.role());
assert_eq!(actual.text(), expected.text());
assert_eq!(actual.kind(), expected.kind());
}
#[test]
fn resolved_permission_result_uses_shared_terminal_projection() {
let session_id = SessionId::new();
let mut app = BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let event = |sequence, kind| shared_projection_adapter_event(session_id, sequence, kind);
app.absorb_session_event(&event(1, shared_permission_request_kind()));
app.absorb_session_event(&event(
2,
SessionEventKind::PermissionResolved {
permission_id: "permission-shared".to_owned(),
approved: true,
},
));
let shared = app
.session_view_snapshot()
.transcript
.items
.iter()
.rev()
.find(|item| {
matches!(
&item.kind,
bcode_session_view_models::TranscriptViewItemKind::Permission { permission }
if permission.permission_id == "permission-shared" && permission.resolved
)
})
.expect("shared resolved permission item");
let expected = terminal_item_from_shared(shared);
let actual = app
.transcript()
.iter()
.last()
.expect("terminal permission item")
.clone();
let sequence = shared.sequence;
drop(app);
assert_eq!(actual.role(), expected.role());
assert_eq!(actual.text(), expected.text());
assert_eq!(actual.kind(), expected.kind());
assert_eq!(actual.event_sequence(), sequence);
}
fn shared_projection_terminal_adapter_events(session_id: SessionId) -> Vec<SessionEvent> {
[
shared_projection_adapter_event(
session_id,
1,
SessionEventKind::UserMessage {
client_id: bcode_session_models::ClientId::new(),
text: "shared user".to_owned(),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
),
shared_projection_adapter_event(
session_id,
2,
SessionEventKind::SystemMessage {
text: "shared system".to_owned(),
},
),
shared_projection_adapter_event(
session_id,
3,
SessionEventKind::ModelUsage {
turn_id: "turn-1".to_owned(),
usage: bcode_session_models::SessionTokenUsage {
input_tokens: Some(2),
output_tokens: Some(3),
..bcode_session_models::SessionTokenUsage::default()
},
},
),
shared_projection_adapter_event(session_id, 4, shared_tool_call_request_kind()),
shared_projection_adapter_event(session_id, 5, shared_permission_request_kind()),
shared_projection_adapter_event(
session_id,
7,
SessionEventKind::ModelTurnFinished {
turn_id: "turn-shared".to_owned(),
outcome: ModelTurnOutcome::Error,
message: Some("provider failed".to_owned()),
},
),
shared_projection_adapter_event(session_id, 8, shared_tool_contribution_kind()),
shared_projection_adapter_event(session_id, 9, shared_plugin_status_note_kind()),
]
.into()
}
fn shared_projection_adapter_event(
session_id: SessionId,
sequence: u64,
kind: SessionEventKind,
) -> SessionEvent {
SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence.saturating_mul(10),
session_id,
provenance: None,
kind,
}
}
fn shared_tool_call_request_kind() -> SessionEventKind {
SessionEventKind::ToolCallRequested {
tool_call_id: "tool-shared".to_owned(),
producer_plugin_id: Some("bcode.shell".to_owned()),
tool_name: "shell.run".to_owned(),
arguments_json: r#"{"command":"printf shared"}"#.to_owned(),
working_directory: None,
}
}
fn shared_permission_request_kind() -> SessionEventKind {
SessionEventKind::PermissionRequested {
permission_id: "permission-shared".to_owned(),
tool_call_id: "tool-shared".to_owned(),
producer_plugin_id: Some("bcode.shell".to_owned()),
tool_name: "shell.run".to_owned(),
arguments_json: r#"{"command":"printf shared"}"#.to_owned(),
batch: None,
policy_source: Some("ask".to_owned()),
policy_reason: Some("needs confirmation".to_owned()),
}
}
fn shared_tool_contribution_kind() -> SessionEventKind {
SessionEventKind::ToolContribution {
event: bcode_session_models::ToolContributionEvent {
invocation_id: "tool-shared".to_owned(),
contribution_id: "status".to_owned(),
sequence: 1,
producer_id: "bcode.test".to_owned(),
schema: "bcode.test.status".to_owned(),
schema_version: 1,
operation: bcode_session_models::ToolContributionOperation::Upsert,
persistence: bcode_session_models::ToolContributionPersistence::Durable,
artifact: None,
payload: serde_json::json!({"status": "ok"}),
},
}
}
fn shared_plugin_status_note_kind() -> SessionEventKind {
SessionEventKind::PluginStatusNote {
plugin_id: "bcode.test".to_owned(),
note_id: "note-1".to_owned(),
text: "plugin status".to_owned(),
metadata: BTreeMap::new(),
}
}
#[test]
fn unknown_contribution_uses_terminal_generic_json_fallback() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.absorb_session_event(&bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 1,
timestamp_ms: 1,
session_id: bcode_session_models::SessionId::new(),
provenance: None,
kind: SessionEventKind::ToolContribution {
event: bcode_session_models::ToolContributionEvent {
invocation_id: "call".to_owned(),
contribution_id: "surface".to_owned(),
sequence: 1,
producer_id: "future.producer".to_owned(),
schema: "future.unknown/schema".to_owned(),
schema_version: 77,
operation: bcode_session_models::ToolContributionOperation::Append,
persistence: bcode_session_models::ToolContributionPersistence::Durable,
artifact: None,
payload: serde_json::json!({"sentinel": "opaque-tui"}),
},
},
});
let transcript_empty = app.transcript().is_empty();
drop(app);
assert!(transcript_empty);
}
#[test]
fn transient_contribution_updates_and_removes_one_live_fallback() {
let session_id = bcode_session_models::SessionId::new();
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
let live = |sequence, operation, sentinel| bcode_session_models::SessionLiveEvent {
session_id,
kind: SessionLiveEventKind::ToolContributionPlaced {
envelope: bcode_session_models::ToolContributionEnvelope::new(
bcode_session_models::ToolContributionPlacement::Hidden,
bcode_session_models::ToolContributionEvent {
invocation_id: "call".to_owned(),
contribution_id: "surface".to_owned(),
sequence,
producer_id: "future.producer".to_owned(),
schema: "future.unknown/schema".to_owned(),
schema_version: 77,
operation,
persistence: bcode_session_models::ToolContributionPersistence::Transient,
artifact: None,
payload: serde_json::json!({"sentinel": sentinel}),
},
),
},
};
app.absorb_session_live_event(&live(
1,
bcode_session_models::ToolContributionOperation::Upsert,
"first",
));
app.absorb_session_live_event(&live(
2,
bcode_session_models::ToolContributionOperation::Append,
"second",
));
assert!(app.transcript().is_empty());
assert_eq!(
app.session_view_snapshot().contributions["call:surface"].sequence,
2
);
app.absorb_session_live_event(&live(
3,
bcode_session_models::ToolContributionOperation::Remove,
"removed",
));
app.absorb_session_live_event(&live(
2,
bcode_session_models::ToolContributionOperation::Upsert,
"stale",
));
let transcript_empty = app.transcript().is_empty();
drop(app);
assert!(transcript_empty);
}
#[test]
fn legacy_shell_contribution_has_no_terminal_json_fallback() {
let mut app = BmuxApp::new_with_history(None, &[], &[], false);
app.absorb_session_event(&bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 1,
timestamp_ms: 1,
session_id: bcode_session_models::SessionId::new(),
provenance: None,
kind: SessionEventKind::ToolContribution {
event: bcode_session_models::ToolContributionEvent {
invocation_id: "shell-call".to_owned(),
contribution_id: "shell-run-summary".to_owned(),
sequence: 1,
producer_id: "bcode.shell".to_owned(),
schema: "bcode.shell.run.summary".to_owned(),
schema_version: 1,
operation: bcode_session_models::ToolContributionOperation::Upsert,
persistence: bcode_session_models::ToolContributionPersistence::Durable,
artifact: None,
payload: serde_json::json!({"output": "shell-render-sentinel"}),
},
},
});
let transcript_empty = app.transcript().is_empty();
drop(app);
assert!(transcript_empty);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChildPanelPresentation {
Hidden,
Composer,
Monitor,
}