use std::collections::{BTreeMap, VecDeque};
use std::time::{Duration, Instant};
use bmux_tui::event::Event;
const TRANSCRIPT_SELECTION_SCOPE_ID: &str = "bcode.transcript";
use super::TuiError;
use super::artifact_stream::ActiveArtifactFetchCompletion;
use super::chat_loop::{ChatLoopState, DraftAutosave, TuiRuntimeSettings};
use super::effects::TuiEffectResult;
use super::history_flow;
use super::invalidation::{InvalidationKey, TemporalRegistry};
use super::markdown_projection_coordinator::MarkdownProjectionCompletion;
use super::session_flow::ActiveChat;
#[allow(dead_code)]
pub enum BcodeRuntimeMessage {
Bootstrap {
handle: bmux_tui_runtime::RuntimeHandle<Self>,
},
TerminalInputFailed(std::io::Error),
Terminal(Event),
SessionStream(Box<history_flow::SessionStreamUpdate>),
ComposerSearchCompleted(super::composer_interaction::ComposerCompletion),
ArtifactFetchCompleted(Box<ActiveArtifactFetchCompletion>),
MarkdownProjectionCompleted(Box<Option<MarkdownProjectionCompletion>>),
PluginSurfaceInvalidated,
EffectCompleted(Box<TuiEffectResult>),
InteractiveSurfaceOpened(Result<super::interactive_surface::InteractiveSurfaceState, String>),
InteractiveSurfaceResolved(Result<bool, bcode_client::ClientError>),
Invalidations(Vec<InvalidationKey>),
DraftSaveDue,
InteractionRetryDue,
StreamingConfiguratorDue,
StreamingPresentationDue,
TelemetryFlushDue,
}
impl BcodeRuntimeMessage {
#[allow(dead_code)]
fn latest_key(&self) -> Option<bmux_tui_runtime::MessageKey> {
match self {
Self::MarkdownProjectionCompleted(_) => Some(bmux_tui_runtime::MessageKey::new(
"bcode.markdown_projection",
)),
Self::StreamingConfiguratorDue => Some(bmux_tui_runtime::MessageKey::new(
"bcode.streaming_configurator",
)),
Self::StreamingPresentationDue => Some(bmux_tui_runtime::MessageKey::new(
"bcode.streaming_presentation",
)),
Self::TelemetryFlushDue => {
Some(bmux_tui_runtime::MessageKey::new("bcode.telemetry_flush"))
}
Self::Bootstrap { .. }
| Self::TerminalInputFailed(_)
| Self::Terminal(_)
| Self::SessionStream(_)
| Self::ArtifactFetchCompleted(_)
| Self::ComposerSearchCompleted(_)
| Self::PluginSurfaceInvalidated
| Self::EffectCompleted(_)
| Self::InteractiveSurfaceOpened(_)
| Self::InteractiveSurfaceResolved(_)
| Self::Invalidations(_)
| Self::DraftSaveDue
| Self::InteractionRetryDue => None,
}
}
}
#[allow(dead_code)]
pub async fn admit(
handle: &bmux_tui_runtime::RuntimeHandle<BcodeRuntimeMessage>,
message: BcodeRuntimeMessage,
) -> Result<(), BcodeRuntimeAdmissionError> {
if let Some(key) = message.latest_key() {
handle
.send_latest(key, message)
.map(|_| ())
.map_err(|error| match error {
bmux_tui_runtime::LatestSendError::Full(_) => BcodeRuntimeAdmissionError::Full,
bmux_tui_runtime::LatestSendError::Closed(_) => BcodeRuntimeAdmissionError::Closed,
})
} else {
handle
.send(message)
.await
.map_err(|_| BcodeRuntimeAdmissionError::Closed)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum BcodeRuntimeAdmissionError {
Full,
Closed,
}
#[derive(Default)]
struct OrderedPresentationQueue {
pending: std::collections::BTreeMap<
bcode_session_models::SessionId,
VecDeque<super::effects::TuiEffect>,
>,
active: std::collections::BTreeSet<bcode_session_models::SessionId>,
}
impl OrderedPresentationQueue {
fn append(
&mut self,
notes: std::collections::BTreeMap<
bcode_session_models::SessionId,
VecDeque<super::effects::TuiEffect>,
>,
) {
for (session_id, mut pending) in notes {
self.pending
.entry(session_id)
.or_default()
.append(&mut pending);
}
}
fn complete(&mut self, session_id: bcode_session_models::SessionId) {
self.active.remove(&session_id);
}
fn take_ready(&mut self) -> Vec<super::effects::TuiEffect> {
let ready = self
.pending
.keys()
.filter(|session_id| !self.active.contains(session_id))
.copied()
.collect::<Vec<_>>();
let mut effects = Vec::with_capacity(ready.len());
for session_id in ready {
if let Some(effect) = self
.pending
.get_mut(&session_id)
.and_then(VecDeque::pop_front)
{
self.active.insert(session_id);
effects.push(effect);
}
if self
.pending
.get(&session_id)
.is_some_and(VecDeque::is_empty)
{
self.pending.remove(&session_id);
}
}
effects
}
}
#[allow(dead_code)]
pub struct BcodeRuntimeModel {
pub chat: ActiveChat,
pub loop_state: ChatLoopState,
pub settings: TuiRuntimeSettings,
pub draft_autosave: DraftAutosave,
pub screen: BcodeRuntimeScreen,
suspended_screen: Option<BcodeRuntimeScreen>,
pub runtime_handle: Option<bmux_tui_runtime::RuntimeHandle<BcodeRuntimeMessage>>,
pub deferred: VecDeque<BcodeRuntimeMessage>,
ordered_notes: OrderedPresentationQueue,
pub invalidation: super::invalidation::UiInvalidation,
presentation_damage: bmux_tui::damage::Damage,
fast_temporal_presentation: bool,
pub committed_hits: bmux_tui::hit::HitMap,
pub committed_selection: bmux_tui::selection::SelectionScene,
committed_visual_text: Vec<bcode_plugin_sdk::tui::PluginTuiSelectionRow>,
committed_visual_targets: Vec<(bmux_tui::geometry::Rect, String)>,
pub transcript_selection: bmux_tui::selection::SelectionController,
pub selected_plain_text: Option<String>,
pub committed_area: bmux_tui::geometry::Rect,
focused_input_owner: Option<FocusedInputOwner>,
composer_field: bcode_plugin_sdk::tui::FocusedTextFieldAdapter,
focused_input_command: Option<bmux_tui_runtime::Command<BcodeRuntimeMessage>>,
focused_visual: Option<String>,
committed_layout: Option<super::render::FrameLayout>,
pub last_presented_at: Option<Instant>,
exit_after_plugin_surface: bool,
plugin_surface_result: Option<(String, Option<serde_json::Value>)>,
pub exit_requested: bool,
pub(crate) resurrection: Option<super::resurrection::Publisher>,
theme_input_signature: u64,
theme_reload_at: Instant,
child_activity_at: Instant,
child_refresh_cursor: Option<bcode_session_models::SessionId>,
scheduled_deadlines: BTreeMap<bmux_tui_runtime::TimerId, Instant>,
temporal_registry: TemporalRegistry,
selection_clear_at: Option<Instant>,
selection_clear_generation: u64,
selection_session: Option<bcode_session_models::SessionId>,
selection_policy: (bool, bool, u64),
transcript_clipboard: fn(&str) -> Result<(), String>,
pending_timeline_jump: Option<(bcode_session_models::SessionId, u64, u64)>,
}
enum RootTimer {
Invalidations,
ArtifactRetry,
StreamingConfigurator,
StreamingPresentation,
DraftSave,
InteractiveSurfaceRetry,
TelemetryFlush,
ThemeReload,
ChildActivity,
SelectionClear(u64),
}
impl RootTimer {
fn id(self) -> bmux_tui_runtime::TimerId {
if let Self::SelectionClear(generation) = self {
return bmux_tui_runtime::TimerId::new(format!("bcode.selection_clear.{generation}"));
}
bmux_tui_runtime::TimerId::new(match self {
Self::Invalidations => "bcode.invalidations",
Self::ArtifactRetry => "bcode.artifact_retry",
Self::StreamingConfigurator => "bcode.streaming_configurator",
Self::StreamingPresentation => "bcode.streaming_presentation",
Self::DraftSave => "bcode.draft_save",
Self::InteractiveSurfaceRetry => "bcode.interactive_surface_retry",
Self::TelemetryFlush => "bcode.telemetry_flush",
Self::ThemeReload => "bcode.theme_reload",
Self::ChildActivity => "bcode.child_activity",
Self::SelectionClear(_) => unreachable!("selection timer handled above"),
})
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum FocusedInputOwner {
Composer,
RootSurface,
InteractiveSurface,
}
impl BcodeRuntimeModel {
fn compose_focused_input_event(&mut self, event: &Event) -> bmux_tui::event::EventOutcome {
if let Event::Key(stroke) = event {
if stroke.key != bmux_keyboard::KeyCode::Escape
&& self.root_interactive_surface_host_key(*stroke).is_some()
{
return bmux_tui::event::EventOutcome::Ignored;
}
return self.composer_interaction_key_outcome(*stroke);
}
if let Event::Mouse(mouse) = event {
let outcome = self.loop_state.composer_interaction.list_event(event);
if matches!(
outcome,
bmux_tui_components::selectable_list::SelectableListOutcome::Selected(_)
) {
self.accept_composer_candidate();
}
if !matches!(
outcome,
bmux_tui_components::selectable_list::SelectableListOutcome::Ignored
) {
return bmux_tui::event::EventOutcome::Redraw;
}
if self.loop_state.composer_interaction.visible()
&& matches!(mouse.kind, bmux_tui::event::MouseEventKind::Down(_))
{
self.loop_state.composer_interaction.dismiss();
self.invalidation = self
.invalidation
.merge(super::invalidation::UiInvalidation::Structural);
}
}
bmux_tui::event::EventOutcome::Ignored
}
fn composer_interaction_key_outcome(
&mut self,
stroke: bmux_keyboard::KeyStroke,
) -> bmux_tui::event::EventOutcome {
use bcode_plugin_sdk::tui::keymap::controls::{ControlAction, ControlInput};
use bmux_tui_components::completion::CompletionCommand;
if (self.loop_state.foreground().is_some()
&& self.loop_state.foreground() != Some(super::foreground::Foreground::Plugin))
|| !self.loop_state.composer_interaction.visible()
{
return bmux_tui::event::EventOutcome::Ignored;
}
if let Some(binding) = self
.settings
.keymap()
.registry()
.action_for_key("composer_interaction", stroke)
&& self.loop_state.composer_interaction.invoke_action(binding)
{
self.loop_state
.composer_interaction
.resolve_hints(self.settings.keymap().registry());
self.invalidation = self
.invalidation
.merge(super::invalidation::UiInvalidation::Structural);
return bmux_tui::event::EventOutcome::Handled;
}
let input = ControlInput::from_registry(
self.settings.keymap().registry(),
"composer_interaction",
stroke,
&[
ControlAction::Cancel,
ControlAction::Submit,
ControlAction::FocusNext,
ControlAction::Up,
ControlAction::Down,
],
false,
);
let command = match input {
ControlInput::Action(ControlAction::Cancel) => CompletionCommand::Dismiss,
ControlInput::Action(ControlAction::Submit | ControlAction::FocusNext) => {
CompletionCommand::Accept
}
ControlInput::Action(ControlAction::Up) => CompletionCommand::Previous,
ControlInput::Action(ControlAction::Down) => CompletionCommand::Next,
_ => return bmux_tui::event::EventOutcome::Ignored,
};
let (outcome, accept) = self.loop_state.composer_interaction.command(command);
if command == CompletionCommand::Dismiss {
self.loop_state.composer_interaction.dismiss();
} else if accept {
self.accept_composer_candidate();
}
if accept {
bmux_tui::event::EventOutcome::Redraw
} else {
outcome
}
}
fn active_focused_field(&self) -> Option<bcode_plugin_sdk::tui::FocusedTextFieldSnapshot> {
match self.focused_input_owner? {
FocusedInputOwner::Composer => self.composer_field.snapshot(self.chat.app.composer()),
FocusedInputOwner::RootSurface => self.loop_state.root_focused_text_field(),
FocusedInputOwner::InteractiveSurface => {
self.loop_state.interactive_focused_text_field()
}
}
}
fn apply_active_focused_edit(
&mut self,
edit: &bcode_plugin_sdk::tui::FocusedTextFieldEdit,
) -> bcode_plugin_sdk::tui::FocusedTextFieldEditResult {
use bcode_plugin_sdk::tui::FocusedTextFieldEditResult;
let rejected = FocusedTextFieldEditResult::Rejected(
bcode_plugin_sdk::composer::ComposerValidationError::Stale,
);
match self.focused_input_owner {
Some(FocusedInputOwner::RootSurface) => {
let client = self.loop_state.foreground_client();
let launch = self.fresh_session_settings();
let Some(result) = self
.loop_state
.apply_root_focused_text_field_edit(edit, &client, launch)
else {
return rejected;
};
let damage = self.apply_root_surface_event_result(result.event);
self.invalidation = self.invalidation.merge(damage);
result.edit_result
}
Some(FocusedInputOwner::InteractiveSurface) => {
let (result, outcome) = self
.loop_state
.apply_interactive_focused_text_field_edit(edit);
if let super::interactive_surface::InteractiveSurfaceEventOutcome::Resolved(
resolution,
) = &outcome
&& let Some(interaction_id) = self
.loop_state
.active_interactive_surface_id()
.map(ToOwned::to_owned)
{
self.focused_input_command = Some(self.interactive_surface_resolution_command(
interaction_id,
resolution.clone(),
));
}
if matches!(
outcome,
super::interactive_surface::InteractiveSurfaceEventOutcome::Redraw
| super::interactive_surface::InteractiveSurfaceEventOutcome::Relayout
) {
self.invalidation = self
.invalidation
.merge(super::invalidation::UiInvalidation::Structural);
}
result
}
Some(FocusedInputOwner::Composer) => {
let result = self
.composer_field
.apply(self.chat.app.composer_mut(), edit);
if result == FocusedTextFieldEditResult::Applied {
self.chat.app.reset_input_history_navigation();
self.chat.app.wake_cursor();
self.draft_autosave.observe(&self.chat, Instant::now());
}
result
}
_ => rejected,
}
}
fn reject_composer_candidate(&mut self) {
self.loop_state.composer_interaction.dismiss();
self.chat
.app
.set_status("Suggestion could not be applied; draft unchanged".to_owned());
}
fn accept_composer_candidate(&mut self) {
if let Some(edit) = self.loop_state.composer_interaction.focused_edit() {
let Some(current) = self.active_focused_field() else {
self.reject_composer_candidate();
return;
};
match edit.validate(¤t) {
Ok(Some(next)) => {
if self.apply_active_focused_edit(&edit)
== bcode_plugin_sdk::tui::FocusedTextFieldEditResult::Applied
{
self.loop_state
.composer_interaction
.finish_focused_edit(next);
} else {
self.reject_composer_candidate();
}
}
Ok(None) => self.loop_state.composer_interaction.dismiss(),
Err(_) => self.reject_composer_candidate(),
}
return;
}
self.loop_state.composer_interaction.dismiss();
}
fn synchronize_composer_interaction(&mut self) {
self.synchronize_composer_interaction_with_paint(false);
}
fn synchronize_composer_interaction_with_paint(&mut self, after_paint: bool) {
let previous_owner = self.focused_input_owner;
let standalone =
self.loop_state.foreground() == Some(super::foreground::Foreground::Plugin);
let interactive_owner = standalone
|| (self.loop_state.has_interactive_surface()
&& self
.loop_state
.routes_non_mouse_event_to_interactive_surface(
self.chat.app.tui_config().interactions,
));
self.focused_input_owner = if standalone {
Some(FocusedInputOwner::RootSurface)
} else if self.loop_state.foreground().is_some()
|| (!matches!(self.screen, BcodeRuntimeScreen::Chat) && !interactive_owner)
{
None
} else if interactive_owner {
Some(FocusedInputOwner::InteractiveSurface)
} else {
Some(FocusedInputOwner::Composer)
};
self.composer_field.observe(
self.chat.app.composer(),
matches!(self.focused_input_owner, Some(FocusedInputOwner::Composer)),
);
let field = self.active_focused_field();
if self.chat.app.plugin_presentation().is_none()
|| (!matches!(self.screen, BcodeRuntimeScreen::Chat) && !interactive_owner)
|| (self.loop_state.foreground().is_some() && !standalone)
|| (interactive_owner && field.is_none() && after_paint)
|| self.runtime_handle.is_none()
{
if self.loop_state.composer_interaction.visible() {
self.invalidation = self
.invalidation
.merge(super::invalidation::UiInvalidation::Structural);
}
self.loop_state.composer_interaction.cancel();
return;
}
let Some(scope) = self.composer_completion_scope(interactive_owner) else {
self.loop_state.composer_interaction.cancel();
return;
};
let Some(field) = field else {
if after_paint || previous_owner != self.focused_input_owner {
self.loop_state.composer_interaction.cancel();
} else if matches!(self.focused_input_owner, Some(FocusedInputOwner::Composer)) {
self.loop_state
.composer_interaction
.observe_unpainted_composer(scope, self.chat.app.composer());
} else {
self.loop_state
.composer_interaction
.await_field_paint(&scope);
}
return;
};
let changed = self
.loop_state
.composer_interaction
.observe_focused_field(scope.clone(), field);
if changed {
self.invalidation = self
.invalidation
.merge(super::invalidation::UiInvalidation::Structural);
}
if !changed && !self.loop_state.composer_interaction.needs_activation() {
return;
}
let reactivation = self.loop_state.composer_interaction.take_reactivation();
let activation = {
let Some(presentation) = self.chat.app.plugin_presentation() else {
return;
};
let Some(document) = self.loop_state.composer_interaction.document() else {
return;
};
reactivation.map_or_else(
|| presentation.activate_composer(document),
|route| {
presentation
.reactivate_composer(&route, document)
.map(|activation| (route, activation))
},
)
};
let Some((route, activation)) = activation else {
self.loop_state.composer_interaction.dismiss();
return;
};
let Some(request) = self
.loop_state
.composer_interaction
.start(route.clone(), activation)
else {
return;
};
self.dispatch_composer_search(scope, route, request);
}
fn prepare_root_presentation(
&mut self,
) -> (Option<std::time::Duration>, bmux_tui::damage::Damage, bool) {
self.synchronize_selection_lifecycle();
let interval = self.settings.bmux_runtime_config().frame_interval;
let damage = std::mem::replace(
&mut self.presentation_damage,
bmux_tui::damage::Damage::None,
);
let temporal = std::mem::take(&mut self.fast_temporal_presentation)
&& self
.transcript_selection
.snapshot(&self.committed_selection)
.is_none();
let launch = self.fresh_session_settings();
self.loop_state.set_render_launch(launch);
(interval, damage, temporal)
}
fn composer_completion_scope(
&self,
owner_field: bool,
) -> Option<super::composer_interaction::ComposerScope> {
let presentation = self.chat.app.plugin_presentation()?;
let session = self.chat.attached_session_id();
if owner_field && session.is_none() && !self.loop_state.has_root_plugin_surface() {
return None;
}
let workspace = if session.is_some() {
self.chat
.app
.working_directory()
.filter(|path| path.is_absolute())
.map(std::path::Path::to_path_buf)
} else {
let launch = self.settings.launch_working_directory();
if !launch.is_absolute() {
return None;
}
Some(launch.to_path_buf())
};
let agent = if owner_field {
self.chat.app.owning_agent_id()
} else {
self.chat
.app
.pending_agent_id()
.or_else(|| self.chat.app.owning_agent_id())
};
Some(super::composer_interaction::ComposerScope {
session,
workspace,
agent_id: agent.map(str::to_owned),
registry_identity: presentation.composer_identity(),
registry_revision: presentation.revision(),
})
}
fn dispatch_composer_search(
&mut self,
scope: super::composer_interaction::ComposerScope,
route: bcode_plugin_sdk::composer::PluginOwnedComposerContribution,
request: bcode_plugin_sdk::composer::ComposerSearchRequest,
) {
let Some(handle) = self.runtime_handle.clone() else {
return;
};
let client = self.loop_state.foreground_client();
let task_client = client.clone();
let context = bcode_ipc::PluginServiceContext {
invocation_id: request.request_id.clone(),
workspace: bcode_plugin_sdk::WorkspaceContext {
session_id: scope.session.map(|session| session.to_string()),
launch_root: scope.workspace,
},
agent_id: if self
.loop_state
.composer_interaction
.focused_field()
.is_some()
{
None
} else {
scope.agent_id
},
deadline_ms: 2_000,
};
let task = tokio::spawn(async move {
let snapshot = match serde_json::to_vec(&request) {
Ok(payload) => task_client
.invoke_plugin_service_contextual(
context,
route.plugin_id,
route.contribution.service_interface_id,
bcode_plugin_sdk::composer::OP_COMPOSER_SEARCH.to_owned(),
payload,
)
.await
.ok()
.filter(|response| {
response.error.is_none() && response.payload.len() <= 2 * 1024 * 1024
})
.and_then(|response| serde_json::from_slice(&response.payload).ok()),
Err(_) => None,
};
let _ = handle
.send(BcodeRuntimeMessage::ComposerSearchCompleted(
super::composer_interaction::ComposerCompletion {
request_id: request.request_id,
snapshot,
},
))
.await;
});
self.loop_state
.composer_interaction
.retain_task(task.abort_handle(), client);
}
fn fresh_session_settings(&self) -> super::plugin_surface_host::FreshSessionSettings {
super::plugin_surface_host::FreshSessionSettings {
keymap: self.settings.keymap().registry().clone(),
working_directory: self
.chat
.app
.working_directory()
.unwrap_or_else(|| self.settings.launch_working_directory())
.to_path_buf(),
provider: self
.chat
.app
.selected_provider_plugin_id()
.map(ToOwned::to_owned),
model: self.chat.app.selected_model_id().map(ToOwned::to_owned),
agent: Some(
self.chat
.app
.pending_agent_id()
.unwrap_or_else(|| self.chat.app.current_agent_id())
.to_owned(),
),
effort: self.chat.app.reasoning_effort().map(ToOwned::to_owned),
summary: self.chat.app.reasoning_summary().map(ToOwned::to_owned),
}
}
#[allow(dead_code)]
pub fn new(
chat: ActiveChat,
settings: TuiRuntimeSettings,
mut loop_state: ChatLoopState,
) -> Self {
loop_state
.set_session_picker_launch_directory(settings.launch_working_directory().to_path_buf());
loop_state.set_slash_palette_settings(settings.completion_settings());
let draft_autosave = DraftAutosave::new(
settings.launch_working_directory().to_path_buf(),
chat.app.composer().text().to_owned(),
);
let theme_input_signature = super::theme::active_theme_input_signature(&chat.app);
let theme_reload_at = Instant::now() + Duration::from_millis(750);
let selection_session = chat.app.session_id();
let selection_policy = (
settings.selection().auto_copy_on_mouse_release,
settings.selection().clear_after_copy,
settings.selection().clear_delay_ms,
);
Self {
selection_clear_at: None,
selection_clear_generation: 0,
selection_session,
selection_policy,
transcript_clipboard: {
#[cfg(not(test))]
let copy: fn(&str) -> Result<(), String> = |text| {
super::markdown_activation::copy_text(text).map_err(|error| error.to_string())
};
#[cfg(test)]
let copy: fn(&str) -> Result<(), String> =
|_| Err("test clipboard not injected".to_owned());
copy
},
chat,
loop_state,
settings,
draft_autosave,
screen: BcodeRuntimeScreen::Chat,
suspended_screen: None,
runtime_handle: None,
deferred: VecDeque::new(),
ordered_notes: OrderedPresentationQueue::default(),
invalidation: super::invalidation::UiInvalidation::Full,
presentation_damage: bmux_tui::damage::Damage::Full,
fast_temporal_presentation: false,
committed_hits: bmux_tui::hit::HitMap::default(),
committed_selection: bmux_tui::selection::SelectionScene::new(),
committed_visual_text: Vec::new(),
committed_visual_targets: Vec::new(),
transcript_selection: bmux_tui::selection::SelectionController::new(),
selected_plain_text: None,
committed_area: bmux_tui::geometry::Rect::new(0, 0, 0, 0),
focused_input_owner: None,
focused_input_command: None,
composer_field: bcode_plugin_sdk::tui::FocusedTextFieldAdapter::new("chat", "composer"),
focused_visual: None,
committed_layout: None,
last_presented_at: None,
exit_after_plugin_surface: false,
plugin_surface_result: None,
exit_requested: false,
resurrection: None,
theme_input_signature,
theme_reload_at,
child_activity_at: Instant::now() + Duration::from_secs(2),
child_refresh_cursor: None,
scheduled_deadlines: BTreeMap::new(),
temporal_registry: TemporalRegistry::default(),
pending_timeline_jump: None,
}
}
fn active_screen(&self) -> BcodeRuntimeScreen {
self.loop_state.active_root_screen()
}
fn synchronize_screen(&mut self) -> super::invalidation::UiInvalidation {
self.synchronize_selection_lifecycle();
let next = self.active_screen();
if next == self.screen {
return super::invalidation::UiInvalidation::None;
}
self.screen = next;
super::invalidation::UiInvalidation::Structural
}
const fn suspend_screen_for_session_navigation(&mut self) {
self.suspended_screen = Some(self.screen);
self.screen = BcodeRuntimeScreen::Chat;
}
fn finish_plugin_session_attach(
&mut self,
session_id: bcode_session_models::SessionId,
result: Result<(), String>,
) {
if result.is_ok() {
let _attached = self
.loop_state
.mark_root_plugin_session_attached(session_id);
} else {
let _completed = self
.loop_state
.complete_root_plugin_session_navigation(session_id, result);
self.resume_screen_after_session_navigation();
}
}
fn resume_screen_after_session_navigation(&mut self) {
let expected = self.suspended_screen.take();
let active = self.active_screen();
self.screen = expected
.filter(|screen| *screen == active)
.unwrap_or(active);
}
pub fn handle_ralph_surface_action(
&mut self,
action: super::ralph_launcher::RalphHomeAction,
) -> super::invalidation::UiInvalidation {
use super::ralph_flow::RalphRootAction;
let action = match action {
super::ralph_launcher::RalphHomeAction::Plan => RalphRootAction::Plan,
super::ralph_launcher::RalphHomeAction::SaveDraft => RalphRootAction::SaveDraft,
super::ralph_launcher::RalphHomeAction::ViewDraft => RalphRootAction::ViewDraft,
super::ralph_launcher::RalphHomeAction::ReviseDraft => RalphRootAction::ReviseDraft,
super::ralph_launcher::RalphHomeAction::ApproveDraft => RalphRootAction::ApproveDraft,
super::ralph_launcher::RalphHomeAction::ApplyDraftToLoop => {
RalphRootAction::ApplyDraftToLoop
}
super::ralph_launcher::RalphHomeAction::CreateFromDraft => {
RalphRootAction::CreateFromDraft
}
super::ralph_launcher::RalphHomeAction::Run
| super::ralph_launcher::RalphHomeAction::Goal => RalphRootAction::Run,
super::ralph_launcher::RalphHomeAction::Approve => RalphRootAction::Approve,
super::ralph_launcher::RalphHomeAction::Stop => RalphRootAction::Stop,
super::ralph_launcher::RalphHomeAction::Resume => RalphRootAction::Resume,
super::ralph_launcher::RalphHomeAction::Status => RalphRootAction::ShowStatus,
super::ralph_launcher::RalphHomeAction::Runs => RalphRootAction::ListRuns,
super::ralph_launcher::RalphHomeAction::Iterations => RalphRootAction::ListIterations,
super::ralph_launcher::RalphHomeAction::Open => RalphRootAction::OpenProgress,
super::ralph_launcher::RalphHomeAction::Audit => RalphRootAction::Audit,
super::ralph_launcher::RalphHomeAction::Replan => RalphRootAction::Replan,
super::ralph_launcher::RalphHomeAction::RebuildLoopContext => {
self.chat
.app
.set_status("Ralph rebuild is unavailable from this surface".to_owned());
return super::invalidation::UiInvalidation::Structural;
}
super::ralph_launcher::RalphHomeAction::Start => {
self.loop_state.open_ralph_start_dialog(
self.settings.launch_working_directory(),
&mut self.chat,
);
return super::invalidation::UiInvalidation::Structural;
}
};
if action.requires_client() {
let repo_root = self.chat.app.working_directory().map_or_else(
|| self.settings.launch_working_directory().to_path_buf(),
std::path::Path::to_path_buf,
);
self.chat
.replace_effect(super::effects::TuiEffect::RalphAction { repo_root, action });
self.chat.app.set_status("running Ralph action…".to_owned());
} else if let Err(error) =
super::ralph_flow::execute_root_local_action(&mut self.chat, action)
{
self.chat
.app
.set_status(format!("Ralph action failed: {error}"));
}
super::invalidation::UiInvalidation::Structural
}
#[cfg(test)]
pub const fn has_plugin_surface(&self) -> bool {
self.loop_state.has_root_plugin_surface()
}
pub fn queue_plugin_surface(
&mut self,
plugin_id: impl Into<String>,
surface: bcode_plugin_sdk::tui::BoxedPluginTuiSurface,
) {
self.loop_state
.queue_root_plugin_surface(plugin_id, surface);
let _invalidation = self.synchronize_screen();
self.invalidation = super::invalidation::UiInvalidation::Full;
}
pub fn queue_standalone_plugin_surface(
&mut self,
plugin_id: impl Into<String>,
surface: bcode_plugin_sdk::tui::BoxedPluginTuiSurface,
) {
self.queue_plugin_surface(plugin_id, surface);
self.exit_after_plugin_surface = true;
}
pub const fn take_plugin_surface_result(
&mut self,
) -> Option<(String, Option<serde_json::Value>)> {
self.plugin_surface_result.take()
}
fn handle_permission_key(
&mut self,
stroke: bmux_keyboard::KeyStroke,
) -> super::invalidation::UiInvalidation {
let Some(action) = self
.settings
.keymap()
.action_for_key(super::keymap::BmuxScope::Permission, stroke)
else {
return super::invalidation::UiInvalidation::None;
};
let Some(dialog) = self.loop_state.permission_dialog.as_mut() else {
return super::invalidation::UiInvalidation::None;
};
match dialog.handle_action(action) {
super::permission_dialog::PermissionDialogOutcome::FocusChanged(label) => {
self.chat
.app
.set_status(format!("permission choice: {label}"));
}
super::permission_dialog::PermissionDialogOutcome::Resolve(resolution) => {
self.queue_permission_resolution(resolution);
}
super::permission_dialog::PermissionDialogOutcome::Ignored => {
return super::invalidation::UiInvalidation::None;
}
}
super::invalidation::UiInvalidation::Structural
}
fn queue_permission_resolution(
&mut self,
resolution: super::permission_dialog::PermissionDialogResolution,
) {
let Some(dialog) = self.loop_state.permission_dialog.as_ref() else {
return;
};
let permission_id = dialog.permission().permission_id.clone();
let batch_id = dialog
.permission()
.batch
.as_ref()
.map(|batch| batch.batch_id.clone());
self.chat
.start_effect(super::effects::TuiEffect::ResolvePermission {
permission_id,
approved: resolution.approved,
remember: resolution.remember,
apply_to_batch: resolution.apply_to_batch,
batch_id,
});
self.loop_state.permission_dialog = None;
self.chat
.app
.set_status(format!("resolving permission: {}", resolution.label));
}
fn apply_plugin_surface_close(
&mut self,
plugin_id: &str,
outcome: Option<serde_json::Value>,
) -> super::invalidation::UiInvalidation {
if self.exit_after_plugin_surface {
self.plugin_surface_result = Some((plugin_id.to_owned(), outcome));
self.exit_requested = true;
return super::invalidation::UiInvalidation::Structural;
}
if plugin_id == "bcode.ralph" {
let action = outcome
.and_then(|value| value.get("ralph_action").cloned())
.and_then(|value| serde_json::from_value(value).ok());
if let Some(action) = action {
return self.handle_ralph_surface_action(action);
}
self.chat.app.set_status("Ralph UI closed".to_owned());
return super::invalidation::UiInvalidation::Structural;
}
super::palette_flow::apply_plugin_surface_outcome(&mut self.chat, plugin_id, outcome);
super::invalidation::UiInvalidation::Structural
}
fn finish_terminal_route_update(
&mut self,
damage: super::invalidation::UiInvalidation,
) -> super::invalidation::UiInvalidation {
damage.merge(self.synchronize_screen())
}
#[allow(clippy::let_and_return, clippy::too_many_lines)]
fn route_terminal_event(&mut self, event: Event) -> super::invalidation::UiInvalidation {
let damage = self.handle_basic_terminal_event(event);
if let Some(session_id) = self.chat.app.take_session_link_request() {
super::session_flow::start_switch_session(
&mut self.chat,
session_id,
super::history_flow::initial_transcript_window_request(self.committed_area),
);
}
self.finish_terminal_route_update(damage)
}
fn return_from_borrowed_chat(&mut self, event: &Event) -> bool {
if self.loop_state.root_plugin_session_is_attached()
&& let Event::Key(stroke) = event
&& self
.settings
.keymap()
.action_for_key(super::keymap::BmuxScope::Chat, *stroke)
== Some(super::keymap::BmuxAction::AppExit)
&& let Some(session_id) = self
.loop_state
.root_plugin_surface_pending_session_navigation()
{
let _completed = self
.loop_state
.complete_root_plugin_session_navigation(session_id, Ok(()));
self.resume_screen_after_session_navigation();
return true;
}
false
}
#[allow(clippy::let_and_return, clippy::too_many_lines)]
fn apply_root_surface_event_result(
&mut self,
result: bcode_plugin_sdk::tui::PluginTuiEventResult,
) -> super::invalidation::UiInvalidation {
let has_effect = result.outcome == bmux_tui::event::EventOutcome::Redraw
|| !matches!(result.action, bcode_plugin_sdk::tui::PluginTuiAction::None);
match result.action {
bcode_plugin_sdk::tui::PluginTuiAction::None
| bcode_plugin_sdk::tui::PluginTuiAction::Redraw => {}
bcode_plugin_sdk::tui::PluginTuiAction::Close { outcome } => {
if let Some((plugin_id, outcome)) = self
.loop_state
.close_root_plugin_surface_with_outcome(outcome)
{
return self.apply_plugin_surface_close(&plugin_id, outcome);
}
}
bcode_plugin_sdk::tui::PluginTuiAction::OpenSession { session_id } => {
self.suspend_screen_for_session_navigation();
self.loop_state
.suspend_root_plugin_surface_for_session(session_id);
super::session_flow::start_switch_session(
&mut self.chat,
session_id,
super::history_flow::initial_transcript_window_request(self.committed_area),
);
}
bcode_plugin_sdk::tui::PluginTuiAction::OpenSurface {
plugin_id,
surface_id,
options,
} => {
self.chat
.replace_effect(super::effects::TuiEffect::OpenPluginSurface {
plugin_id,
surface_kind: surface_id.clone(),
instance_id: format!("workflow-related-{surface_id}"),
options,
working_directory: self.settings.launch_working_directory().to_path_buf(),
session_id: self.chat.attached_session_id(),
});
}
bcode_plugin_sdk::tui::PluginTuiAction::SubscribeWorkflowRuns => {
let (updates, requests) = super::plugin_surface_host::subscribe_workflow_views(
self.loop_state.foreground_client(),
self.loop_state
.root_plugin_surface_invalidation()
.expect("plugin surface invalidation"),
);
self.loop_state
.attach_root_plugin_surface_updates(updates, requests);
}
bcode_plugin_sdk::tui::PluginTuiAction::SelectWorkflowRun { run_id } => {
self.loop_state.request_root_workflow_run(run_id);
}
bcode_plugin_sdk::tui::PluginTuiAction::UpdateWorkflowCatalogQuery {
filter,
sort,
group,
search,
} => {
self.loop_state
.request_root_workflow_query(filter, sort, group, search);
}
bcode_plugin_sdk::tui::PluginTuiAction::LoadMoreWorkflowRuns { cursor } => {
self.loop_state.request_more_root_workflow_runs(cursor);
}
bcode_plugin_sdk::tui::PluginTuiAction::InvokePluginCommand {
plugin_id,
command_id,
arguments,
} => {
self.chat
.replace_effect(super::effects::TuiEffect::InvokePluginCommand {
plugin_id,
command_id,
arguments,
working_directory: self.settings.launch_working_directory().to_path_buf(),
session_id: self.chat.attached_session_id(),
});
}
bcode_plugin_sdk::tui::PluginTuiAction::RunCommand { command } => {
self.chat.app.replace_composer_with(&command);
self.loop_state.close_root_plugin_surface();
}
}
if has_effect {
super::invalidation::UiInvalidation::Structural
} else {
super::invalidation::UiInvalidation::None
}
}
#[allow(clippy::let_and_return, clippy::too_many_lines)]
fn handle_basic_terminal_event(&mut self, event: Event) -> super::invalidation::UiInvalidation {
self.synchronize_selection_lifecycle();
if self.return_from_borrowed_chat(&event) {
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.has_root_plugin_surface()
&& !self.loop_state.root_plugin_surface_is_suspended()
{
let result = self
.loop_state
.handle_root_plugin_surface_event(
&event,
&self.loop_state.foreground_client(),
self.fresh_session_settings(),
)
.expect("plugin surface was present");
if result.outcome != bmux_tui::event::EventOutcome::Ignored {
return self.apply_root_surface_event_result(result);
}
}
if self.loop_state.has_session_picker() {
if let Event::Mouse(mouse) = &event
&& matches!(
mouse.kind,
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left)
)
&& let Some(id) = super::mouse_flow::mouse_hit_id(&self.committed_hits, *mouse)
&& self
.loop_state
.handle_session_catalog_control(&mut self.chat, &id)
{
return super::invalidation::UiInvalidation::Structural;
}
match self.loop_state.handle_session_picker_event(
&mut self.chat,
self.settings.keymap(),
&event,
) {
super::chat_loop::SessionPickerRootOutcome::Create => {
super::session_flow::switch_to_draft_session(&mut self.chat);
self.chat
.replace_effect(super::effects::TuiEffect::LoadDraftStatus {
launch_working_directory: self
.settings
.launch_working_directory()
.to_path_buf(),
});
}
super::chat_loop::SessionPickerRootOutcome::SearchHit(hit) => {
match super::session_flow::start_switch_session_from_search_hit(
&mut self.chat,
&hit,
super::history_flow::initial_transcript_window_request(self.committed_area),
) {
Ok(()) => self
.chat
.app
.set_status("opening search result…".to_owned()),
Err(error) => self
.chat
.app
.set_status(format!("search result unavailable: {error:?}")),
}
}
super::chat_loop::SessionPickerRootOutcome::Select(session_id) => {
super::session_flow::start_switch_session(
&mut self.chat,
session_id,
super::history_flow::initial_transcript_window_request(self.committed_area),
);
self.chat.app.set_status("opening session…".to_owned());
}
super::chat_loop::SessionPickerRootOutcome::Canceled => {
self.chat.app.set_status("session picker closed".to_owned());
}
super::chat_loop::SessionPickerRootOutcome::Handled => {}
super::chat_loop::SessionPickerRootOutcome::Unhandled => {
unreachable!("session picker was present");
}
}
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.has_auth_pool_picker() {
match self
.loop_state
.handle_auth_pool_picker_event(&event, self.settings.keymap())
{
super::chat_loop::AuthPoolPickerRootOutcome::Continue => {}
super::chat_loop::AuthPoolPickerRootOutcome::Cancel => {
self.chat
.app
.set_status("auth pool picker closed".to_owned());
}
super::chat_loop::AuthPoolPickerRootOutcome::Promote { pool, profile } => {
self.chat
.start_effect(super::effects::TuiEffect::SetAuthPoolPreference {
pool,
profile: Some(profile),
});
self.chat
.app
.set_status("updating preferred subscription…".to_owned());
}
super::chat_loop::AuthPoolPickerRootOutcome::Clear { pool } => {
self.chat
.start_effect(super::effects::TuiEffect::SetAuthPoolPreference {
pool,
profile: None,
});
self.chat
.app
.set_status("clearing subscription preference…".to_owned());
}
}
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.has_model_picker() {
if let Some((provider_plugin_id, action)) = self
.loop_state
.handle_model_picker_event(self.settings.keymap(), &event)
{
match action {
super::model_flow::ModelPickerAction::Continue => {
if self.loop_state.has_model_picker() {
return super::invalidation::UiInvalidation::Structural;
}
self.chat
.replace_effect(super::effects::TuiEffect::LoadModelPicker {
provider_plugin_id,
});
self.chat.app.set_status("loading models…".to_owned());
}
super::model_flow::ModelPickerAction::Cancel => {
self.chat.app.set_status("model picker closed".to_owned());
}
super::model_flow::ModelPickerAction::Check(model_id) => {
if let Some(generation) = self.loop_state.start_selected_model_check() {
self.chat
.start_effect(super::effects::TuiEffect::CheckSelectedModel {
session_id: self.chat.attached_session_id(),
provider_plugin_id,
model_id,
generation,
});
}
}
super::model_flow::ModelPickerAction::Select(model_id) => {
if let Some(session_id) = self.chat.attached_session_id() {
self.chat
.start_effect(super::effects::TuiEffect::SetSessionModel {
session_id,
provider_plugin_id,
model_id,
});
self.chat.app.set_status("applying model…".to_owned());
} else {
self.chat
.app
.apply_local_model_selection(provider_plugin_id, &model_id);
}
}
}
}
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.has_skill_picker() {
let action = self
.loop_state
.handle_skill_picker_event(self.settings.keymap(), &event)
.expect("skill picker was present");
match action {
super::skill_picker::SkillPickerAction::Continue => {}
super::skill_picker::SkillPickerAction::Cancel => {
self.chat.app.set_status("skill picker closed".to_owned());
}
super::skill_picker::SkillPickerAction::Help(skill_id) => {
self.chat
.replace_effect(super::effects::TuiEffect::DescribeSkill { skill_id });
self.chat
.app
.set_status("loading skill details…".to_owned());
}
super::skill_picker::SkillPickerAction::Activate(skill_id) => {
self.start_root_skill_action(
super::effects::SkillActionKind::Activate,
skill_id,
String::new(),
);
}
super::skill_picker::SkillPickerAction::Deactivate(skill_id) => {
self.start_root_skill_action(
super::effects::SkillActionKind::Deactivate,
skill_id,
String::new(),
);
}
super::skill_picker::SkillPickerAction::Invoke {
skill_id,
arguments,
} => {
self.start_root_skill_action(
super::effects::SkillActionKind::Invoke,
skill_id,
arguments,
);
}
}
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.has_ralph_start_dialog() {
match self
.loop_state
.handle_ralph_start_dialog_event(&event, self.settings.keymap())
.expect("Ralph start dialog was present")
{
super::ralph_start_dialog::RalphStartDialogOutcome::Handled => {}
super::ralph_start_dialog::RalphStartDialogOutcome::Canceled => {
self.chat.app.set_status("Ralph start canceled".to_owned());
}
super::ralph_start_dialog::RalphStartDialogOutcome::Submit => {
let mut dialog = self
.loop_state
.take_ralph_start_dialog()
.expect("Ralph start dialog was present");
if dialog.loop_name_text().is_empty() {
dialog.set_status("Ralph loop name is required");
self.loop_state.restore_ralph_start_dialog(dialog);
} else {
let repo_root = self.chat.app.working_directory().map_or_else(
|| self.settings.launch_working_directory().to_path_buf(),
std::path::Path::to_path_buf,
);
self.chat
.start_effect(super::effects::TuiEffect::RalphStart {
request: super::ralph_flow::RalphStartRequest {
loop_name: dialog.loop_name_text(),
repo_root,
session_id: self.chat.attached_session_id(),
session_title: self
.chat
.app
.session_title()
.map(ToOwned::to_owned),
work_area_path: dialog.work_area_path_text(),
branch: dialog.branch_text(),
validation_commands: dialog.validation_command_texts(),
},
});
self.chat.app.set_status("creating Ralph loop…".to_owned());
}
}
}
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.has_working_directory_dialog() {
match self
.loop_state
.handle_working_directory_dialog_event(self.settings.keymap(), &event)
{
super::chat_loop::WorkingDirectoryDialogRootOutcome::Apply(path) => {
let Some(session_id) = self.chat.attached_session_id() else {
self.chat.app.set_status("no active session".to_owned());
return super::invalidation::UiInvalidation::Structural;
};
self.chat
.start_effect(super::effects::TuiEffect::AttachWorktree {
session_id,
path,
});
}
super::chat_loop::WorkingDirectoryDialogRootOutcome::Canceled => {
self.chat
.app
.set_status("working directory change canceled".to_owned());
}
super::chat_loop::WorkingDirectoryDialogRootOutcome::Handled => {}
super::chat_loop::WorkingDirectoryDialogRootOutcome::Unhandled => {
unreachable!("working-directory dialog was present")
}
}
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.has_worktree_create_dialog() {
match self
.loop_state
.handle_worktree_create_dialog_event(self.settings.keymap(), &event)
{
super::chat_loop::WorktreeCreateDialogRootOutcome::Create {
name,
target,
base,
} => {
let attach_session_id = match target {
super::wt_create_dialog::WorktreeCreateTarget::CurrentSession => {
self.chat.attached_session_id()
}
super::wt_create_dialog::WorktreeCreateTarget::NewSession => None,
};
self.chat
.start_effect(super::effects::TuiEffect::CreateWorktree {
operation_id: format!(
"tui-worktree-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
),
request: bcode_worktree_models::WorktreeCreateRequest {
name,
cwd: Some(
self.chat
.app
.working_directory()
.unwrap_or_else(|| self.settings.launch_working_directory())
.to_path_buf(),
),
path: None,
branch: None,
new_branch: None,
base_ref: Some(base.model()),
detach: false,
force: false,
attach_session_id,
new_session: target
== super::wt_create_dialog::WorktreeCreateTarget::NewSession,
no_setup: false,
},
});
self.chat.app.set_status("creating worktree…".to_owned());
}
super::chat_loop::WorktreeCreateDialogRootOutcome::Canceled => {
self.chat
.app
.set_status("worktree creation canceled".to_owned());
}
super::chat_loop::WorktreeCreateDialogRootOutcome::Handled => {}
super::chat_loop::WorktreeCreateDialogRootOutcome::Unhandled => {
unreachable!("worktree dialog was present")
}
}
return super::invalidation::UiInvalidation::Structural;
}
let damage = match event {
Event::Resize(_) => {
self.loop_state.composer_interaction.painted_list = None;
self.loop_state.composer_interaction.painted_layout = None;
super::invalidation::UiInvalidation::Full
}
Event::Focus(bmux_tui::event::FocusEvent::Lost) => {
self.loop_state.composer_interaction.dismiss();
super::invalidation::UiInvalidation::Structural
}
Event::Focus(_) | Event::Tick => super::invalidation::UiInvalidation::Paint,
Event::Paste(text) => {
if self.loop_state.modal_foreground() {
return super::invalidation::UiInvalidation::None;
}
self.chat.app.reset_input_history_navigation();
self.chat.app.paste_composer_text(&text);
self.chat.app.wake_cursor();
self.loop_state.refresh_slash_palette(&mut self.chat);
self.draft_autosave.observe(&self.chat, Instant::now());
super::invalidation::UiInvalidation::Structural
}
Event::Key(stroke) => {
if !self.loop_state.modal_foreground()
&& self.settings.keymap().action("chat", stroke) == Some("tui.monitor.toggle")
{
self.chat.app.toggle_child_panel();
if let Some(session_id) = self.chat.attached_session_id() {
self.chat
.replace_effect(super::effects::TuiEffect::LoadChildActivity {
session_id,
after: None,
generation: 0,
});
self.chat
.replace_effect(super::effects::TuiEffect::LoadSessionChildren {
parent_id: session_id,
after: None,
generation: 0,
});
}
return super::invalidation::UiInvalidation::Structural;
}
if !self.loop_state.modal_foreground()
&& self.chat.app.child_panel_focused()
&& self.settings.keymap().action("monitor", stroke).is_some()
{
match self.settings.keymap().action("monitor", stroke) {
Some("tui.control.up") => self.chat.app.move_monitor_selection(false),
Some("tui.control.down") => self.chat.app.move_monitor_selection(true),
Some("tui.control.right") => {
if let Some(session_id) = self.chat.app.expand_monitor_selected() {
self.chat.replace_effect(
super::effects::TuiEffect::LoadSessionChildren {
parent_id: session_id,
after: None,
generation: 0,
},
);
self.chat.replace_effect(
super::effects::TuiEffect::LoadChildActivity {
session_id,
after: None,
generation: 0,
},
);
}
}
Some("tui.monitor.nextPage") => {
if let Some((parent_id, after)) = self.chat.app.monitor_next_page() {
self.chat.replace_effect(
super::effects::TuiEffect::LoadSessionChildren {
parent_id,
after: Some(after),
generation: 0,
},
);
}
}
Some("tui.control.submit" | "tui.control.left") => {
let target = if self.settings.keymap().action("monitor", stroke)
== Some("tui.control.left")
{
self.chat
.app
.session_view_snapshot()
.session_summary
.as_ref()
.and_then(|summary| summary.parent_id)
} else {
self.chat.app.selected_monitor_session()
};
if let Some(session_id) = target {
super::session_flow::start_switch_session(
&mut self.chat,
session_id,
super::history_flow::initial_transcript_window_request(
self.committed_area,
),
);
}
}
Some("tui.control.cancel") => self.chat.app.release_child_panel_focus(),
_ => return super::invalidation::UiInvalidation::None,
}
return super::invalidation::UiInvalidation::Structural;
}
if !self.loop_state.modal_foreground()
&& let Some(identity) = &self.focused_visual
{
if self.settings.keymap().action("visual", stroke) == Some("tui.control.cancel")
{
self.focused_visual = None;
return super::invalidation::UiInvalidation::Structural;
}
let host = super::plugin_surface_host::root_host(
bmux_tui_runtime::InvalidationSignal::new(),
self.loop_state.foreground_client(),
self.fresh_session_settings(),
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
);
if self
.chat
.app
.plugin_presentation()
.is_some_and(|presentation| {
presentation.content_event_with_host(
identity,
&Event::Key(stroke),
&host,
)
})
{
return super::invalidation::UiInvalidation::Structural;
}
self.focused_visual = None;
}
if self.loop_state.permission_dialog.is_some() {
return self.handle_permission_key(stroke);
}
if self.loop_state.foreground() == Some(super::foreground::Foreground::Streaming)
&& self.loop_state.handle_streaming_configurator_key(
&mut self.chat,
stroke,
self.settings.keymap(),
)
{
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.foreground() == Some(super::foreground::Foreground::Streaming) {
return super::invalidation::UiInvalidation::None;
}
match self.loop_state.handle_timeline_dialog_key(
&mut self.chat,
stroke,
self.settings.keymap(),
) {
super::chat_loop::TimelineDialogRootOutcome::Handled => {
return super::invalidation::UiInvalidation::Structural;
}
super::chat_loop::TimelineDialogRootOutcome::Jump(entry) => {
self.apply_root_timeline_jump(&entry);
return super::invalidation::UiInvalidation::Structural;
}
super::chat_loop::TimelineDialogRootOutcome::Unhandled => {}
}
if self.loop_state.foreground() == Some(super::foreground::Foreground::Timeline) {
return super::invalidation::UiInvalidation::None;
}
match self.loop_state.handle_thinking_dialog_key(
&mut self.chat,
stroke,
self.settings.keymap(),
) {
super::chat_loop::ThinkingDialogRootOutcome::Handled => {
return super::invalidation::UiInvalidation::Structural;
}
super::chat_loop::ThinkingDialogRootOutcome::Apply {
effort,
summary,
visible,
mode,
} => {
self.apply_root_thinking_dialog(effort, summary, visible, mode);
return super::invalidation::UiInvalidation::Structural;
}
super::chat_loop::ThinkingDialogRootOutcome::Unhandled => {}
}
if self.loop_state.foreground() == Some(super::foreground::Foreground::Thinking) {
return super::invalidation::UiInvalidation::None;
}
if self.loop_state.handle_theme_picker_key(
&mut self.chat,
stroke,
self.settings.keymap(),
) {
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.foreground() == Some(super::foreground::Foreground::Theme) {
return super::invalidation::UiInvalidation::None;
}
if self.loop_state.has_command_palette() {
if let Some(action) = self
.loop_state
.handle_command_palette_key(stroke, self.settings.keymap())
{
self.apply_root_command_action(action);
}
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.has_slash_palette() {
match self.loop_state.handle_slash_palette_key(
&mut self.chat,
stroke,
self.settings.keymap(),
) {
super::chat_loop::SlashPaletteRootOutcome::Handled => {
return super::invalidation::UiInvalidation::Structural;
}
super::chat_loop::SlashPaletteRootOutcome::Submit => {
self.stage_root_submission(bcode_ipc::PromptPlacement::Steering);
return super::invalidation::UiInvalidation::Structural;
}
super::chat_loop::SlashPaletteRootOutcome::Unhandled => {}
}
}
if self.loop_state.modal_foreground() {
return super::invalidation::UiInvalidation::None;
}
if self
.settings
.keymap()
.action_for_key(super::keymap::BmuxScope::Chat, stroke)
== Some(super::keymap::BmuxAction::ClipboardPasteImage)
{
self.paste_clipboard_image();
return super::invalidation::UiInvalidation::Structural;
}
if self
.settings
.keymap()
.action_for_key(super::keymap::BmuxScope::Chat, stroke)
== Some(super::keymap::BmuxAction::TranscriptClearSelection)
{
self.clear_transcript_selection();
return super::invalidation::UiInvalidation::Paint;
}
if self
.settings
.keymap()
.action_for_key(super::keymap::BmuxScope::Chat, stroke)
== Some(super::keymap::BmuxAction::TranscriptCopySelection)
{
self.copy_transcript_selection();
return super::invalidation::UiInvalidation::Structural;
}
if self
.settings
.keymap()
.action_for_key(super::keymap::BmuxScope::Chat, stroke)
== Some(super::keymap::BmuxAction::CommandPaletteOpen)
{
self.loop_state.open_command_palette(&mut self.chat);
return super::invalidation::UiInvalidation::Structural;
}
if self
.settings
.keymap()
.action_for_key(super::keymap::BmuxScope::Chat, stroke)
== Some(super::keymap::BmuxAction::SessionSearchOpen)
{
self.loop_state.open_session_search(&mut self.chat);
return super::invalidation::UiInvalidation::Structural;
}
let outcome =
super::input::handle_key(&mut self.chat.app, self.settings.keymap(), stroke);
match outcome.request {
super::input::KeyRequest::None => {}
super::input::KeyRequest::Interrupt => {
super::chat_loop::start_cancel_turn(&mut self.chat, &mut self.loop_state);
}
super::input::KeyRequest::CycleAgent => {
super::chat_loop::cycle_session_agent(&mut self.chat);
}
super::input::KeyRequest::CycleThinkingEffort => {
super::thinking_flow::cycle_thinking_effort(&mut self.chat);
}
super::input::KeyRequest::Submit { placement } => {
self.stage_root_submission(placement);
}
}
self.loop_state.refresh_slash_palette(&mut self.chat);
self.draft_autosave.observe(&self.chat, Instant::now());
if outcome.redraw {
super::invalidation::UiInvalidation::Structural
} else {
super::invalidation::UiInvalidation::None
}
}
Event::Mouse(mouse) => {
if !self.loop_state.modal_foreground()
&& self.chat.app.child_panel_open()
&& matches!(
mouse.kind,
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left)
)
&& super::mouse_flow::mouse_hit_id(&self.committed_hits, mouse).as_deref()
== Some("child-monitor-more")
&& let Some((parent_id, after)) = self.chat.app.monitor_next_page()
{
self.chat
.start_effect(super::effects::TuiEffect::LoadSessionChildren {
parent_id,
after: Some(after),
generation: 0,
});
return super::invalidation::UiInvalidation::Structural;
}
if !self.loop_state.modal_foreground()
&& self.chat.app.child_panel_open()
&& matches!(
mouse.kind,
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left)
)
&& let Some(hit) = super::mouse_flow::mouse_hit_id(&self.committed_hits, mouse)
&& let Some(id) = hit
.strip_prefix("child-monitor:")
.or_else(|| hit.strip_prefix("child-monitor-expand:"))
&& let Ok(session_id) = id.parse()
{
self.chat.app.select_monitor_session(session_id);
if hit.starts_with("child-monitor-expand:")
&& let Some(parent_id) = self.chat.app.expand_monitor_selected()
{
self.chat
.start_effect(super::effects::TuiEffect::LoadSessionChildren {
parent_id,
after: None,
generation: 0,
});
}
return super::invalidation::UiInvalidation::Structural;
}
if !self.loop_state.modal_foreground()
&& matches!(
mouse.kind,
bmux_tui::event::MouseEventKind::ScrollLeft
| bmux_tui::event::MouseEventKind::ScrollRight
| bmux_tui::event::MouseEventKind::Down(
bmux_tui::event::MouseButton::Right
)
)
&& let Some(identity) =
self.committed_visual_targets
.iter()
.find_map(|(area, identity)| {
area.contains(mouse.position).then_some(identity)
})
&& let Some(presentation) = self.chat.app.plugin_presentation()
&& presentation.content_event_with_host(
identity,
&Event::Mouse(mouse),
&super::plugin_surface_host::root_host(
bmux_tui_runtime::InvalidationSignal::new(),
self.loop_state.foreground_client(),
self.fresh_session_settings(),
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
),
)
{
if matches!(mouse.kind, bmux_tui::event::MouseEventKind::Down(_)) {
self.focused_visual = Some(identity.clone());
}
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.foreground() == Some(super::foreground::Foreground::Permission) {
let hit_id = super::mouse_flow::mouse_hit_id(&self.committed_hits, mouse);
let changed = super::mouse_flow::handle_permission_action_mouse(
hit_id.as_deref(),
&mut self.chat,
&mut self.loop_state.permission_dialog,
mouse,
);
return if changed {
super::invalidation::UiInvalidation::Structural
} else {
super::invalidation::UiInvalidation::None
};
}
if !self.loop_state.modal_foreground()
&& matches!(
mouse.kind,
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left)
)
&& let Some(hit) = super::mouse_flow::mouse_hit_id(&self.committed_hits, mouse)
&& let Some((index, identity)) = hit
.strip_prefix("activity-inspect:")
.and_then(|value| value.split_once(':'))
&& let (Ok(index), Ok(identity)) =
(index.parse::<usize>(), identity.parse::<u64>())
&& self
.chat
.app
.transcript()
.get(index)
.is_some_and(|item| item.id().get() == identity)
&& let Some((plugin_id, surface_kind, options)) =
self.chat.app.activity_inspection(index)
{
self.chat
.replace_effect(super::effects::TuiEffect::OpenPluginSurface {
plugin_id,
instance_id: format!("activity-{surface_kind}"),
surface_kind,
options,
working_directory: self
.settings
.launch_working_directory()
.to_path_buf(),
session_id: self.chat.attached_session_id(),
});
return super::invalidation::UiInvalidation::Structural;
}
if let Some(selection_damage) = self.handle_transcript_selection_mouse(mouse) {
return selection_damage;
}
if self.loop_state.foreground() == Some(super::foreground::Foreground::Streaming)
&& self.loop_state.handle_streaming_configurator_mouse(
&mut self.chat,
mouse,
self.committed_area,
)
{
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.foreground() == Some(super::foreground::Foreground::Theme)
&& self.loop_state.handle_theme_picker_mouse(
&mut self.chat,
mouse,
self.committed_area,
)
{
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.foreground() == Some(super::foreground::Foreground::Commands) {
if let Some(action) = self
.loop_state
.handle_command_palette_mouse(mouse, self.committed_area)
{
self.apply_root_command_action(action);
}
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.foreground() == Some(super::foreground::Foreground::Slash) {
let _handled = self.loop_state.handle_slash_palette_mouse(
&mut self.chat,
mouse,
self.committed_area,
);
return super::invalidation::UiInvalidation::Structural;
}
if self.loop_state.modal_foreground() {
return super::invalidation::UiInvalidation::None;
}
let hit_id = super::mouse_flow::mouse_hit_id(&self.committed_hits, mouse);
let changed = if self.loop_state.permission_dialog.is_some() {
super::mouse_flow::handle_permission_action_mouse(
hit_id.as_deref(),
&mut self.chat,
&mut self.loop_state.permission_dialog,
mouse,
)
} else {
super::mouse_flow::handle_non_permission_mouse(
hit_id.as_deref(),
&mut self.chat,
mouse,
self.settings.mouse_scroll_rows(),
)
};
if changed {
super::invalidation::UiInvalidation::Structural
} else {
super::invalidation::UiInvalidation::None
}
}
Event::User(_) => {
self.deferred
.push_back(BcodeRuntimeMessage::Terminal(event));
super::invalidation::UiInvalidation::None
}
};
damage
}
fn handle_transcript_selection_mouse(
&mut self,
mouse: bmux_tui::event::MouseEvent,
) -> Option<super::invalidation::UiInvalidation> {
if self.loop_state.modal_foreground()
|| self.loop_state.has_command_palette()
|| self.loop_state.has_slash_palette()
|| self
.committed_layout
.is_none_or(|layout| !layout.body().contains(mouse.position))
&& self.transcript_selection.phase()
== bmux_tui::selection::SelectionGesturePhase::Idle
{
return None;
}
self.synchronize_selection_lifecycle();
let had_selection = self
.selected_plain_text
.as_ref()
.is_some_and(|text| !text.is_empty());
let outcome = self
.transcript_selection
.handle_mouse(&self.committed_selection, mouse);
match outcome {
bmux_tui::selection::SelectionOutcome::Ignored => None,
bmux_tui::selection::SelectionOutcome::Click => {
self.cancel_selection_clear();
self.refresh_selected_plain_text();
None
}
bmux_tui::selection::SelectionOutcome::Armed => {
self.cancel_selection_clear();
self.refresh_selected_plain_text();
if had_selection {
self.presentation_damage = bmux_tui::damage::Damage::Full;
self.fast_temporal_presentation = false;
Some(super::invalidation::UiInvalidation::Paint)
} else {
Some(super::invalidation::UiInvalidation::None)
}
}
bmux_tui::selection::SelectionOutcome::Changed { .. } => {
self.refresh_selected_plain_text();
let scrolled = self.settings.selection_autoscroll()
&& self.committed_layout.is_some_and(|layout| {
scroll_transcript_for_selection_edge(
&mut self.chat.app,
layout.body(),
mouse.position,
)
});
self.presentation_damage = bmux_tui::damage::Damage::Full;
self.fast_temporal_presentation = false;
Some(if scrolled {
super::invalidation::UiInvalidation::Structural
} else {
super::invalidation::UiInvalidation::Paint
})
}
bmux_tui::selection::SelectionOutcome::Completed => {
self.refresh_selected_plain_text();
if self.settings.selection().auto_copy_on_mouse_release {
self.copy_transcript_selection();
}
self.presentation_damage = bmux_tui::damage::Damage::Full;
self.fast_temporal_presentation = false;
Some(super::invalidation::UiInvalidation::Paint)
}
bmux_tui::selection::SelectionOutcome::Cleared
| bmux_tui::selection::SelectionOutcome::Invalidated => {
self.clear_transcript_selection();
self.presentation_damage = bmux_tui::damage::Damage::Full;
self.fast_temporal_presentation = false;
Some(super::invalidation::UiInvalidation::Paint)
}
}
}
fn markdown_selection_has_visible_coverage(
&self,
snapshot: &bmux_tui::selection::SelectionSnapshot,
) -> bool {
let Some(layout) = self.committed_layout else {
return false;
};
let fragments = self.committed_selection.fragments();
for item in self.chat.app.transcript() {
let prefix = format!("bcode.transcript.item.{}.markdown.code.", item.id().get());
let has_code_slices = snapshot
.slices
.iter()
.any(|slice| slice.content_id.as_str().starts_with(&prefix));
if !has_code_slices {
continue;
}
let Some(projection) = super::render::transcript_markdown_selection_projection(
&self.chat.app,
item,
layout.body().width,
) else {
return false;
};
if !projection.has_selection_source(item.text()) {
return false;
}
let Some(resolved) = resolved_transcript_selection_ranges(item, snapshot) else {
return false;
};
let item_prefix = format!("bcode.transcript.item.{}.markdown", item.id().get());
for unit in projection.selection_provenance() {
if !matches!(
unit.kind,
bcode_markdown_render::MarkdownSelectionKind::Text
| bcode_markdown_render::MarkdownSelectionKind::Code
) {
continue;
}
for range in &unit.source_ranges {
if !resolved
.iter()
.any(|selected| selected.start < range.end && range.start < selected.end)
{
continue;
}
let Some(source) = item.text().get(range.clone()) else {
return false;
};
if source.chars().all(char::is_whitespace) && unit.rects.is_empty() {
continue;
}
if !fragments.iter().any(|fragment| {
fragment.content_id.as_str().starts_with(&item_prefix)
&& fragment.source_range.start <= range.start
&& fragment.source_range.end >= range.end
&& fragment.revision == item.revision()
}) {
return false;
}
}
}
}
true
}
fn refresh_selected_plain_text(&mut self) {
if self
.transcript_selection
.reconcile(&self.committed_selection)
== bmux_tui::selection::SelectionOutcome::Invalidated
{
self.clear_transcript_selection();
return;
}
self.selected_plain_text = self
.transcript_selection
.snapshot(&self.committed_selection)
.and_then(|snapshot| {
if !self.markdown_selection_has_visible_coverage(&snapshot) {
return None;
}
let mut parts = Vec::new();
let mut start = 0;
while start < snapshot.slices.len() {
let visual = snapshot.slices[start]
.content_id
.as_str()
.starts_with("bcode.visual:");
let mut end = start + 1;
while end < snapshot.slices.len()
&& snapshot.slices[end]
.content_id
.as_str()
.starts_with("bcode.visual:")
== visual
{
end += 1;
}
let mut group = snapshot.clone();
group.slices = snapshot.slices[start..end].to_vec();
parts.push(if visual {
export_visual_selection(&group, &self.committed_visual_text)?
} else {
export_plain_transcript_selection(self.chat.app.transcript(), &group)?
});
start = end;
}
(!parts.is_empty()).then(|| parts.join("\n\n"))
})
.filter(|text| !text.is_empty());
self.chat
.app
.set_transcript_selection_pinned(self.selected_plain_text.is_some());
}
fn start_root_skill_action(
&mut self,
action: super::effects::SkillActionKind,
skill_id: bcode_skill_models::SkillId,
arguments: String,
) {
super::skill_flow::start_skill_action(
self.settings.launch_working_directory(),
self.settings.launch_options(),
&mut self.chat,
action,
skill_id,
arguments,
);
}
fn apply_root_timeline_jump(&mut self, entry: &super::timeline_dialog::TimelineEntry) {
if let Some(index) = self
.chat
.app
.transcript_index_for_sequence(entry.sequence())
&& self.chat.app.jump_to_transcript_index(index)
{
self.pending_timeline_jump = None;
self.chat
.app
.set_status("jumped to timeline message".to_owned());
return;
}
let Some(session_id) = self.chat.attached_session_id() else {
self.chat
.app
.set_status("timeline requires an active session".to_owned());
return;
};
let generation = self.chat.app.history_window_generation();
let sequence = entry.sequence();
self.pending_timeline_jump = Some((session_id, generation, sequence));
self.chat
.replace_effect(super::effects::TuiEffect::LoadTimelineJump {
session_id,
generation,
sequence,
});
self.chat
.app
.set_status("loading timeline message…".to_owned());
}
fn synchronize_selection_lifecycle(&mut self) {
let session = self.chat.app.session_id();
let config = self.settings.selection();
let policy = (
config.auto_copy_on_mouse_release,
config.clear_after_copy,
config.clear_delay_ms,
);
if self.selection_session != session {
self.clear_transcript_selection();
self.committed_selection = bmux_tui::selection::SelectionScene::new();
self.selection_session = session;
}
if self.selection_policy != policy {
self.cancel_selection_clear();
self.selection_policy = policy;
}
}
fn cancel_selection_clear(&mut self) {
let id = RootTimer::SelectionClear(self.selection_clear_generation).id();
if let Some(handle) = &self.runtime_handle {
let _cancelled = handle.cancel_timer(&id);
}
self.scheduled_deadlines.remove(&id);
self.selection_clear_at = None;
self.selection_clear_generation = self.selection_clear_generation.wrapping_add(1);
}
fn clear_transcript_selection(&mut self) {
self.cancel_selection_clear();
self.transcript_selection.clear();
self.invalidation = self
.invalidation
.merge(super::invalidation::UiInvalidation::Paint);
self.selected_plain_text = None;
self.chat.app.set_transcript_selection_pinned(false);
self.presentation_damage = bmux_tui::damage::Damage::Full;
self.fast_temporal_presentation = false;
}
fn clear_transcript_selection_if_due(
&mut self,
now: Instant,
) -> super::invalidation::UiInvalidation {
self.synchronize_selection_lifecycle();
if self
.selection_clear_at
.is_some_and(|deadline| deadline <= now)
{
self.clear_transcript_selection();
super::invalidation::UiInvalidation::Paint
} else {
super::invalidation::UiInvalidation::None
}
}
fn copy_transcript_selection(&mut self) {
self.synchronize_selection_lifecycle();
self.refresh_selected_plain_text();
self.cancel_selection_clear();
let Some(text) = self
.selected_plain_text
.as_deref()
.filter(|text| !text.is_empty())
else {
self.chat
.app
.set_status("no transcript selection".to_owned());
return;
};
let result = (self.transcript_clipboard)(text);
self.report_transcript_copy_result(result);
}
fn report_transcript_copy_result<E: std::fmt::Display>(&mut self, result: Result<(), E>) {
match result {
Ok(()) => {
self.chat
.app
.set_status("transcript selection copied".to_owned());
if self.settings.selection().clear_after_copy {
let delay = self.settings.selection().clear_delay_ms;
if delay == 0 {
self.clear_transcript_selection();
} else {
self.selection_clear_at =
Instant::now().checked_add(Duration::from_millis(delay));
}
}
}
Err(error) => self.chat.app.set_status(format!("copy failed: {error}")),
}
}
fn paste_clipboard_image(&mut self) {
let working_directory = self.chat.app.working_directory().map_or_else(
|| self.settings.launch_working_directory().to_path_buf(),
std::path::Path::to_path_buf,
);
match super::clipboard_image::save_clipboard_image(
self.chat.app.session_id(),
&working_directory,
) {
Ok(artifact) => {
let text = super::clipboard_image::pasted_image_text(&artifact.model);
self.chat.app.reset_input_history_navigation();
self.chat.app.paste_composer_text(&text);
self.chat.app.wake_cursor();
self.chat.app.set_status(format!(
"Image pasted: {}; source saved in session artifacts",
bcode_plugin_sdk::path::display_from_current_dir(&artifact.model)
));
}
Err(error) => self
.chat
.app
.set_status(format!("image paste failed: {error}")),
}
}
fn apply_root_thinking_dialog(
&mut self,
effort: Option<String>,
summary: Option<String>,
visible: bool,
mode: bcode_config::TuiThinkingMode,
) {
self.chat.app.set_reasoning_visible(visible);
self.chat.app.set_reasoning_display_mode(mode);
let effort_generation = effort
.as_ref()
.filter(|effort| self.chat.app.reasoning_effort() != Some(effort.as_str()))
.map(|effort| self.chat.app.set_pending_reasoning_effort(effort.clone()));
if let Some(session_id) = self.chat.attached_session_id() {
self.chat
.start_effect(super::effects::TuiEffect::SetSessionReasoning {
session_id,
effort,
summary,
effort_generation,
status: "reasoning output settings applied".to_owned(),
});
self.chat.app.set_status("setting thinking…".to_owned());
} else {
self.chat.app.apply_reasoning_selection(effort, summary);
self.chat.app.set_status(format!(
"reasoning output settings applied: {}",
self.chat.app.thinking_label()
));
}
}
fn stage_root_submission(&mut self, placement: bcode_ipc::PromptPlacement) {
let launch_working_directory = self.settings.launch_working_directory().to_path_buf();
match super::composer_flow::stage_root_submission(
&launch_working_directory,
self.settings.launch_options(),
&mut self.chat,
placement,
) {
super::composer_flow::RootSubmission::MessageStaged(staged) => {
if staged {
self.draft_autosave.mark_dirty_now();
}
}
super::composer_flow::RootSubmission::SlashCommand(message) => {
if matches!(
message.trim(),
"/queue-resume" | "/queue-cancel" | "/queue-confirm" | "/queue-dismiss"
) {
self.stage_queue_recovery(message.trim());
return;
}
let working_directory = self
.chat
.app
.working_directory()
.unwrap_or_else(|| self.settings.launch_working_directory())
.to_path_buf();
self.chat
.replace_effect(super::effects::TuiEffect::ExecuteSlashCommand {
session_id: self.chat.attached_session_id(),
working_directory,
current_agent_id: self.chat.app.current_agent_id().to_owned(),
reasoning_display_mode: self.chat.app.reasoning_display_mode(),
reasoning_visible: self.chat.app.reasoning_visible(),
message,
});
self.chat
.app
.set_status("running slash command…".to_owned());
}
}
}
fn stage_queue_recovery(&mut self, command: &str) {
use bcode_session_view_models::SessionViewAction;
if command == "/queue-dismiss" {
self.chat.app.dismiss_queue_confirmation();
return;
}
let Some(session_id) = self.chat.attached_session_id() else {
return;
};
let Some(recovery) = self
.chat
.app
.session_view_snapshot()
.runtime
.queue_recovery()
else {
self.chat
.app
.set_status("No queued message needs recovery".to_owned());
self.chat.app.dismiss_queue_confirmation();
return;
};
let action = match command {
"/queue-resume" if recovery.can_resume => SessionViewAction::PrepareSubmissionResume {
session_id,
submission_id: recovery.submission_id,
expected_accepted_sequence: recovery.expected_accepted_sequence,
},
"/queue-cancel" if recovery.can_cancel => SessionViewAction::CancelPendingSubmission {
session_id,
operation_id: format!(
"cancel:{}:{}",
recovery.submission_id.0, recovery.expected_accepted_sequence
),
submission_id: recovery.submission_id,
expected_accepted_sequence: recovery.expected_accepted_sequence,
},
"/queue-confirm" if recovery.can_resume => {
if !self.chat.app.queue_recovery.settings_visible {
self.chat.app.set_status(
"Enlarge the terminal to review all resume settings before confirming"
.to_owned(),
);
return;
}
let Some(confirmation) = self.chat.app.queue_recovery.confirmation.take() else {
return;
};
if confirmation.submission_id != recovery.submission_id
|| confirmation.expected_accepted_sequence
!= recovery.expected_accepted_sequence
{
return;
}
SessionViewAction::ResumeSubmission {
session_id,
operation_id: uuid::Uuid::new_v4().to_string(),
confirmation,
}
}
_ => {
self.chat.app.set_status(recovery.reason);
return;
}
};
self.chat.app.queue_recovery.generation =
self.chat.app.queue_recovery.generation.wrapping_add(1);
let generation = self.chat.app.queue_recovery.generation;
self.chat
.start_effect(super::effects::TuiEffect::QueueRecovery {
generation,
session_id,
action: Box::new(action),
});
}
fn apply_root_command_action(&mut self, request: super::chat_loop::CommandDispatchRequest) {
let super::chat_loop::CommandDispatchRequest { action, session } = request;
match action {
bcode_command::CommandAction::Host { route } => match route.as_str() {
"session.new" => {
super::session_flow::switch_to_draft_session(&mut self.chat);
self.chat
.replace_effect(super::effects::TuiEffect::LoadDraftStatus {
launch_working_directory: self
.settings
.launch_working_directory()
.to_path_buf(),
});
}
"session.switch" | "session.rename" | "session.delete" => {
self.loop_state.open_session_picker(&mut self.chat);
}
"session.search" => self.loop_state.open_session_search(&mut self.chat),
"turn.cancel" => {
super::chat_loop::start_cancel_turn(&mut self.chat, &mut self.loop_state);
}
"context.compact" => {
if let Some(session_id) = self.chat.attached_session_id() {
self.chat
.start_effect(super::effects::TuiEffect::CompactContext { session_id });
self.chat.app.set_status("compacting context…".to_owned());
} else {
self.chat.app.set_status("No active session".to_owned());
}
}
"theme.select" => self.loop_state.open_theme_picker(&mut self.chat),
"streaming.configure" => {
self.loop_state.open_streaming_configurator(&mut self.chat);
}
"help" => {
self.chat.append_durable_presentation_note(
"bcode.host",
self.settings.keymap().help_markdown(),
bcode_command::CommandTextFormat::Markdown,
);
self.chat.app.set_status("help shown".to_owned());
}
route => self
.chat
.app
.set_status(format!("unsupported host command route: {route}")),
},
bcode_command::CommandAction::Plugin {
plugin_id,
command_id,
} => {
let session_id = self.chat.attached_session_id();
if let Some(refusal) = session.refusal(session_id.is_some()) {
self.chat.app.set_status(refusal.message().to_owned());
return;
}
let working_directory = self
.chat
.app
.working_directory()
.unwrap_or_else(|| self.settings.launch_working_directory())
.to_path_buf();
self.chat
.start_effect(super::effects::TuiEffect::InvokePluginCommand {
plugin_id,
command_id,
arguments: None,
working_directory,
session_id,
});
self.chat
.app
.set_status("running plugin command…".to_owned());
}
}
}
fn root_interactive_surface_host_key(
&self,
stroke: bmux_keyboard::KeyStroke,
) -> Option<super::keymap::BmuxAction> {
let action = self
.settings
.keymap()
.action_for_key(super::keymap::BmuxScope::Chat, stroke)?;
(matches!(
action,
super::keymap::BmuxAction::AppExit
| super::keymap::BmuxAction::AppInterrupt
| super::keymap::BmuxAction::InteractionFocusActive
) || matches!(
action,
super::keymap::BmuxAction::TranscriptPageUp
| super::keymap::BmuxAction::TranscriptPageDown
| super::keymap::BmuxAction::TranscriptTop
| super::keymap::BmuxAction::TranscriptBottom
| super::keymap::BmuxAction::TranscriptLineUp
| super::keymap::BmuxAction::TranscriptLineDown
))
.then_some(action)
}
fn interactive_surface_resolution_command(
&self,
interaction_id: String,
resolution: bcode_session_models::ToolExchangeResolution,
) -> bmux_tui_runtime::Command<BcodeRuntimeMessage> {
let client = self.loop_state.foreground_client();
bmux_tui_runtime::Command::start_if_idle(
bmux_tui_runtime::CommandKey::new("bcode.interactive_surface_resolution"),
async move {
let result = bcode_session_view::execute_session_view_action(
&client,
bcode_session_view_models::SessionViewAction::ResolveExchange {
interaction_id,
resolution,
},
)
.await
.and_then(|outcome| match outcome {
bcode_session_view_models::SessionViewActionOutcome::InteractionResolved {
resolved,
} => Ok(resolved),
_ => Err(bcode_client::ClientError::UnexpectedResponse),
});
Some(BcodeRuntimeMessage::InteractiveSurfaceResolved(result))
},
)
}
fn handle_session_changed(&mut self) {
self.composer_field.reset();
self.clear_transcript_selection();
self.selection_session = self.chat.app.session_id();
self.committed_selection = bmux_tui::selection::SelectionScene::new();
self.pending_timeline_jump = None;
self.loop_state
.session_changed(self.chat.viewing_session_id());
self.draft_autosave.reset_for_session_change();
}
fn collect_runtime_work(
&mut self,
handle: &bmux_tui_runtime::RuntimeHandle<BcodeRuntimeMessage>,
) -> Vec<bmux_tui_runtime::Command<BcodeRuntimeMessage>> {
let (mut commands, notes) = self.loop_state.take_pending_effects(&mut self.chat, handle);
if let Some(command) = self.focused_input_command.take() {
commands.push(command);
}
self.ordered_notes.append(notes);
for effect in self.ordered_notes.take_ready() {
commands.push(self.loop_state.ordered_effect_command(effect, handle));
}
if let Some(request) = self.loop_state.next_surface_open_request() {
let keymap = self.settings.keymap().clone();
commands.push(bmux_tui_runtime::Command::start_if_idle(
bmux_tui_runtime::CommandKey::new("bcode.interactive_surface_open"),
async move {
let result = match super::plugin_tui::load_default_runtime_with_static_bundled(
&super::static_bundled_plugins(),
) {
Ok(runtime) => {
super::interactive_surface::InteractiveSurfaceState::open_request(
&runtime, &request, &keymap,
)
.await
.map_err(|error| error.to_string())
}
Err(error) => Err(error.to_string()),
};
Some(BcodeRuntimeMessage::InteractiveSurfaceOpened(result))
},
));
}
if let Some(invalidation) = self.loop_state.root_plugin_surface_invalidation() {
commands.push(bmux_tui_runtime::Command::start_if_idle(
bmux_tui_runtime::CommandKey::new("bcode.plugin_surface_invalidation"),
async move {
invalidation.wait().await;
Some(BcodeRuntimeMessage::PluginSurfaceInvalidated)
},
));
} else {
commands.push(bmux_tui_runtime::Command::cancel(
bmux_tui_runtime::CommandKey::new("bcode.plugin_surface_invalidation"),
));
}
commands
}
fn reconcile_temporal_registry(&mut self) {
let now = Instant::now();
self.temporal_registry.reconcile(
self.chat
.app
.invalidation_requests(now, std::time::SystemTime::now()),
);
}
fn schedule_deadlines(&mut self) {
self.synchronize_selection_lifecycle();
self.reconcile_temporal_registry();
let invalidation_at = self.temporal_registry.next_at();
let Some(handle) = &self.runtime_handle else {
return;
};
let selection_id = RootTimer::SelectionClear(self.selection_clear_generation).id();
let obsolete: Vec<_> = self
.scheduled_deadlines
.keys()
.filter(|id| {
id.as_str().starts_with("bcode.selection_clear.")
&& (**id != selection_id || self.selection_clear_at.is_none())
})
.cloned()
.collect();
for id in obsolete {
let _cancelled = handle.cancel_timer(&id);
self.scheduled_deadlines.remove(&id);
}
let now = Instant::now();
let deadlines = [
(RootTimer::Invalidations, invalidation_at),
(
RootTimer::ArtifactRetry,
self.loop_state.next_artifact_retry_at(),
),
(
RootTimer::StreamingConfigurator,
self.loop_state.streaming_configurator_deadline(now),
),
(
RootTimer::StreamingPresentation,
self.chat.app.next_streaming_presentation_deadline(now),
),
(
RootTimer::SelectionClear(self.selection_clear_generation),
self.selection_clear_at,
),
(RootTimer::DraftSave, self.draft_autosave.next_save_at()),
(
RootTimer::InteractiveSurfaceRetry,
self.loop_state.next_interactive_surface_retry_at(),
),
(
RootTimer::TelemetryFlush,
self.loop_state.next_telemetry_flush_at(),
),
(RootTimer::ThemeReload, Some(self.theme_reload_at)),
(RootTimer::ChildActivity, Some(self.child_activity_at)),
];
for (timer, deadline) in deadlines {
let id = timer.id();
match deadline {
Some(deadline) if self.scheduled_deadlines.get(&id) != Some(&deadline) => {
handle.schedule_timer(id.clone(), deadline);
self.scheduled_deadlines.insert(id, deadline);
}
Some(_) => {}
None => {
let _cancelled = handle.cancel_timer(&id);
self.scheduled_deadlines.remove(&id);
}
}
}
}
fn select_presentation_damage(
&self,
invalidation: super::invalidation::UiInvalidation,
temporal_damage: &[super::app::TemporalDamage],
) -> bmux_tui::damage::Damage {
if invalidation == super::invalidation::UiInvalidation::Paint
&& let Some(layout) = self.committed_layout
{
let regions = temporal_damage.iter().filter_map(|damage| match damage {
super::app::TemporalDamage::Composer => Some(layout.composer()),
super::app::TemporalDamage::Status => Some(layout.status()),
super::app::TemporalDamage::LatestBar => layout.latest_bar(),
super::app::TemporalDamage::Transcript => Some(layout.body()),
super::app::TemporalDamage::Full => None,
});
if !temporal_damage.contains(&super::app::TemporalDamage::Full) {
let damage = bmux_tui::damage::Damage::regions(
regions,
self.committed_area,
bmux_tui::damage::DamagePolicy {
max_regions: 64,
max_area_percent: 100,
},
);
if !damage.is_none() {
return damage;
}
}
}
bmux_tui::damage::Damage::Full
}
fn refresh_child_monitor(&mut self, now: Instant) {
self.child_activity_at = now + Duration::from_secs(2);
if let Some(session_id) = self.chat.attached_session_id() {
self.chat
.start_effect(super::effects::TuiEffect::LoadChildActivity {
session_id,
after: None,
generation: 0,
});
let pending = self.chat.app.monitor_pending_activity_sessions();
let expanded = if pending.is_empty() {
self.chat.app.monitor_discovery_frontier()
} else {
pending.clone()
};
let next = expanded
.iter()
.copied()
.find(|id| self.child_refresh_cursor.is_none_or(|last| *id > last));
if pending.is_empty() && (next.is_none() || self.child_refresh_cursor.is_none()) {
self.chat
.start_effect(super::effects::TuiEffect::LoadSessionChildren {
parent_id: session_id,
after: self.chat.app.monitor_frontier_cursor(session_id),
generation: 0,
});
}
if !expanded.is_empty() {
let child_id = if pending.is_empty() {
next.unwrap_or(expanded[0])
} else {
pending[0]
};
if pending.is_empty() {
self.child_refresh_cursor = Some(child_id);
}
self.chat
.start_effect(super::effects::TuiEffect::LoadSessionChildren {
parent_id: child_id,
after: self.chat.app.monitor_frontier_cursor(child_id),
generation: 0,
});
self.chat
.start_effect(super::effects::TuiEffect::LoadChildActivity {
session_id: child_id,
after: None,
generation: 0,
});
}
}
}
fn handle_timer(
&mut self,
timer: &bmux_tui_runtime::TimerId,
) -> (
super::invalidation::UiInvalidation,
Vec<super::app::TemporalDamage>,
) {
self.scheduled_deadlines.remove(timer);
let now = Instant::now();
if timer.as_str().starts_with("bcode.selection_clear.") {
let damage =
if *timer == RootTimer::SelectionClear(self.selection_clear_generation).id() {
self.clear_transcript_selection_if_due(now)
} else {
super::invalidation::UiInvalidation::None
};
return (damage, Vec::new());
}
let invalidation = match timer.as_str() {
"bcode.invalidations" => {
let due = self.temporal_registry.take_due(now);
return self.chat.app.handle_invalidations_with_damage(&due, now);
}
"bcode.artifact_retry" => {
self.loop_state.start_due_artifact_fetches(now);
super::invalidation::UiInvalidation::None
}
"bcode.streaming_configurator" => {
if self.loop_state.advance_streaming_configurator(now) {
super::invalidation::UiInvalidation::Structural
} else {
super::invalidation::UiInvalidation::None
}
}
"bcode.streaming_presentation" => {
if self.chat.app.advance_streaming_presentation(now) {
return (
super::invalidation::UiInvalidation::Paint,
vec![super::app::TemporalDamage::Transcript],
);
}
super::invalidation::UiInvalidation::None
}
"bcode.draft_save" => {
super::chat_loop::start_draft_save(&mut self.chat, &mut self.draft_autosave);
super::invalidation::UiInvalidation::None
}
"bcode.telemetry_flush" => {
if let Some(handle) = &self.runtime_handle {
self.loop_state.record_runtime_stats(&handle.stats());
}
self.loop_state.flush_telemetry_if_due(now);
super::invalidation::UiInvalidation::None
}
"bcode.child_activity" => {
self.refresh_child_monitor(now);
super::invalidation::UiInvalidation::None
}
"bcode.theme_reload" => {
self.theme_reload_at = now + Duration::from_millis(750);
let signature = super::theme::active_theme_input_signature(&self.chat.app);
if signature == self.theme_input_signature {
return (super::invalidation::UiInvalidation::None, Vec::new());
}
self.theme_input_signature = signature;
self.chat.app.invalidate_theme_catalog();
if let Some(id) = self.chat.app.reload_theme_if_valid().map(str::to_owned) {
self.chat.app.set_status(format!("theme {id} reloaded"));
} else {
self.chat.app.cancel_theme_preview();
self.loop_state.close_theme_picker();
self.chat
.app
.set_status("preview invalidated; restored configured theme".to_owned());
}
super::invalidation::UiInvalidation::Full
}
_ => super::invalidation::UiInvalidation::None,
};
(invalidation, Vec::new())
}
async fn settle_plugin_operations(&mut self) -> Result<(), TuiError> {
for (plugin_id, action) in Box::pin(self.loop_state.settle_root_plugin_effects()).await? {
if let bcode_plugin_sdk::tui::PluginTuiAction::Close { outcome } = action {
self.plugin_surface_result = Some((plugin_id, outcome));
}
}
Ok(())
}
pub fn shutdown_owned_work(&mut self) {
self.loop_state.abort_all_effects();
if let Some(event_task) = self.chat.event_task.take() {
event_task.abort();
}
}
}
#[allow(dead_code)]
pub struct BcodeRuntimePresenter<'a, 'b, W> {
terminal: &'a mut bmux_tui::terminal::Terminal<&'b mut W>,
}
impl<'a, 'b, W> BcodeRuntimePresenter<'a, 'b, W> {
#[allow(dead_code)]
#[must_use]
pub const fn new(terminal: &'a mut bmux_tui::terminal::Terminal<&'b mut W>) -> Self {
Self { terminal }
}
}
impl<W: std::io::Write> bmux_tui_runtime::Presenter<BcodeRuntimeModel>
for BcodeRuntimePresenter<'_, '_, W>
{
type Error = TuiError;
fn resize(&mut self, size: bmux_tui::geometry::Size) {
self.terminal
.resize(bmux_tui::geometry::Rect::new(0, 0, size.width, size.height));
}
fn reset(&mut self, _reason: bmux_tui_runtime::ResetReason) {
self.terminal.reset();
}
fn present(
&mut self,
program: &mut BcodeRuntimeModel,
) -> Result<bmux_tui_runtime::PresentReport, Self::Error> {
let (frame_interval, damage, fast_temporal_presentation) =
program.prepare_root_presentation();
let (draw_stats, layout) = super::chat_loop::draw_chat_frame(
self.terminal,
&mut program.chat,
&mut program.loop_state,
Duration::ZERO,
frame_interval,
damage,
fast_temporal_presentation,
program.committed_layout,
&program.transcript_selection,
)?;
program.synchronize_composer_interaction_with_paint(true);
program.committed_hits = self.terminal.hits().clone();
program.committed_selection = self.terminal.selection().clone();
program.committed_visual_text.clear();
program.committed_visual_targets.clear();
if let Some(layout) = layout {
let body = layout.body();
for (offset, line) in program
.chat
.app
.transcript_layout()
.visible_lines_from_top(
program.chat.app.transcript_top_row(body.height),
body.height,
)
.into_iter()
.enumerate()
{
if line.source != super::transcript_layout::VisibleTranscriptSource::Transcript {
continue;
}
if let Some((identity, _)) = program
.chat
.app
.transcript_layout()
.content_anchor(line.entry_index, line.row_in_entry)
{
let area = bmux_tui::geometry::Rect::new(
body.x,
body.y
.saturating_add(u16::try_from(offset).unwrap_or(u16::MAX)),
body.width,
1,
)
.intersection(body);
if !area.is_empty() {
program
.committed_visual_targets
.push((area, identity.to_owned()));
}
}
if let Some(row) = program
.chat
.app
.transcript_layout()
.selection_row(line.entry_index, line.row_in_entry)
{
program.committed_visual_text.push(row.clone());
}
}
}
if program.focused_visual.as_ref().is_some_and(|focused| {
!program
.committed_visual_targets
.iter()
.any(|(_, identity)| identity == focused)
}) {
program.focused_visual = None;
}
let reconciliation = program
.transcript_selection
.reconcile(&program.committed_selection);
if reconciliation == bmux_tui::selection::SelectionOutcome::Invalidated {
program.clear_transcript_selection();
program.presentation_damage = bmux_tui::damage::Damage::Full;
program.fast_temporal_presentation = false;
} else if program.transcript_selection.phase()
!= bmux_tui::selection::SelectionGesturePhase::Idle
{
program.refresh_selected_plain_text();
}
program.committed_area = self.terminal.area();
if matches!(
program.focused_input_owner,
Some(FocusedInputOwner::Composer)
) {
let area = program.chat.app.composer_content_area();
program
.composer_field
.painted(program.chat.app.composer(), area, area);
}
program.committed_layout = layout;
Ok(bmux_tui_runtime::PresentReport {
changed_cells: draw_stats.changed_cells,
full_repaint: draw_stats.full_repaint,
})
}
}
fn selection_edge_scroll_rows(
body: bmux_tui::geometry::Rect,
point: bmux_tui::geometry::Point,
) -> Option<(bool, usize)> {
if body.is_empty() {
return None;
}
let (backward, intensity) = if point.y <= body.y {
(true, body.y.saturating_sub(point.y).saturating_add(1))
} else if point.y >= body.bottom().saturating_sub(1) {
(
false,
point
.y
.saturating_sub(body.bottom().saturating_sub(1))
.saturating_add(1),
)
} else {
return None;
};
Some((backward, usize::from(intensity.min(body.height.max(1)))))
}
fn scroll_transcript_for_selection_edge(
app: &mut super::app::BmuxApp,
body: bmux_tui::geometry::Rect,
point: bmux_tui::geometry::Point,
) -> bool {
let Some((backward, rows)) = selection_edge_scroll_rows(body, point) else {
return false;
};
if backward {
app.scroll_transcript_up(rows)
} else {
app.scroll_transcript_down(rows)
}
}
fn ensure_transcript_parent_scope(
scene: &mut bmux_tui::selection::SelectionScene,
body: bmux_tui::geometry::Rect,
) {
if body.is_empty()
|| scene
.scopes()
.iter()
.any(|scope| scope.id.as_str() == TRANSCRIPT_SELECTION_SCOPE_ID)
{
return;
}
scene.push_scope(bmux_tui::selection::SelectionScope::new(
TRANSCRIPT_SELECTION_SCOPE_ID,
body,
));
}
#[allow(clippy::redundant_pub_crate)]
pub(super) fn transcript_selection_scene(
app: &super::app::BmuxApp,
body: bmux_tui::geometry::Rect,
) -> bmux_tui::selection::SelectionScene {
let mut scene = bmux_tui::selection::SelectionScene::new();
register_transcript_selection_scene(&mut scene, app, body);
scene
}
#[allow(clippy::too_many_lines)]
fn register_transcript_selection_scene(
scene: &mut bmux_tui::selection::SelectionScene,
app: &super::app::BmuxApp,
body: bmux_tui::geometry::Rect,
) {
ensure_transcript_parent_scope(scene, body);
if body.is_empty() {
return;
}
let top_row = app.transcript_top_row(body.height);
let mut source_ranges =
std::collections::BTreeMap::<usize, Vec<Option<std::ops::Range<usize>>>>::new();
let mut y = body.y;
for visible in app
.transcript_layout()
.visible_lines_from_top(top_row, body.height)
{
if y >= body.bottom() {
break;
}
let Some(row) = app.transcript_layout().line(visible) else {
continue;
};
if visible.source != super::transcript_layout::VisibleTranscriptSource::Transcript {
y = y.saturating_add(1);
continue;
}
let Some(item) = app.transcript().get(visible.entry_index) else {
y = y.saturating_add(1);
continue;
};
let scope_id = format!("bcode.transcript.item.{}", item.id().get());
if !scene
.scopes()
.iter()
.any(|scope| scope.id.as_str() == scope_id)
{
let entry_start = app
.transcript_layout()
.entry_start_row(visible.source, visible.entry_index)
.unwrap_or(visible.row_index);
let entry_rows = app
.transcript_layout()
.entry_row_count(visible.source, visible.entry_index)
.unwrap_or(1);
let visible_start = entry_start.max(top_row);
let visible_end = entry_start
.saturating_add(entry_rows)
.min(top_row.saturating_add(usize::from(body.height)));
scene.push_scope(
bmux_tui::selection::SelectionScope::new(
scope_id.clone(),
bmux_tui::geometry::Rect::new(
body.x,
body.y.saturating_add(
u16::try_from(visible_start.saturating_sub(top_row))
.unwrap_or(u16::MAX),
),
body.width,
u16::try_from(visible_end.saturating_sub(visible_start))
.unwrap_or(u16::MAX),
),
)
.parent(TRANSCRIPT_SELECTION_SCOPE_ID)
.capture(bmux_tui::selection::SelectionCapture::Delegate)
.order(u64::try_from(visible.entry_index).unwrap_or(u64::MAX))
.revision(item.revision()),
);
}
if let Some(selection) = app
.transcript_layout()
.selection_row(visible.entry_index, visible.row_in_entry)
{
for cell in &selection.cells {
let x = body.x.saturating_add(cell.column);
if x.saturating_add(cell.width) > body.right() {
continue;
}
scene.push_fragment(
bmux_tui::selection::SelectionFragment::new(
scope_id.clone(),
format!("bcode.visual:{}", selection.identity),
bmux_tui::geometry::Rect::new(x, y, cell.width, 1),
u64::try_from(visible.row_in_entry).unwrap_or(u64::MAX),
cell.bytes.clone(),
)
.revision(selection.revision),
);
}
y = y.saturating_add(1);
continue;
}
let text = row.plain_text();
if item.text_format() == bcode_session_view_models::TextFormat::Markdown {
let entry_start = app
.transcript_layout()
.entry_start_row(visible.source, visible.entry_index)
.unwrap_or(visible.row_index);
let layout = super::render::TranscriptItemLayout::resolve(
&app.presented_theme(),
item,
body.width,
);
let content_offset = {
let entry_rows = app
.transcript_layout()
.entry_row_count(visible.source, visible.entry_index)
.unwrap_or_default();
let markdown_rows =
super::render::transcript_markdown_projection_for_layout(app, item, body.width)
.as_ref()
.map_or(0, |projection| projection.lines.len());
entry_rows
.saturating_sub(markdown_rows)
.saturating_sub(1)
.saturating_sub(layout.bottom_rows())
};
let document_row = visible
.row_in_entry
.checked_sub(content_offset)
.and_then(|row| u16::try_from(row).ok());
if let Some(document_row) = document_row {
let projection =
super::render::transcript_markdown_selection_projection(app, item, body.width);
if let Some(projection) = projection {
if !projection.has_selection_source(item.text()) {
y = y.saturating_add(1);
continue;
}
let origin = bmux_tui::geometry::Point::new(
body.x.saturating_add(layout.markdown_x()),
body.y.saturating_add(
u16::try_from(
entry_start
.saturating_add(content_offset)
.saturating_sub(top_row),
)
.unwrap_or(u16::MAX),
),
);
for fragment in super::markdown_selection::markdown_selection_fragments(
&scope_id,
&format!("bcode.transcript.item.{}.markdown", item.id().get()),
&projection,
bmux_tui::geometry::Point::new(origin.x, 0),
u64::try_from(visible.entry_index)
.unwrap_or(u64::MAX)
.saturating_mul(1_000_000),
item.revision(),
Some(document_row),
)
.into_iter()
.filter_map(|mut fragment| {
if fragment.area.y != document_row {
return None;
}
fragment.area.y = y;
(fragment.area.x >= body.x && fragment.area.right() <= body.right())
.then_some(fragment)
}) {
scene.push_fragment(fragment);
}
y = y.saturating_add(1);
continue;
}
}
y = y.saturating_add(1);
continue;
}
let ranges = source_ranges.entry(visible.entry_index).or_insert_with(|| {
transcript_plain_row_source_ranges(
item.text.as_str(),
app.transcript_layout()
.entry_row_count(visible.source, visible.entry_index)
.unwrap_or(0),
)
});
let source_mapping = ranges
.get(visible.row_in_entry)
.and_then(Clone::clone)
.and_then(|range| {
item.text
.get(range.clone())
.map(|source| (source, range.start))
})
.or_else(|| {
let mut offset = 0;
item.text().split_inclusive('\n').find_map(|line| {
let source = line.trim_end_matches(['\r', '\n']);
let start = offset;
offset += line.len();
(!source.is_empty() && text.contains(source)).then_some((source, start))
})
});
let Some((source, source_offset)) = source_mapping else {
y = y.saturating_add(1);
continue;
};
let prefix = text
.find(source)
.and_then(|start| text.get(..start))
.unwrap_or("");
let prefix_width =
u16::try_from(bmux_tui::text::Line::raw(prefix).width()).unwrap_or(u16::MAX);
for fragment in bmux_tui::selection::plain_text_fragments(
scope_id,
format!("bcode.transcript.item.{}.plain", item.id().get()),
bmux_tui::geometry::Rect::new(
body.x.saturating_add(prefix_width),
y,
body.width.saturating_sub(prefix_width),
1,
),
u64::try_from(visible.entry_index)
.unwrap_or(u64::MAX)
.saturating_mul(1_000_000)
.saturating_add(u64::try_from(source_offset).unwrap_or(u64::MAX)),
source,
source_offset,
item.revision(),
) {
scene.push_fragment(fragment);
}
y = y.saturating_add(1);
}
}
fn export_visual_selection(
snapshot: &bmux_tui::selection::SelectionSnapshot,
rows: &[bcode_plugin_sdk::tui::PluginTuiSelectionRow],
) -> Option<String> {
export_visual_slices(&snapshot.slices, rows)
}
fn export_visual_slices(
slices: &[bmux_tui::selection::SelectionSlice],
rows: &[bcode_plugin_sdk::tui::PluginTuiSelectionRow],
) -> Option<String> {
let mut output = String::new();
let mut previous = None;
for slice in slices {
let identity = slice.content_id.as_str().strip_prefix("bcode.visual:")?;
if previous.is_some_and(|previous| previous != identity) {
output.push('\n');
}
let mut start = slice.source_range.start;
for row in rows
.iter()
.filter(|row| row.identity == identity && row.revision == slice.revision)
{
let end = row
.byte_start
.checked_add(row.text.len())?
.min(slice.source_range.end);
if start >= row.byte_start && start < end {
output.push_str(row.text.get(start - row.byte_start..end - row.byte_start)?);
start = end;
}
}
if start != slice.source_range.end {
return None;
}
previous = Some(identity);
}
(!output.is_empty()).then_some(output)
}
#[test]
fn visual_copy_joins_soft_rows_and_rejects_stale_source() {
use bcode_plugin_sdk::tui::PluginTuiSelectionRow;
use bmux_tui::selection::{SelectionContentId, SelectionSlice};
let row = |identity: &str, start, text: &str| PluginTuiSelectionRow {
identity: identity.to_owned(),
byte_start: start,
text: text.to_owned(),
cells: Vec::new(),
revision: 7,
};
let rows = vec![
row("line", 0, "界"),
row("line", 3, "e\u{301}"),
row("next", 0, "next"),
];
let slice = |identity: &str, range| SelectionSlice {
content_id: SelectionContentId::new(format!("bcode.visual:{identity}")),
source_range: range,
revision: 7,
};
let mut slices = vec![slice("line", 0..6), slice("next", 0..4)];
assert_eq!(
export_visual_slices(&slices, &rows).as_deref(),
Some("界e\u{301}\nnext")
);
slices[0].revision = 8;
assert_eq!(export_visual_slices(&slices, &rows), None);
}
fn resolved_transcript_selection_ranges(
item: &super::transcript::TranscriptItem,
snapshot: &bmux_tui::selection::SelectionSnapshot,
) -> Option<Vec<std::ops::Range<usize>>> {
let prefix = format!("bcode.transcript.item.{}.", item.id().get());
let endpoints = [&snapshot.anchor, &snapshot.focus];
for endpoint in endpoints {
if endpoint.content_id.as_str().starts_with(&prefix) && endpoint.revision != item.revision()
{
return None;
}
}
let blocks = bcode_markdown_render::markdown_code_block_selections(item.text());
let mut ranges = Vec::new();
for slice in &snapshot.slices {
let Some(kind) = slice.content_id.as_str().strip_prefix(&prefix) else {
continue;
};
if slice.revision != item.revision() {
return None;
}
let selected = if kind.starts_with("markdown.code.")
&& kind.rsplit('.').next() == Some("body")
{
let offsets = endpoints
.into_iter()
.filter(|endpoint| endpoint.content_id.as_str().starts_with(&prefix))
.map(|endpoint| endpoint.offset)
.collect::<Vec<_>>();
match offsets.as_slice() {
[anchor, focus] => (*anchor).min(*focus)..(*anchor).max(*focus),
[offset] => {
if snapshot.anchor.content_id.as_str().starts_with(&prefix) == snapshot.reversed
{
0..*offset
} else {
*offset..item.text().len()
}
}
[] => 0..item.text().len(),
_ => return None,
}
} else {
slice.source_range.clone()
};
if let Some(suffix) = kind.strip_prefix("markdown.") {
ranges.extend(super::markdown_selection::expand_code_selection_range(
item.text(),
&blocks,
suffix,
selected,
)?);
} else if kind == "markdown" || kind == "plain" {
item.text().get(selected.clone())?;
ranges.push(selected);
} else {
return None;
}
}
if snapshot.anchor.content_id != snapshot.focus.content_id
&& endpoints.into_iter().all(|endpoint| {
endpoint
.content_id
.as_str()
.starts_with(&format!("{prefix}markdown"))
})
&& endpoints.into_iter().any(|endpoint| {
endpoint
.content_id
.as_str()
.starts_with(&format!("{prefix}markdown.code."))
})
{
let interval = snapshot.anchor.offset.min(snapshot.focus.offset)
..snapshot.anchor.offset.max(snapshot.focus.offset);
item.text().get(interval.clone())?;
ranges.push(interval);
}
ranges.sort_by_key(|range| (range.start, range.end));
let mut disjoint: Vec<std::ops::Range<usize>> = Vec::new();
for range in ranges {
if let Some(previous) = disjoint.last_mut()
&& range.start <= previous.end
{
previous.end = previous.end.max(range.end);
} else {
disjoint.push(range);
}
}
Some(disjoint)
}
#[must_use]
fn export_plain_transcript_selection<'a>(
items: impl IntoIterator<Item = &'a super::transcript::TranscriptItem>,
snapshot: &bmux_tui::selection::SelectionSnapshot,
) -> Option<String> {
let mut selected = std::collections::BTreeSet::new();
for slice in &snapshot.slices {
let content = slice.content_id.as_str();
let remainder = content.strip_prefix("bcode.transcript.item.")?;
let (item_id, kind) = remainder.split_once('.')?;
let item_id = item_id.parse::<u64>().ok()?;
if kind != "plain" && kind != "markdown" && !kind.starts_with("markdown.code.") {
return None;
}
selected.insert(item_id);
}
let mut output = Vec::new();
for item in items {
if !selected.remove(&item.id().get()) {
continue;
}
let disjoint = resolved_transcript_selection_ranges(item, snapshot)?;
let mut text = String::new();
for range in disjoint {
text.push_str(item.text().get(range)?);
}
if !text.is_empty() {
output.push(format_transcript_selection_item(item, &text));
}
}
if !selected.is_empty() {
return None;
}
(!output.is_empty()).then(|| output.join("\n\n"))
}
#[cfg(test)]
pub fn export_plain_transcript_selection_for_test<'a>(
items: impl IntoIterator<Item = &'a super::transcript::TranscriptItem>,
snapshot: &bmux_tui::selection::SelectionSnapshot,
) -> Option<String> {
export_plain_transcript_selection(items, snapshot)
}
fn format_transcript_selection_item(
item: &super::transcript::TranscriptItem,
text: &str,
) -> String {
use super::transcript::TranscriptItemKind;
let metadata = match item.kind() {
TranscriptItemKind::ToolRequest {
tool_name,
status,
active,
..
} => Some(format!(
"Tool request: {tool_name} [{}]",
status.map_or(
if *active { "active" } else { "requested" },
|status| match status {
bcode_session_view_models::ToolInvocationViewStatus::Requested => "requested",
bcode_session_view_models::ToolInvocationViewStatus::Waiting => "waiting",
bcode_session_view_models::ToolInvocationViewStatus::Running => "running",
bcode_session_view_models::ToolInvocationViewStatus::Finished => "finished",
bcode_session_view_models::ToolInvocationViewStatus::Failed => "failed",
bcode_session_view_models::ToolInvocationViewStatus::Cancelled => "cancelled",
}
)
)),
TranscriptItemKind::ToolResult {
tool_name,
is_error,
timing,
..
} => Some(format!(
"Tool result: {} [{}{}]",
tool_name.as_deref().unwrap_or("unknown"),
if *is_error { "failed" } else { "finished" },
timing
.duration_ms
.map_or_else(String::new, |duration| format!(", {duration}ms"))
)),
TranscriptItemKind::ToolRequestDraft { .. } => {
Some("Tool request draft [pending; rendered-text fallback]".to_owned())
}
TranscriptItemKind::ToolContribution { contribution, .. } => Some(format!(
"Tool contribution: {} v{} by {} [rendered-text fallback]",
contribution.schema, contribution.schema_version, contribution.producer_id
)),
_ => None,
};
metadata.map_or_else(
|| text.to_owned(),
|metadata| {
item.timestamp_ms().map_or_else(
|| format!("{metadata}\n{text}"),
|timestamp| format!("{metadata} @ {timestamp}ms\n{text}"),
)
},
)
}
fn transcript_plain_row_source_ranges(
source: &str,
rendered_rows: usize,
) -> Vec<Option<std::ops::Range<usize>>> {
let source_rows = source
.split_inclusive('\n')
.scan(0_usize, |offset, line| {
let start = *offset;
*offset = offset.saturating_add(line.len());
let content_len = line.strip_suffix('\n').map_or(line.len(), str::len);
Some(start..start.saturating_add(content_len))
})
.collect::<Vec<_>>();
if source_rows.len() != rendered_rows {
return vec![None; rendered_rows];
}
source_rows.into_iter().map(Some).collect()
}
impl bmux_tui_runtime::Program for BcodeRuntimeModel {
type Message = BcodeRuntimeMessage;
type Error = TuiError;
fn presentation_committed(
&mut self,
_report: bmux_tui_runtime::PresentReport,
) -> bmux_tui_runtime::Update<Self::Message> {
self.invalidation = super::invalidation::UiInvalidation::None;
self.last_presented_at = Some(Instant::now());
self.chat.app.commit_transcript_presentation();
self.loop_state.mark_presentation_committed();
let deferred = self
.loop_state
.apply_deferred_session_stream_updates(&mut self.chat);
self.schedule_deadlines();
if deferred {
self.invalidation = super::invalidation::UiInvalidation::Structural;
self.presentation_damage = bmux_tui::damage::Damage::Full;
self.fast_temporal_presentation = false;
bmux_tui_runtime::Update::redraw()
} else {
bmux_tui_runtime::Update::none()
}
}
#[allow(clippy::too_many_lines)]
fn update(
&mut self,
event: bmux_tui_runtime::RuntimeEvent<Self::Message>,
) -> Result<bmux_tui_runtime::Update<Self::Message>, Self::Error> {
self.synchronize_selection_lifecycle();
self.synchronize_composer_interaction();
let mut temporal_damage = Vec::new();
let damage = match event {
bmux_tui_runtime::RuntimeEvent::Message(
BcodeRuntimeMessage::ComposerSearchCompleted(completion),
) => {
if self.loop_state.composer_interaction.complete(completion) {
self.loop_state
.composer_interaction
.resolve_hints(self.settings.keymap().registry());
super::invalidation::UiInvalidation::Structural
} else {
super::invalidation::UiInvalidation::None
}
}
bmux_tui_runtime::RuntimeEvent::Message(BcodeRuntimeMessage::Bootstrap { handle }) => {
self.runtime_handle = Some(handle.clone());
let (_replacement_sender, replacement_receiver) = tokio::sync::mpsc::channel(1);
let mut session_stream =
std::mem::replace(&mut self.chat.event_receiver, replacement_receiver);
let mut artifact_completions = self.loop_state.take_artifact_completion_receiver();
let mut markdown_completions = self.loop_state.take_markdown_completion_receiver();
let session_subscription = bmux_tui_runtime::Subscription::new(
bmux_tui_runtime::SubscriptionKey::new("bcode.session_stream"),
move |sender| async move {
while let Some(update) = session_stream.recv().await {
if sender
.send(BcodeRuntimeMessage::SessionStream(Box::new(update)))
.await
.is_err()
{
break;
}
}
},
);
let artifact_subscription = bmux_tui_runtime::Subscription::new(
bmux_tui_runtime::SubscriptionKey::new("bcode.artifact_completions"),
move |sender| async move {
while let Some(completion) = artifact_completions.recv().await {
if sender
.send(BcodeRuntimeMessage::ArtifactFetchCompleted(Box::new(
completion,
)))
.await
.is_err()
{
break;
}
}
},
);
let markdown_subscription = bmux_tui_runtime::Subscription::new(
bmux_tui_runtime::SubscriptionKey::new("bcode.markdown_completions"),
move |sender| async move {
loop {
if markdown_completions.changed().await.is_err() {
break;
}
let completion = markdown_completions.borrow_and_update().clone();
if sender
.send(BcodeRuntimeMessage::MarkdownProjectionCompleted(Box::new(
completion,
)))
.await
.is_err()
{
break;
}
}
},
);
self.schedule_deadlines();
let mut update = bmux_tui_runtime::Update::redraw()
.with_subscription(session_subscription)
.with_subscription(artifact_subscription)
.with_subscription(markdown_subscription);
for command in self.collect_runtime_work(&handle) {
update = update.with_command(command);
}
return Ok(update);
}
bmux_tui_runtime::RuntimeEvent::Message(BcodeRuntimeMessage::TerminalInputFailed(
error,
)) => {
return Err(std::io::Error::new(
error.kind(),
format!("reading terminal input: {error}"),
)
.into());
}
bmux_tui_runtime::RuntimeEvent::Terminal(event)
| bmux_tui_runtime::RuntimeEvent::Message(BcodeRuntimeMessage::Terminal(event)) => {
if self.return_from_borrowed_chat(&event) {
return Ok(bmux_tui_runtime::Update::redraw());
}
if self.loop_state.has_interactive_surface() && !self.loop_state.modal_foreground()
{
if let Event::Key(stroke) = event
&& !(stroke.key == bmux_keyboard::KeyCode::Escape
&& self.loop_state.composer_interaction.visible())
&& let Some(action) = self.root_interactive_surface_host_key(stroke)
{
match action {
super::keymap::BmuxAction::AppInterrupt => {
let Some((interaction_id, resolution)) =
self.loop_state.dismiss_interactive_surface()
else {
return Ok(bmux_tui_runtime::Update::none());
};
let command = self.interactive_surface_resolution_command(
interaction_id,
resolution,
);
return Ok(bmux_tui_runtime::Update::redraw().with_command(command));
}
super::keymap::BmuxAction::AppExit => self.chat.app.request_exit(),
super::keymap::BmuxAction::InteractionFocusActive => {
if self.chat.app.tui_config().interactions.placement
== bcode_config::TuiInteractionPlacement::Transcript
&& let Some(interaction_id) =
self.loop_state.active_interactive_surface_id()
{
let _changed = self
.chat
.app
.focus_interaction_in_transcript(interaction_id);
}
}
action => {
let _handled = super::input::handle_chat_action(
&mut self.chat.app,
Some(action),
);
}
}
return Ok(bmux_tui_runtime::Update::redraw());
}
if let Event::Mouse(mouse) = event
&& matches!(
mouse.kind,
bmux_tui::event::MouseEventKind::ScrollUp
| bmux_tui::event::MouseEventKind::ScrollDown
)
{
let changed = super::mouse_flow::handle_non_permission_mouse(
None,
&mut self.chat,
mouse,
self.settings.mouse_scroll_rows(),
);
if changed {
self.invalidation = self
.invalidation
.merge(super::invalidation::UiInvalidation::Structural);
self.presentation_damage = bmux_tui::damage::Damage::Full;
self.fast_temporal_presentation = false;
}
return Ok(bmux_tui_runtime::Update::redraw());
}
}
let outcome = self.compose_focused_input_event(&event);
if outcome != bmux_tui::event::EventOutcome::Ignored {
if outcome == bmux_tui::event::EventOutcome::Handled {
return Ok(bmux_tui_runtime::Update::none());
}
self.invalidation = self
.invalidation
.merge(super::invalidation::UiInvalidation::Structural);
self.presentation_damage = bmux_tui::damage::Damage::Full;
self.fast_temporal_presentation = false;
let command = self.focused_input_command.take();
let update = bmux_tui_runtime::Update::redraw();
return Ok(command.map_or(update, |command| {
bmux_tui_runtime::Update::redraw().with_command(command)
}));
}
let route_to_surface = !self.loop_state.modal_foreground()
&& match event {
Event::Mouse(mouse) if self.loop_state.has_interactive_surface() => self
.loop_state
.active_interactive_surface_geometry()
.is_some_and(|geometry| geometry.destination.contains(mouse.position)),
_ => self
.loop_state
.routes_non_mouse_event_to_interactive_surface(
self.chat.app.tui_config().interactions,
),
};
if route_to_surface {
match self.loop_state.handle_interactive_surface_event(&event) {
super::interactive_surface::InteractiveSurfaceEventOutcome::Ignored => {}
super::interactive_surface::InteractiveSurfaceEventOutcome::Consumed => {
return Ok(bmux_tui_runtime::Update::none());
}
super::interactive_surface::InteractiveSurfaceEventOutcome::Redraw => {
self.invalidation = self
.invalidation
.merge(super::invalidation::UiInvalidation::Paint);
let geometry = self
.loop_state
.active_interactive_surface_geometry()
.filter(|geometry| !geometry.destination.is_empty());
self.presentation_damage =
geometry.map_or(bmux_tui::damage::Damage::Full, |geometry| {
bmux_tui::damage::Damage::regions(
[geometry.destination],
self.committed_area,
bmux_tui::damage::DamagePolicy {
max_regions: 1,
max_area_percent: 100,
},
)
});
self.fast_temporal_presentation = geometry.is_some()
&& self.committed_layout.is_some()
&& !self.presentation_damage.is_full()
&& !self.presentation_damage.is_none();
return Ok(bmux_tui_runtime::Update::redraw());
}
super::interactive_surface::InteractiveSurfaceEventOutcome::Relayout => {
self.invalidation = self
.invalidation
.merge(super::invalidation::UiInvalidation::Items);
self.presentation_damage = bmux_tui::damage::Damage::Full;
self.fast_temporal_presentation = false;
return Ok(bmux_tui_runtime::Update::redraw());
}
super::interactive_surface::InteractiveSurfaceEventOutcome::Resolved(
resolution,
) => {
let Some(interaction_id) = self
.loop_state
.active_interactive_surface_id()
.map(ToOwned::to_owned)
else {
return Ok(bmux_tui_runtime::Update::none());
};
let command = self
.interactive_surface_resolution_command(interaction_id, resolution);
return Ok(bmux_tui_runtime::Update::redraw().with_command(command));
}
}
}
self.route_terminal_event(event)
}
bmux_tui_runtime::RuntimeEvent::Message(BcodeRuntimeMessage::Invalidations(keys)) => {
self.chat.app.handle_invalidations(&keys, Instant::now())
}
bmux_tui_runtime::RuntimeEvent::Message(
BcodeRuntimeMessage::StreamingConfiguratorDue,
) => {
if self
.loop_state
.advance_streaming_configurator(Instant::now())
{
super::invalidation::UiInvalidation::Structural
} else {
super::invalidation::UiInvalidation::None
}
}
bmux_tui_runtime::RuntimeEvent::Message(
BcodeRuntimeMessage::StreamingPresentationDue,
) => {
if self.chat.app.advance_streaming_presentation(Instant::now()) {
super::invalidation::UiInvalidation::Paint
} else {
super::invalidation::UiInvalidation::None
}
}
bmux_tui_runtime::RuntimeEvent::Message(BcodeRuntimeMessage::SessionStream(update)) => {
let refresh_permissions = matches!(
update.as_ref(),
super::history_flow::SessionStreamUpdate::Event(event)
if matches!(
event.as_ref(),
bcode_ipc::Event::Session(session_event)
if matches!(
session_event.kind,
bcode_session_models::SessionEventKind::PermissionRequested { .. }
| bcode_session_models::SessionEventKind::PermissionResolved { .. }
)
)
);
let changed = self
.loop_state
.apply_session_stream_update(&mut self.chat, *update);
if refresh_permissions {
self.chat
.replace_effect(super::effects::TuiEffect::ListPermissions);
}
if changed {
super::invalidation::UiInvalidation::Structural
} else {
super::invalidation::UiInvalidation::None
}
}
bmux_tui_runtime::RuntimeEvent::Message(
BcodeRuntimeMessage::PluginSurfaceInvalidated,
) => {
let client = self.loop_state.foreground_client();
let action = self
.loop_state
.poll_root_plugin_surface(&client, self.fresh_session_settings());
self.loop_state.verify_plugin_operations()?;
match action {
Some(bcode_plugin_sdk::tui::PluginTuiAction::Close { outcome }) => {
if let Some((plugin_id, outcome)) = self
.loop_state
.close_root_plugin_surface_with_outcome(outcome)
{
self.apply_plugin_surface_close(&plugin_id, outcome)
} else {
super::invalidation::UiInvalidation::Structural
}
}
Some(bcode_plugin_sdk::tui::PluginTuiAction::OpenSession { session_id }) => {
let initial_window_request =
super::history_flow::initial_transcript_window_request(
super::render::transcript_area_for_frame(
&self.chat.app,
self.committed_area,
),
);
self.suspend_screen_for_session_navigation();
self.loop_state
.suspend_root_plugin_surface_for_session(session_id);
super::session_flow::start_switch_session(
&mut self.chat,
session_id,
initial_window_request,
);
self.handle_session_changed();
super::invalidation::UiInvalidation::Structural
}
Some(bcode_plugin_sdk::tui::PluginTuiAction::OpenSurface {
plugin_id,
surface_id,
options,
}) => {
self.chat
.replace_effect(super::effects::TuiEffect::OpenPluginSurface {
plugin_id,
surface_kind: surface_id.clone(),
instance_id: format!("workflow-related-{surface_id}"),
options,
working_directory: self
.settings
.launch_working_directory()
.to_path_buf(),
session_id: self.chat.attached_session_id(),
});
super::invalidation::UiInvalidation::Structural
}
Some(bcode_plugin_sdk::tui::PluginTuiAction::SubscribeWorkflowRuns) => {
let (updates, requests) =
super::plugin_surface_host::subscribe_workflow_views(
self.loop_state.foreground_client(),
self.loop_state
.root_plugin_surface_invalidation()
.expect("plugin surface invalidation"),
);
self.loop_state
.attach_root_plugin_surface_updates(updates, requests);
super::invalidation::UiInvalidation::Structural
}
Some(bcode_plugin_sdk::tui::PluginTuiAction::SelectWorkflowRun { run_id }) => {
self.loop_state.request_root_workflow_run(run_id);
super::invalidation::UiInvalidation::Structural
}
Some(bcode_plugin_sdk::tui::PluginTuiAction::UpdateWorkflowCatalogQuery {
filter,
sort,
group,
search,
}) => {
self.loop_state
.request_root_workflow_query(filter, sort, group, search);
super::invalidation::UiInvalidation::Structural
}
Some(bcode_plugin_sdk::tui::PluginTuiAction::LoadMoreWorkflowRuns {
cursor,
}) => {
self.loop_state.request_more_root_workflow_runs(cursor);
super::invalidation::UiInvalidation::Structural
}
Some(bcode_plugin_sdk::tui::PluginTuiAction::InvokePluginCommand {
plugin_id,
command_id,
arguments,
}) => {
self.chat
.replace_effect(super::effects::TuiEffect::InvokePluginCommand {
plugin_id,
command_id,
arguments,
working_directory: self
.settings
.launch_working_directory()
.to_path_buf(),
session_id: self.chat.attached_session_id(),
});
super::invalidation::UiInvalidation::Structural
}
Some(bcode_plugin_sdk::tui::PluginTuiAction::RunCommand { command }) => {
self.chat.app.replace_composer_with(&command);
self.loop_state.close_root_plugin_surface();
super::invalidation::UiInvalidation::Structural
}
Some(bcode_plugin_sdk::tui::PluginTuiAction::Redraw) => {
super::invalidation::UiInvalidation::Structural
}
Some(bcode_plugin_sdk::tui::PluginTuiAction::None) | None => {
super::invalidation::UiInvalidation::None
}
}
}
bmux_tui_runtime::RuntimeEvent::Message(
BcodeRuntimeMessage::ArtifactFetchCompleted(completion),
) => {
if self
.loop_state
.apply_artifact_completion(&self.chat, *completion)
{
super::invalidation::UiInvalidation::Items
} else {
super::invalidation::UiInvalidation::None
}
}
bmux_tui_runtime::RuntimeEvent::Message(
BcodeRuntimeMessage::MarkdownProjectionCompleted(completion),
) => {
if (*completion).is_some_and(|completion| {
self.loop_state
.apply_markdown_projection_completion(&mut self.chat, completion)
}) {
super::invalidation::UiInvalidation::Items
} else {
super::invalidation::UiInvalidation::None
}
}
bmux_tui_runtime::RuntimeEvent::Message(BcodeRuntimeMessage::EffectCompleted(
result,
)) => {
let navigation_session = self
.loop_state
.root_plugin_surface_pending_session_navigation();
let navigation_result = match result.as_ref() {
TuiEffectResult::SessionOpened {
session_id, result, ..
} if navigation_session == Some(*session_id) => Some((
*session_id,
result
.as_ref()
.as_ref()
.map(|_| ())
.map_err(ToString::to_string),
)),
_ => None,
};
if let TuiEffectResult::AppendPresentationNote { session_id, .. } = result.as_ref()
{
self.ordered_notes.complete(*session_id);
}
let previous_frame_interval = self.settings.bmux_runtime_config().frame_interval;
let previous_session_id = self.chat.viewing_session_id();
if let TuiEffectResult::TimelineJumpLoaded {
session_id,
generation,
sequence,
..
} = result.as_ref()
{
if self.pending_timeline_jump != Some((*session_id, *generation, *sequence)) {
return Ok(bmux_tui_runtime::Update::default());
}
self.pending_timeline_jump = None;
}
let observation = result.daemon_observation();
self.loop_state.observe_daemon(&mut self.chat, &observation);
super::chat_loop::apply_effect_result(
&mut self.settings,
&mut self.chat,
&mut self.draft_autosave,
&mut self.loop_state,
*result,
);
if previous_session_id != self.chat.viewing_session_id() {
self.handle_session_changed();
}
if let Some((session_id, navigation_result)) = navigation_result {
self.finish_plugin_session_attach(session_id, navigation_result);
} else {
let _invalidation = self.synchronize_screen();
}
let frame_interval = self.settings.bmux_runtime_config().frame_interval;
if frame_interval != previous_frame_interval
&& let Some(handle) = &self.runtime_handle
{
handle.set_frame_interval(frame_interval);
}
super::invalidation::UiInvalidation::Structural
}
bmux_tui_runtime::RuntimeEvent::Message(
BcodeRuntimeMessage::InteractiveSurfaceOpened(result),
) => match self.loop_state.complete_interactive_surface_open(result) {
super::chat_loop::InteractiveSurfaceOpenCompletion::Opened => {
super::invalidation::UiInvalidation::Structural
}
super::chat_loop::InteractiveSurfaceOpenCompletion::Stale => {
super::invalidation::UiInvalidation::None
}
super::chat_loop::InteractiveSurfaceOpenCompletion::Failed => {
self.chat
.app
.set_status("Interactive request unavailable; retrying".to_owned());
super::invalidation::UiInvalidation::Paint
}
},
bmux_tui_runtime::RuntimeEvent::Message(
BcodeRuntimeMessage::InteractiveSurfaceResolved(result),
) => match result {
Ok(resolved) => {
self.loop_state
.complete_interactive_surface_resolution(resolved);
self.chat.app.set_status(if resolved {
"interactive request resolved".to_owned()
} else {
"interactive request was already resolved by another client".to_owned()
});
super::invalidation::UiInvalidation::Structural
}
Err(error) => {
self.loop_state
.complete_interactive_surface_resolution(false);
self.chat
.app
.set_status(format!("Interactive response failed; retry: {error}"));
super::invalidation::UiInvalidation::Paint
}
},
bmux_tui_runtime::RuntimeEvent::Message(BcodeRuntimeMessage::DraftSaveDue) => {
super::chat_loop::start_draft_save(&mut self.chat, &mut self.draft_autosave);
super::invalidation::UiInvalidation::None
}
bmux_tui_runtime::RuntimeEvent::Message(
BcodeRuntimeMessage::InteractionRetryDue | BcodeRuntimeMessage::TelemetryFlushDue,
) => super::invalidation::UiInvalidation::None,
bmux_tui_runtime::RuntimeEvent::Timer(timer) => {
let (damage, due_temporal_damage) = self.handle_timer(&timer);
temporal_damage = due_temporal_damage;
damage
}
};
self.synchronize_composer_interaction();
self.loop_state.verify_plugin_operations()?;
self.invalidation = self.invalidation.merge(damage);
let route_invalidation = self.synchronize_screen();
self.invalidation = self.invalidation.merge(route_invalidation);
self.draft_autosave.observe(&self.chat, Instant::now());
if let Some(publisher) = &mut self.resurrection {
publisher.observe(self.chat.attachment);
}
let housekeeping = self
.loop_state
.prepare_runtime_work(&mut self.chat, self.committed_area);
self.invalidation = self.invalidation.merge(housekeeping);
self.presentation_damage =
self.select_presentation_damage(self.invalidation, &temporal_damage);
if self.transcript_selection.phase() != bmux_tui::selection::SelectionGesturePhase::Idle {
self.presentation_damage = bmux_tui::damage::Damage::Full;
}
self.fast_temporal_presentation = self.transcript_selection.phase()
== bmux_tui::selection::SelectionGesturePhase::Idle
&& self.invalidation == super::invalidation::UiInvalidation::Paint
&& !temporal_damage.is_empty()
&& !temporal_damage.contains(&super::app::TemporalDamage::Full)
&& !temporal_damage.contains(&super::app::TemporalDamage::Transcript)
&& !self.presentation_damage.is_full();
let mut update = match self.invalidation {
super::invalidation::UiInvalidation::None => bmux_tui_runtime::Update::none(),
super::invalidation::UiInvalidation::Full => bmux_tui_runtime::Update::reset(),
super::invalidation::UiInvalidation::Paint
| super::invalidation::UiInvalidation::Items
| super::invalidation::UiInvalidation::Structural => bmux_tui_runtime::Update::redraw(),
};
if self.exit_requested || self.chat.app.should_exit() {
update = update.merge(bmux_tui_runtime::Update::exit());
}
self.schedule_deadlines();
if let Some(handle) = self.runtime_handle.clone() {
for command in self.collect_runtime_work(&handle) {
update = update.with_command(command);
}
}
Ok(update)
}
}
pub fn record_runtime_stats(model: &mut BcodeRuntimeModel, stats: &bmux_tui_runtime::RuntimeStats) {
model.loop_state.record_runtime_stats(stats);
model.loop_state.flush_telemetry_if_due(Instant::now());
}
#[allow(dead_code)]
pub fn finish_runtime<P>(
mut output: bmux_tui_runtime::RuntimeOutput<BcodeRuntimeModel, P>,
) -> BcodeRuntimeModel {
record_runtime_stats(&mut output.program, &output.stats);
output.program.shutdown_owned_work();
output.program
}
#[allow(dead_code)]
pub fn run<W: std::io::Write>(
runtime: bmux_tui_runtime::Runtime<BcodeRuntimeModel, BcodeRuntimePresenter<'_, '_, W>>,
handle: bmux_tui_runtime::RuntimeHandle<BcodeRuntimeMessage>,
) -> impl std::future::Future<Output = Result<BcodeRuntimeModel, TuiError>> {
let runtime = Box::pin(runtime.run());
async move {
let mut input = bmux_tui_runtime::TerminalInput::start::<BcodeRuntimeModel>(
handle,
BcodeRuntimeMessage::TerminalInputFailed,
);
let outcome = runtime.await;
input.shutdown().await;
Box::pin(finish_runtime_result(outcome)).await
}
}
type RootRuntimeResult<P> = Result<
bmux_tui_runtime::RuntimeOutput<BcodeRuntimeModel, P>,
bmux_tui_runtime::RuntimeError<TuiError, TuiError, BcodeRuntimeModel, P>,
>;
async fn finish_runtime_result<P>(
outcome: RootRuntimeResult<P>,
) -> Result<BcodeRuntimeModel, TuiError> {
match outcome {
Ok(mut output) => {
let settlement = Box::pin(output.program.settle_plugin_operations()).await;
let model = finish_runtime(output);
settlement.map(|()| model)
}
Err(
bmux_tui_runtime::RuntimeError::Program { error, mut output }
| bmux_tui_runtime::RuntimeError::Presenter { error, mut output },
) => {
record_runtime_stats(&mut output.program, &output.stats);
let settlement = Box::pin(output.program.settle_plugin_operations()).await;
output.program.shutdown_owned_work();
settlement.and(Err(error))
}
}
}
#[allow(dead_code)]
pub fn runtime<'a, 'b, W: std::io::Write>(
terminal: &'a mut bmux_tui::terminal::Terminal<&'b mut W>,
model: BcodeRuntimeModel,
) -> (
bmux_tui_runtime::Runtime<BcodeRuntimeModel, BcodeRuntimePresenter<'a, 'b, W>>,
bmux_tui_runtime::RuntimeHandle<BcodeRuntimeMessage>,
) {
let config = model.settings.bmux_runtime_config();
let (runtime, handle) =
bmux_tui_runtime::Runtime::new(model, BcodeRuntimePresenter::new(terminal), config);
let bootstrap = BcodeRuntimeMessage::Bootstrap {
handle: handle.clone(),
};
assert!(
handle.try_send(bootstrap).is_ok(),
"new root runtime accepts bootstrap message"
);
(runtime, handle)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BcodeRuntimeScreen {
#[default]
Chat,
PluginSurface,
SessionPicker,
ModelPicker,
SkillPicker,
WorkingDirectory,
WorktreeCreate,
RalphStart,
CommandPalette,
SlashPalette,
Permission,
Thinking,
Timeline,
Theme,
Streaming,
AuthPool,
InteractiveSurface,
}
#[cfg(test)]
#[path = "child_product_tests.rs"]
mod child_product_tests;
#[cfg(test)]
mod tests {
use super::{BcodeRuntimeAdmissionError, BcodeRuntimeMessage, OrderedPresentationQueue, admit};
use crate::app::BmuxApp;
use bcode_command::CommandTextFormat;
use bcode_session_models::SessionId;
use std::collections::{BTreeMap, VecDeque};
use std::convert::Infallible;
fn assert_runtime_message_is_send<T: Send + 'static>() {}
#[tokio::test]
async fn active_selection_forces_full_damage_and_disables_fast_temporal_frames() {
let mut model = root_test_model();
model.transcript_selection = {
let body = bmux_tui::geometry::Rect::new(0, 0, 8, 1);
model
.committed_selection
.push_scope(bmux_tui::selection::SelectionScope::new(
"selection.test",
body,
));
for fragment in bmux_tui::selection::plain_text_fragments(
"selection.test",
"selection.test.content",
body,
0,
"abcdefgh",
0,
1,
) {
model.committed_selection.push_fragment(fragment);
}
let mut controller = bmux_tui::selection::SelectionController::new();
let _ = controller.handle_mouse(
&model.committed_selection,
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(0, 0),
),
);
let _ = controller.handle_mouse(
&model.committed_selection,
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(4, 0),
),
);
controller
};
model.invalidation = super::super::invalidation::UiInvalidation::Paint;
model.fast_temporal_presentation = true;
model.presentation_damage = model.select_presentation_damage(model.invalidation, &[]);
if model.transcript_selection.phase() != bmux_tui::selection::SelectionGesturePhase::Idle {
model.presentation_damage = bmux_tui::damage::Damage::Full;
model.fast_temporal_presentation = false;
}
assert!(model.presentation_damage.is_full());
assert!(!model.fast_temporal_presentation);
drop(model);
}
#[tokio::test]
async fn runtime_work_starts_existing_older_history_effect_for_selection_reveal() {
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: 10,
timestamp_ms: 10,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::UserMessage {
client_id: bcode_session_models::ClientId::new(),
text: "resident tail".to_owned(),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
}];
let mut model = root_test_model_with_history(session_id, &history);
model
.chat
.app
.replace_latest_transcript_window(&history, true);
model.chat.app.request_older_history_for_test(2);
let cursor = model.chat.app.older_history_cursor().expect("older cursor");
assert_eq!(
model.loop_state.prepare_runtime_work(
&mut model.chat,
bmux_tui::geometry::Rect::new(0, 0, 40, 12),
),
super::super::invalidation::UiInvalidation::Structural
);
assert!(model.chat.pending_effects.contains_effect(
&super::super::effects::TuiEffect::LoadOlderHistory {
session_id,
cursor,
generation: model.chat.app.history_window_generation(),
width_columns: 40,
}
));
assert!(model.chat.app.loading_older_history());
model.chat.pending_effects = super::super::effects::TuiEffectQueue::default();
model
.chat
.app
.replace_transcript_window(&history, false, true, 10);
model.chat.app.request_newer_history_for_test(2);
let newer_cursor = model.chat.app.newer_history_cursor().expect("newer cursor");
assert_eq!(
model.loop_state.prepare_runtime_work(
&mut model.chat,
bmux_tui::geometry::Rect::new(0, 0, 40, 12),
),
super::super::invalidation::UiInvalidation::Structural
);
assert!(model.chat.pending_effects.contains_effect(
&super::super::effects::TuiEffect::LoadNewerHistory {
session_id,
cursor: newer_cursor,
generation: model.chat.app.history_window_generation(),
width_columns: 40,
}
));
drop(model);
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn history_completion_respects_replacement_and_reuses_current_boundary() {
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
use bcode_session_models::{
SessionEvent, SessionEventKind, SessionHistoryCursor, SessionHistoryPage,
};
let session_id = bcode_session_models::SessionId::new();
let event = |sequence| SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: SessionEventKind::AssistantMessage {
text: format!("message {sequence}"),
},
};
let history = [event(10)];
let mut model = root_test_model_with_history(session_id, &history);
model
.chat
.app
.replace_latest_transcript_window(&history, true);
model.chat.app.request_older_history_for_test(2);
let cursor = model
.chat
.app
.older_history_cursor()
.expect("older boundary");
let old_generation = model.chat.app.history_window_generation();
assert_eq!(
model.loop_state.prepare_runtime_work(
&mut model.chat,
bmux_tui::geometry::Rect::new(0, 0, 40, 12),
),
super::super::invalidation::UiInvalidation::Structural
);
assert!(model.chat.pending_effects.contains_effect(
&super::super::effects::TuiEffect::LoadOlderHistory {
session_id,
cursor,
generation: old_generation,
width_columns: 40,
}
));
model.chat.pending_effects = super::super::effects::TuiEffectQueue::default();
model
.chat
.app
.replace_latest_transcript_window(&history, true);
model.chat.app.request_older_history_for_test(2);
let new_generation = model.chat.app.history_window_generation();
assert_ne!(new_generation, old_generation);
assert_eq!(
model.loop_state.prepare_runtime_work(
&mut model.chat,
bmux_tui::geometry::Rect::new(0, 0, 40, 12),
),
super::super::invalidation::UiInvalidation::Structural
);
let result = |generation| super::super::effects::TuiEffectResult::OlderHistoryLoaded {
session_id,
cursor,
generation,
result: Ok((
SessionHistoryPage {
session_id,
events: vec![event(9)],
compatibility_issues: vec![],
next_cursor: Some(SessionHistoryCursor { sequence: 8 }),
has_more: true,
},
Vec::new(),
)),
};
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::EffectCompleted(
Box::new(result(old_generation)),
)),
)
.expect("stale completion");
assert!(model.chat.app.loading_older_history());
assert_eq!(model.chat.app.older_history_cursor(), Some(cursor));
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::EffectCompleted(
Box::new(super::super::effects::TuiEffectResult::OlderHistoryLoaded {
session_id,
cursor,
generation: old_generation,
result: Err(bcode_client::ClientError::UnexpectedResponse),
}),
)),
)
.expect("stale failure completion");
assert!(model.chat.app.loading_older_history());
assert_eq!(model.chat.app.older_history_cursor(), Some(cursor));
let stale = TranscriptFrameSequence::new(&mut model.chat.app, 40, 12).run([
TranscriptFrameStep {
label: "replacement frame after stale page",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "stable replacement frame",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(stale[0].observation, stale[1].observation);
assert!(!stale[0].observation.semantic_items.is_empty());
let stale_items = stale[0].observation.semantic_items.clone();
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::EffectCompleted(
Box::new(result(new_generation)),
)),
)
.expect("current completion");
assert!(!model.chat.app.loading_older_history());
assert_eq!(
model.chat.app.older_history_cursor(),
Some(SessionHistoryCursor { sequence: 8 })
);
let accepted = TranscriptFrameSequence::new(&mut model.chat.app, 40, 12).run([
TranscriptFrameStep {
label: "accepted replacement page",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "accepted replacement page stable",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(accepted[0].observation, accepted[1].observation);
assert!(accepted[0].observation.semantic_items.len() > stale_items.len());
drop(model);
}
fn milestone_root_key(model: &mut super::BcodeRuntimeModel, key: bmux_keyboard::KeyCode) {
let _invalidation = model.handle_basic_terminal_event(bmux_tui::event::Event::Key(
bmux_keyboard::KeyStroke::simple(key),
));
}
fn milestone_root_complete(
model: &mut super::BcodeRuntimeModel,
result: super::super::effects::TuiEffectResult,
) {
bmux_tui_runtime::Program::update(
model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::EffectCompleted(
Box::new(result),
)),
)
.expect("milestone root completion");
}
fn milestone_root_open(model: &mut super::BcodeRuntimeModel) {
milestone_root_complete(
model,
super::super::effects::TuiEffectResult::SlashCommandExecuted {
message: "/timeline".into(),
result: Ok(super::super::slash_commands::SlashCommandOutcome::OpenTimeline),
},
);
}
fn milestone_root_request(
model: &mut super::BcodeRuntimeModel,
) -> Option<super::super::timeline_dialog::MilestonePageRequest> {
let (effects, notes) = model.chat.pending_effects.drain_runtime();
assert!(notes.is_empty());
let mut requests = effects.into_iter().map(|(_, effect)| match effect {
super::super::effects::TuiEffect::LoadMilestones { request } => request,
_ => panic!("unexpected effect during milestone navigation"),
});
let request = requests.next();
assert!(requests.next().is_none());
request
}
fn milestone_root_page(
request: super::super::timeline_dialog::MilestonePageRequest,
next_after_sequence: Option<u64>,
) -> super::super::effects::TuiEffectResult {
super::super::effects::TuiEffectResult::MilestonesLoaded {
request,
result: Ok(bcode_session_models::SessionMilestonePage {
milestones: Vec::new(),
next_after_sequence,
}),
}
}
#[tokio::test]
async fn milestone_root_close_reopen_rejects_stale_success_and_failure() {
use bmux_keyboard::KeyCode;
let mut model = root_test_model_with_history(bcode_session_models::SessionId::new(), &[]);
milestone_root_open(&mut model);
milestone_root_key(&mut model, KeyCode::Char('m'));
let old = milestone_root_request(&mut model).expect("first page request");
milestone_root_key(&mut model, KeyCode::Escape);
milestone_root_open(&mut model);
milestone_root_key(&mut model, KeyCode::Char('m'));
let current = milestone_root_request(&mut model).expect("reopened page request");
assert_ne!(old, current);
let status = model.chat.app.status().to_owned();
milestone_root_complete(&mut model, milestone_root_page(old, Some(999)));
milestone_root_complete(
&mut model,
super::super::effects::TuiEffectResult::MilestonesLoaded {
request: old,
result: Err(bcode_client::ClientError::UnexpectedResponse),
},
);
assert_eq!(model.chat.app.status(), status);
milestone_root_key(&mut model, KeyCode::Enter);
assert!(model.pending_timeline_jump.is_none());
assert!(milestone_root_request(&mut model).is_none());
milestone_root_complete(&mut model, milestone_root_page(current, Some(42)));
assert_eq!(model.chat.app.status(), "milestones: more pages available");
milestone_root_key(&mut model, KeyCode::Char('n'));
let next = milestone_root_request(&mut model).expect("dialog survived loading Enter");
drop(model);
assert_eq!(next.after_sequence, Some(42));
}
#[tokio::test]
async fn milestone_root_rapid_filter_and_page_keys_preserve_current_request() {
use bmux_keyboard::KeyCode;
let session_id = bcode_session_models::SessionId::new();
let mut prompt = root_test_history_event(session_id, 10);
prompt.kind = bcode_session_models::SessionEventKind::UserMessage {
client_id: bcode_session_models::ClientId::new(),
text: "resident prompt".into(),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
};
let mut model = root_test_model_with_history(session_id, std::slice::from_ref(&prompt));
milestone_root_open(&mut model);
milestone_root_key(&mut model, KeyCode::Char('m'));
let first = milestone_root_request(&mut model).expect("first request");
milestone_root_key(&mut model, KeyCode::Char('p'));
milestone_root_key(&mut model, KeyCode::Char('n'));
assert!(milestone_root_request(&mut model).is_none());
milestone_root_key(&mut model, KeyCode::Char('m'));
let replacement = milestone_root_request(&mut model).expect("replacement request");
milestone_root_key(&mut model, KeyCode::Char('n'));
assert!(milestone_root_request(&mut model).is_none());
milestone_root_complete(&mut model, milestone_root_page(first, Some(999)));
milestone_root_complete(&mut model, milestone_root_page(replacement, Some(7)));
milestone_root_key(&mut model, KeyCode::Char('n'));
let next = milestone_root_request(&mut model).expect("next request");
assert_eq!(next.after_sequence, Some(7));
milestone_root_key(&mut model, KeyCode::Char('n'));
assert!(milestone_root_request(&mut model).is_none());
milestone_root_key(&mut model, KeyCode::Char('m'));
let restarted = milestone_root_request(&mut model).expect("restart first page");
assert_eq!(restarted.after_sequence, None);
milestone_root_complete(&mut model, milestone_root_page(next, Some(999)));
milestone_root_complete(&mut model, milestone_root_page(restarted, None));
assert_eq!(model.chat.app.status(), "milestones: final page");
milestone_root_key(&mut model, KeyCode::Char('n'));
assert!(milestone_root_request(&mut model).is_none());
milestone_root_key(&mut model, KeyCode::Char('p'));
milestone_root_key(&mut model, KeyCode::Enter);
assert_eq!(model.chat.app.status(), "loading timeline message…");
let (effects, notes) = model.chat.pending_effects.drain_runtime();
assert!(notes.is_empty());
assert_eq!(effects.len(), 1);
let (_, effect) = effects.into_iter().next().expect("timeline source load");
let super::super::effects::TuiEffect::LoadTimelineJump {
session_id: requested_session,
generation,
sequence,
} = effect
else {
panic!("prompt navigation must load its canonical source");
};
assert_eq!(requested_session, session_id);
assert_eq!(sequence, 10);
assert_eq!(
model.pending_timeline_jump,
Some((session_id, generation, sequence))
);
milestone_root_complete(
&mut model,
super::super::effects::TuiEffectResult::TimelineJumpLoaded {
session_id,
generation,
sequence,
result: Ok((vec![prompt], false, false, Vec::new())),
},
);
assert_eq!(model.chat.app.status(), "jumped to timeline message");
assert!(model.pending_timeline_jump.is_none());
assert!(milestone_root_request(&mut model).is_none());
drop(model);
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn timeline_completion_cannot_replace_a_newer_window_or_session() {
use super::super::effects::TuiEffectResult;
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
let session_id = bcode_session_models::SessionId::new();
let history = [root_test_history_event(session_id, 10)];
let mut model = root_test_model_with_history(session_id, &history);
let generation = model.chat.app.history_window_generation();
model.pending_timeline_jump = Some((session_id, generation, 9));
model
.chat
.app
.replace_latest_transcript_window(&history, true);
let replacement_generation = model.chat.app.history_window_generation();
model.pending_timeline_jump = Some((session_id, replacement_generation, 9));
let before =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "replacement",
input: TranscriptFrameInput::Observe,
}]);
let complete =
|model: &mut super::BcodeRuntimeModel, session_id, generation, sequence, result| {
bmux_tui_runtime::Program::update(
model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::EffectCompleted(Box::new(
TuiEffectResult::TimelineJumpLoaded {
session_id,
generation,
sequence,
result,
},
)),
),
)
.expect("timeline completion");
};
complete(
&mut model,
session_id,
generation,
9,
Ok((
vec![root_test_history_event(session_id, 9)],
true,
true,
Vec::new(),
)),
);
complete(
&mut model,
bcode_session_models::SessionId::new(),
replacement_generation,
9,
Ok((
vec![root_test_history_event(session_id, 9)],
true,
true,
Vec::new(),
)),
);
complete(
&mut model,
session_id,
generation,
9,
Err(super::TuiError::Client(
bcode_client::ClientError::UnexpectedResponse,
)),
);
let stale =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "stale completion",
input: TranscriptFrameInput::Observe,
}]);
assert_eq!(stale[0].observation, before[0].observation);
for (sequence, events, has_older, has_newer) in [
(9, vec![root_test_history_event(session_id, 8)], true, true),
(
9,
vec![root_test_history_event(
bcode_session_models::SessionId::new(),
9,
)],
true,
true,
),
(
9,
vec![
root_test_history_event(session_id, 9),
root_test_history_event(session_id, 9),
],
true,
true,
),
(0, vec![root_test_history_event(session_id, 0)], true, false),
] {
model.pending_timeline_jump = Some((session_id, replacement_generation, sequence));
complete(
&mut model,
session_id,
replacement_generation,
sequence,
Ok((events, has_older, has_newer, Vec::new())),
);
}
let rejected = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "invalid current timeline results rejected",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "unchanged replacement",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(rejected[0].observation, rejected[1].observation);
assert_eq!(
rejected[0].observation.semantic_items,
before[0].observation.semantic_items
);
model.pending_timeline_jump = Some((session_id, replacement_generation, 9));
complete(
&mut model,
session_id,
replacement_generation,
9,
Ok((
vec![root_test_history_event(session_id, 9)],
true,
true,
Vec::new(),
)),
);
let accepted = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "current completion",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(accepted[0].observation, accepted[1].observation);
assert_ne!(
accepted[0].observation.semantic_items,
before[0].observation.semantic_items
);
assert!(model.chat.app.transcript_index_for_sequence(9).is_some());
drop(model);
}
#[tokio::test]
async fn timeline_jump_replacement_in_same_window_ignores_prior_completion() {
use super::super::effects::TuiEffectResult;
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
let session_id = bcode_session_models::SessionId::new();
let history = [root_test_history_event(session_id, 10)];
let mut model = root_test_model_with_history(session_id, &history);
let generation = model.chat.app.history_window_generation();
model.pending_timeline_jump = Some((session_id, generation, 9));
model.pending_timeline_jump = Some((session_id, generation, 8));
let before =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "before completions",
input: TranscriptFrameInput::Observe,
}]);
for result in [
Ok((
vec![root_test_history_event(session_id, 9)],
true,
true,
Vec::new(),
)),
Err(super::TuiError::Client(
bcode_client::ClientError::UnexpectedResponse,
)),
] {
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::EffectCompleted(Box::new(
TuiEffectResult::TimelineJumpLoaded {
session_id,
generation,
sequence: 9,
result,
},
)),
),
)
.expect("superseded completion");
}
let stale =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "superseded success and failure",
input: TranscriptFrameInput::Observe,
}]);
assert_eq!(before[0].observation, stale[0].observation);
assert_eq!(
model.pending_timeline_jump,
Some((session_id, generation, 8))
);
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::EffectCompleted(
Box::new(TuiEffectResult::TimelineJumpLoaded {
session_id,
generation,
sequence: 8,
result: Ok((
vec![root_test_history_event(session_id, 8)],
true,
true,
Vec::new(),
)),
}),
)),
)
.expect("current completion");
let accepted = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "current timeline jump",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(accepted[0].observation, accepted[1].observation);
assert_ne!(
accepted[0].observation.semantic_items,
before[0].observation.semantic_items
);
assert!(model.chat.app.transcript_index_for_sequence(8).is_some());
assert_eq!(model.pending_timeline_jump, None);
drop(model);
}
#[tokio::test]
async fn timeline_entry_resolves_source_sequence_after_window_replacement() {
use super::super::effects::TuiEffect;
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
let session_id = bcode_session_models::SessionId::new();
let initial = [root_test_history_event(session_id, 10)];
let mut model = root_test_model_with_history(session_id, &initial);
let _ =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "initial window",
input: TranscriptFrameInput::Observe,
}]);
let stale =
super::super::timeline_dialog::TimelineEntry::new(10, 10, "older resident message");
let replacement = [root_test_history_event(session_id, 20)];
model
.chat
.app
.replace_latest_transcript_window(&replacement, true);
let _ =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "replacement window",
input: TranscriptFrameInput::Observe,
}]);
model.apply_root_timeline_jump(&stale);
let generation = model.chat.app.history_window_generation();
assert_eq!(
model.pending_timeline_jump,
Some((session_id, generation, 10))
);
assert!(
model
.chat
.pending_effects
.contains_effect(&TuiEffect::LoadTimelineJump {
session_id,
generation,
sequence: 10,
})
);
let after =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "stale index did not jump to replacement",
input: TranscriptFrameInput::Observe,
}]);
assert_eq!(after[0].observation.semantic_items.len(), 1);
assert!(after[0].text.contains("message 20"));
assert!(model.chat.app.transcript_index_for_sequence(10).is_none());
drop(model);
}
#[tokio::test]
async fn resident_timeline_jump_supersedes_pending_remote_selection() {
use super::super::effects::TuiEffectResult;
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
let session_id = bcode_session_models::SessionId::new();
let history = [root_test_history_event(session_id, 10)];
let mut model = root_test_model_with_history(session_id, &history);
let _ =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "layout resident entry",
input: TranscriptFrameInput::Observe,
}]);
let generation = model.chat.app.history_window_generation();
model.apply_root_timeline_jump(&super::super::timeline_dialog::TimelineEntry::new(
9,
9,
"remote message",
));
assert_eq!(
model.pending_timeline_jump,
Some((session_id, generation, 9))
);
model.apply_root_timeline_jump(&super::super::timeline_dialog::TimelineEntry::new(
10,
10,
"resident message",
));
assert_eq!(model.pending_timeline_jump, None);
let before =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "resident selection",
input: TranscriptFrameInput::Observe,
}]);
for result in [
Ok((
vec![root_test_history_event(session_id, 9)],
true,
true,
Vec::new(),
)),
Err(super::TuiError::Client(
bcode_client::ClientError::UnexpectedResponse,
)),
] {
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::EffectCompleted(Box::new(
TuiEffectResult::TimelineJumpLoaded {
session_id,
generation,
sequence: 9,
result,
},
)),
),
)
.expect("stale remote completion");
}
let after =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "resident selection after stale completions",
input: TranscriptFrameInput::Observe,
}]);
assert_eq!(before[0].observation, after[0].observation);
assert!(model.chat.app.transcript_index_for_sequence(10).is_some());
assert!(model.chat.app.transcript_index_for_sequence(9).is_none());
drop(model);
}
#[tokio::test]
async fn newer_completion_cannot_change_a_replaced_centered_window() {
use super::super::effects::{TuiEffect, TuiEffectResult};
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
use bcode_session_models::{SessionHistoryCursor, SessionHistoryPage};
let session_id = bcode_session_models::SessionId::new();
let initial = [root_test_history_event(session_id, 10)];
let replacement = [root_test_history_event(session_id, 20)];
let mut model = root_test_model_with_history(session_id, &initial);
model
.chat
.app
.replace_transcript_window(&initial, false, true, 10);
let cursor = model.chat.app.newer_history_cursor().expect("newer cursor");
model.chat.app.request_newer_history_for_test(2);
let stale_generation = model.chat.app.history_window_generation();
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
assert!(
model
.chat
.pending_effects
.contains_effect(&TuiEffect::LoadNewerHistory {
session_id,
cursor,
generation: stale_generation,
width_columns: 80,
})
);
model
.chat
.app
.replace_transcript_window(&replacement, false, true, 20);
let before =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "replacement",
input: TranscriptFrameInput::Observe,
}]);
for result in [
Ok((
SessionHistoryPage {
session_id,
events: vec![root_test_history_event(session_id, 11)],
compatibility_issues: vec![],
next_cursor: None,
has_more: false,
},
Vec::new(),
)),
Err(bcode_client::ClientError::UnexpectedResponse),
] {
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::EffectCompleted(Box::new(
TuiEffectResult::NewerHistoryLoaded {
session_id,
cursor,
generation: stale_generation,
result,
},
)),
),
)
.expect("ignore stale newer completion");
}
let after = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "stale page ignored",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "stable",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(before[0].observation, after[0].observation);
assert_eq!(after[0].observation, after[1].observation);
assert_eq!(
model.chat.app.newer_history_cursor(),
Some(SessionHistoryCursor { sequence: 21 })
);
drop(model);
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn newer_completion_after_resynchronization_does_not_reopen_the_old_window() {
use super::super::effects::{TuiEffect, TuiEffectResult};
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
use bcode_session_models::{SessionHistoryPage, SessionSummary, SessionTitleSource};
let session_id = bcode_session_models::SessionId::new();
let initial = [root_test_history_event(session_id, 10)];
let mut model = root_test_model_with_history(session_id, &initial);
model
.chat
.app
.replace_transcript_window(&initial, false, true, 10);
model.chat.app.request_newer_history_for_test(2);
let cursor = model.chat.app.newer_history_cursor().expect("newer cursor");
let generation = model.chat.app.history_window_generation();
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
assert!(
model
.chat
.pending_effects
.contains_effect(&TuiEffect::LoadNewerHistory {
session_id,
cursor,
generation,
width_columns: 80,
})
);
let session = SessionSummary {
id: session_id,
parent_id: None,
name: Some("reconnected".to_owned()),
explicit_name: Some("reconnected".to_owned()),
derived_title: None,
automatic_title: None,
automatic_title_source_sequence: None,
title_source: SessionTitleSource::Explicit,
client_count: 1,
created_at_ms: 1,
updated_at_ms: 2,
working_directory: "/tmp/bcode-tui-test".into(),
import: None,
execution: None,
location: None,
};
let attached = bcode_client::AttachedSessionHistory {
session,
history: vec![root_test_history_event(session_id, 20)],
submissions: vec![],
input_history: vec![],
usage_summary: bcode_session_models::SessionUsageSummary::default(),
import_warnings: vec![],
draft: None,
runtime_selection: bcode_ipc::SessionRuntimeSelection::default(),
projection_window: None,
};
for update in [
super::super::history_flow::SessionStreamUpdate::ResyncStarted { session_id },
super::super::history_flow::SessionStreamUpdate::Resynchronized {
session_id,
attached: Box::new(attached),
},
] {
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::SessionStream(
Box::new(update),
)),
)
.expect("stream update");
}
assert_ne!(model.chat.app.history_window_generation(), generation);
let before =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "resynchronized",
input: TranscriptFrameInput::Observe,
}]);
for result in [
Ok((
SessionHistoryPage {
session_id,
events: vec![root_test_history_event(session_id, 11)],
compatibility_issues: vec![],
next_cursor: None,
has_more: false,
},
Vec::new(),
)),
Err(bcode_client::ClientError::UnexpectedResponse),
] {
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::EffectCompleted(Box::new(
TuiEffectResult::NewerHistoryLoaded {
session_id,
cursor,
generation,
result,
},
)),
),
)
.expect("late newer completion");
}
let after = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "stale newer page ignored",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "stable",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(before[0].observation, after[0].observation);
assert_eq!(after[0].observation, after[1].observation);
assert!(model.chat.app.transcript_index_for_sequence(20).is_some());
assert!(model.chat.app.transcript_index_for_sequence(11).is_none());
drop(model);
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn reconnect_replaces_window_and_rejects_old_history_completion() {
use super::super::effects::{TuiEffect, TuiEffectResult};
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
use bcode_session_models::{
SessionEvent, SessionEventKind, SessionHistoryCursor, SessionHistoryPage,
SessionSummary, SessionTitleSource,
};
let session_id = bcode_session_models::SessionId::new();
let event = |sequence| SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: SessionEventKind::AssistantMessage {
text: format!("message {sequence}"),
},
};
let history = [event(10)];
let mut model = root_test_model_with_history(session_id, &history);
model
.chat
.app
.replace_latest_transcript_window(&history, true);
model.chat.app.request_older_history_for_test(2);
let cursor = model
.chat
.app
.older_history_cursor()
.expect("older boundary");
let generation = model.chat.app.history_window_generation();
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 40, 12));
assert!(
model
.chat
.pending_effects
.contains_effect(&TuiEffect::LoadOlderHistory {
session_id,
cursor,
generation,
width_columns: 40,
})
);
let session = SessionSummary {
id: session_id,
parent_id: None,
name: Some("reconnected".to_owned()),
explicit_name: Some("reconnected".to_owned()),
derived_title: None,
automatic_title: None,
automatic_title_source_sequence: None,
title_source: SessionTitleSource::Explicit,
client_count: 1,
created_at_ms: 1,
updated_at_ms: 2,
working_directory: "/tmp/bcode-tui-test".into(),
import: None,
execution: None,
location: None,
};
let attached = bcode_client::AttachedSessionHistory {
session,
history: vec![event(20)],
submissions: vec![],
input_history: vec![],
usage_summary: bcode_session_models::SessionUsageSummary::default(),
import_warnings: vec![],
draft: None,
runtime_selection: bcode_ipc::SessionRuntimeSelection::default(),
projection_window: None,
};
for update in [
super::super::history_flow::SessionStreamUpdate::ResyncStarted { session_id },
super::super::history_flow::SessionStreamUpdate::Resynchronized {
session_id,
attached: Box::new(attached),
},
] {
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::SessionStream(
Box::new(update),
)),
)
.expect("stream update");
}
assert_ne!(model.chat.app.history_window_generation(), generation);
let before =
TranscriptFrameSequence::new(&mut model.chat.app, 40, 12).run([TranscriptFrameStep {
label: "reconnected",
input: TranscriptFrameInput::Observe,
}]);
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::EffectCompleted(
Box::new(TuiEffectResult::OlderHistoryLoaded {
session_id,
cursor,
generation,
result: Ok((
SessionHistoryPage {
session_id,
events: vec![event(9)],
compatibility_issues: vec![],
next_cursor: Some(SessionHistoryCursor { sequence: 8 }),
has_more: true,
},
Vec::new(),
)),
}),
)),
)
.expect("late completion");
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::EffectCompleted(
Box::new(TuiEffectResult::OlderHistoryLoaded {
session_id,
cursor,
generation,
result: Err(bcode_client::ClientError::UnexpectedResponse),
}),
)),
)
.expect("late failure completion");
let after = TranscriptFrameSequence::new(&mut model.chat.app, 40, 12).run([
TranscriptFrameStep {
label: "late page ignored",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(before[0].observation, after[0].observation);
assert_eq!(after[0].observation, after[1].observation);
assert!(model.chat.app.transcript_index_for_sequence(20).is_some());
assert!(model.chat.app.transcript_index_for_sequence(9).is_none());
drop(model);
}
#[tokio::test]
async fn resumed_boundary_effect_completion_preserves_reader_in_accepted_frame() {
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
use bcode_session_models::{SessionEvent, SessionEventKind};
let session_id = bcode_session_models::SessionId::new();
let event = |sequence| SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: SessionEventKind::AssistantMessage {
text: format!("message {sequence}"),
},
};
let recent = (30..42).map(event).collect::<Vec<_>>();
let mut model = root_test_model_with_history(session_id, &recent);
model
.chat
.app
.replace_latest_transcript_window(&recent, true);
let initial =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "resume",
input: TranscriptFrameInput::Observe,
}]);
let before = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "reach history boundary",
input: TranscriptFrameInput::ScrollUp(initial[0].observation.viewport_top + 2),
},
TranscriptFrameStep {
label: "repeat input while loading",
input: TranscriptFrameInput::ScrollUp(2),
},
]);
let reader = &before[1].observation;
let cursor = model.chat.app.older_history_cursor().expect("older cursor");
let generation = model.chat.app.history_window_generation();
assert_eq!(
model.loop_state.prepare_runtime_work(
&mut model.chat,
bmux_tui::geometry::Rect::new(0, 0, 80, 12),
),
super::super::invalidation::UiInvalidation::Structural
);
assert!(model.chat.pending_effects.contains_effect(
&super::super::effects::TuiEffect::LoadOlderHistory {
session_id,
cursor,
generation,
width_columns: 80,
}
));
complete_older_history_root_test(
&mut model,
session_id,
cursor,
generation,
(1..30).map(event).collect(),
);
let after = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "page accepted",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "same page accepted again",
input: TranscriptFrameInput::Observe,
},
]);
let loaded = &after[0].observation;
let anchor = reader.anchor.as_ref().expect("source anchor before page");
let source = reader
.terminal_rows
.get(reader.viewport_top + anchor.viewport_row_offset)
.and_then(|(source, _)| source.as_ref())
.expect("source row before page");
let previous_row = loaded
.terminal_rows
.iter()
.position(|(id, row)| id.as_ref() == Some(source) && *row == anchor.row_in_item)
.expect("reader source row survives page");
assert!(previous_row >= loaded.viewport_top);
assert!(
previous_row - loaded.viewport_top <= 5,
"reader moved {} rows; before top {}, after top {}, anchor {:?}",
previous_row - loaded.viewport_top,
reader.viewport_top,
loaded.viewport_top,
anchor
);
assert_eq!(loaded, &after[1].observation);
drop(model);
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn resumed_boundary_real_ipc_effect_keeps_reader_across_non_presenting_page() {
use super::super::effects::TuiEffect;
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
use bcode_session_models::{
ClientId, ProjectionHistoryPage, ProjectionSourceRange, ProjectionWindow, SessionEvent,
SessionEventKind,
};
let session_id = bcode_session_models::SessionId::new();
let event = move |sequence, kind| SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind,
};
let recent = (30..42)
.map(|sequence| {
event(
sequence,
SessionEventKind::UserMessage {
client_id: ClientId::new(),
text: format!("message {sequence}"),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
)
})
.collect::<Vec<_>>();
let mut model = root_test_model_with_history(session_id, &recent);
model
.chat
.app
.replace_latest_transcript_window(&recent, true);
let before = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "resume",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "load boundary",
input: TranscriptFrameInput::ScrollUp(100),
},
TranscriptFrameStep {
label: "repeat while pending",
input: TranscriptFrameInput::ScrollUp(2),
},
]);
let reader = &before[2].observation;
let cursor = model.chat.app.older_history_cursor().expect("older cursor");
let generation = model.chat.app.history_window_generation();
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
let effect = TuiEffect::LoadOlderHistory {
session_id,
cursor,
generation,
width_columns: 80,
};
assert!(model.chat.pending_effects.contains_effect(&effect));
let (scheduled, _) = model.chat.pending_effects.drain_runtime();
assert!(scheduled.into_iter().any(|(_, queued)| matches!(queued, TuiEffect::LoadOlderHistory { session_id: queued_session, cursor: queued_cursor, generation: queued_generation, .. } if queued_session == session_id && queued_cursor == cursor && queued_generation == generation)));
let socket_dir = tempfile::tempdir().expect("IPC directory");
let endpoint = bcode_ipc::IpcEndpoint::unix_socket(socket_dir.path().join("page.sock"));
let listener = bcode_ipc::LocalIpcListener::bind(&endpoint).expect("listener");
let responder = tokio::spawn(async move {
let mut stream = listener.accept().await.expect("client");
loop {
let envelope = bcode_ipc::recv_envelope(&mut stream)
.await
.expect("request");
let request = bcode_ipc::decode_request(&envelope.payload).expect("decode");
let done = matches!(
request,
bcode_ipc::Request::SessionProjectionHistoryPage { .. }
);
let response = match request {
bcode_ipc::Request::Hello { .. } => {
bcode_ipc::Response::Ok(bcode_ipc::ResponsePayload::Hello {
protocol_version: bcode_ipc::ProtocolVersion::current(),
client_id: ClientId::new(),
daemon: bcode_ipc::DaemonStatus {
namespace: bcode_ipc::daemon_namespace(),
protocol_version: u32::from(
bcode_ipc::ProtocolVersion::current().0,
),
artifact_id: Some(bcode_ipc::ArtifactId::current()),
executable_digest: Some(
bcode_daemon_lifecycle::current_executable_identity()
.expect("test executable identity")
.1,
),
build_fingerprint: bcode_ipc::BUILD_FINGERPRINT.to_owned(),
storage_writer_epoch: Some(
bcode_ipc::CURRENT_SESSION_STORAGE_WRITER_EPOCH,
),
session_event_schema_version: Some(
bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
),
state_location_id: Some(bcode_ipc::state_location_id()),
..bcode_ipc::DaemonStatus::default()
},
})
}
bcode_ipc::Request::SessionProjectionHistoryPage {
session_id: requested,
request,
} => {
assert_eq!(requested, session_id);
assert!(request.limits.max_events_scanned <= 512);
bcode_ipc::Response::Ok(
bcode_ipc::ResponsePayload::SessionProjectionHistoryPage {
page: ProjectionHistoryPage {
session_id,
window: ProjectionWindow {
projection:
bcode_session_models::SessionProjectionKind::Transcript,
transcript_items: vec![],
source_range: Some(ProjectionSourceRange {
start_sequence: 25,
end_sequence: 29,
}),
has_older: true,
has_newer: true,
scanned_events: 5,
},
events: (25..30)
.map(|sequence| {
event(
sequence,
SessionEventKind::ClientAttached {
client_id: ClientId::new(),
},
)
})
.collect(),
},
},
)
}
other => panic!("unexpected request: {other:?}"),
};
let reply = bcode_ipc::response_envelope(envelope.request_id, &response)
.expect("response envelope");
bcode_ipc::send_envelope(&mut stream, &reply)
.await
.expect("send response");
if done {
break;
}
}
});
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
effect.run_for_test(bcode_client::BcodeClient::new(endpoint)),
)
.await
.expect("effect timeout");
responder.await.expect("responder");
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::EffectCompleted(
Box::new(result),
)),
)
.expect("deliver effect");
let after = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "accepted empty semantic page",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "repeat",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(reader.anchor, after[0].observation.anchor);
assert_eq!(reader.viewport_top, after[0].observation.viewport_top);
assert_eq!(after[0].observation, after[1].observation);
assert_eq!(
model
.chat
.app
.older_history_cursor()
.map(|cursor| cursor.sequence),
Some(24)
);
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
let (next_scheduled, _) = model.chat.pending_effects.drain_runtime();
assert!(next_scheduled.iter().any(|(_, queued)| matches!(queued, TuiEffect::LoadOlderHistory { session_id: queued_session, cursor: queued_cursor, generation: queued_generation, .. } if *queued_session == session_id && queued_cursor.sequence == 24 && *queued_generation == generation)));
model.chat.app.scroll_transcript_up(1);
assert!(model.chat.app.loading_older_history());
let next_page = vec![event(
23,
SessionEventKind::UserMessage {
client_id: ClientId::new(),
text: "earlier message".to_owned(),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
)];
deliver_older_history_root_test(
&mut model,
session_id,
bcode_session_models::SessionHistoryCursor { sequence: 24 },
generation,
Ok(bcode_session_models::SessionHistoryPage {
session_id,
events: next_page,
compatibility_issues: vec![],
next_cursor: None,
has_more: false,
}),
);
let presenting = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "presenting page",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "presenting page settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(presenting[0].observation, presenting[1].observation);
assert_eq!(model.chat.app.older_history_cursor(), None);
assert!(
presenting[0].observation.semantic_items.len()
> after[0].observation.semantic_items.len()
);
let reader_anchor = reader.anchor.as_ref().expect("reader anchor");
let reader_source = reader.terminal_rows
[reader.viewport_top + reader_anchor.viewport_row_offset]
.0
.as_ref()
.expect("source-backed reader");
let source_row = presenting[0]
.observation
.terminal_rows
.iter()
.position(|(id, row)| {
id.as_ref() == Some(reader_source) && *row == reader_anchor.row_in_item
})
.expect("source survives page");
let reader_offset = source_row.saturating_sub(presenting[0].observation.viewport_top);
assert!(
reader_offset > reader_anchor.viewport_row_offset,
"presenting page must reveal requested movement: reader_offset={reader_offset}, prior_offset={}, source_row={source_row}, top={}",
reader_anchor.viewport_row_offset,
presenting[0].observation.viewport_top
);
assert_eq!(
reader_offset - reader_anchor.viewport_row_offset,
2,
"only the newly navigable rows are revealed, once"
);
drop(model);
}
#[cfg(unix)]
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn persisted_resume_reverses_across_naturally_evicted_newer_history() {
use super::super::effects::TuiEffect;
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
use bcode_session_models::{ClientId, SessionHistoryDirection, SessionHistoryQuery};
let directory = std::sync::Arc::new(tempfile::tempdir().expect("isolated state"));
let state_root = directory.path().join("state");
std::fs::create_dir(&state_root).expect("state directory");
let locations = bcode_config::resolve_state_location_set_with_environment(
&bcode_config::StateConfig::default(),
&bcode_config::StateLocationSelection {
root: Some(state_root),
profile: None,
},
&bcode_config::ConfigEnvironmentSnapshot::new(
BTreeMap::new(),
directory.path().to_path_buf(),
),
)
.expect("resolve isolated state");
let sessions_root = locations.primary().sessions_root().to_path_buf();
let working_directory = directory.path().to_path_buf();
let handle = tokio::runtime::Handle::current();
let fixture_owner = std::sync::Arc::clone(&directory);
let cancellation = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let worker_cancellation = cancellation.clone();
let mut fixture = tokio::task::spawn_blocking(move || {
handle.block_on(async move {
let sessions = bcode_session::SessionManager::persistent(sessions_root)
.expect("persistent sessions");
let session = sessions
.create_session(None, working_directory)
.await
.expect("session");
let ownership = sessions
.acquire_session_ownership(
session.id,
bcode_session::SessionOwnershipKind::RuntimeWork,
)
.await
.expect("fixture write ownership");
let client_id = ClientId::new();
for index in 0..1_100 {
if worker_cancellation.load(std::sync::atomic::Ordering::Acquire) {
drop(ownership);
sessions
.release_idle_session_resources(session.id)
.await
.expect("release cancelled fixture ownership");
drop(sessions);
drop(fixture_owner);
return None;
}
sessions
.append_user_message(session.id, client_id, format!("archived {index}"))
.await
.expect("persist user message");
}
let recent = sessions
.session_history_page(
session.id,
SessionHistoryQuery {
cursor: None,
limit: 12,
direction: SessionHistoryDirection::Backward,
},
)
.await
.expect("recent bounded history")
.events;
drop(ownership);
sessions
.release_idle_session_resources(session.id)
.await
.expect("release completed fixture ownership");
drop(sessions);
drop(fixture_owner);
Some((session, recent))
})
});
let (session, recent) = if let Ok(result) =
tokio::time::timeout(
std::time::Duration::from_mins(5),
&mut fixture,
)
.await
{
result
.expect("fixture worker completed")
.expect("fixture not cancelled")
} else {
cancellation.store(true, std::sync::atomic::Ordering::Release);
let settled =
tokio::time::timeout(std::time::Duration::from_secs(10), &mut fixture).await;
assert!(
settled.is_ok(),
"fixture cancellation did not settle; worker retains its isolated root"
);
panic!(
"bounded 1100-event history fixture initialization exceeded 300 seconds after retaining write ownership"
);
};
let mut model = root_test_model_with_history(session.id, &recent);
model
.chat
.app
.replace_latest_transcript_window(&recent, true);
let resume = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "persisted resume",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "scroll to older boundary",
input: TranscriptFrameInput::ScrollUp(usize::MAX / 4),
},
]);
assert!(resume[0].observation.semantic_items.len() <= 12);
let endpoint = bcode_ipc::IpcEndpoint::unix_socket(directory.path().join("server.sock"));
let shutdown = bcode_agent_runtime::CancellationToken::new();
let client =
bcode_client::BcodeClient::for_state_location(endpoint.clone(), locations.primary());
let server = bcode_server::run_embedded_with_locations_and_shutdown(
endpoint,
bcode_config::BcodeConfig::default(),
bcode_plugin::PluginRuntimeHost::load_defaults_with_static_bundled(
&bcode_plugin::PluginSelection {
mode: bcode_plugin::PluginSelectionMode::Explicit,
enabled: std::collections::BTreeSet::new(),
disabled: std::collections::BTreeSet::new(),
},
&[],
)
.expect("empty plugin host"),
bcode_model_catalog::ModelCatalogResolver::embedded(),
Vec::new(),
shutdown.clone(),
Some(locations),
);
let exercise = async {
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
if client.verified_server_status().await.is_ok() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await
.expect("server ready");
for _ in 0..72 {
if model.chat.app.newer_history_cursor().is_some() {
break;
}
let before_page = model.chat.app.frame_observation();
let prior_anchor = before_page.anchor.as_ref().expect("older source reader");
let prior_reader_source = before_page.terminal_rows
[before_page.viewport_top + prior_anchor.viewport_row_offset]
.clone();
let cursor = model.chat.app.older_history_cursor().expect("older cursor");
let generation = model.chat.app.history_window_generation();
model.loop_state.prepare_runtime_work(
&mut model.chat,
bmux_tui::geometry::Rect::new(0, 0, 80, 12),
);
let (scheduled, _) = model.chat.pending_effects.drain_runtime();
let effect = scheduled.into_iter().find_map(|(_, effect)| {
matches!(&effect, TuiEffect::LoadOlderHistory { session_id, cursor: requested, generation: current, .. }
if *session_id == session.id && *requested == cursor && *current == generation)
.then_some(effect)
}).expect("scheduled older page");
let result = effect.run_for_test(client.clone()).await;
if let super::super::effects::TuiEffectResult::OlderHistoryLoaded {
result: page,
..
} = &result
{
let (page, _) = page.as_ref().expect("canonical older page");
assert!(!page.events.is_empty());
assert!(page.events.len() <= 512);
} else {
panic!("expected older page");
}
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::EffectCompleted(Box::new(result)),
),
)
.expect("accept older page");
let frame = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "older accepted",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "older settled",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "continue upward",
input: TranscriptFrameInput::ScrollUp(usize::MAX / 4),
},
]);
assert_eq!(frame[0].observation, frame[1].observation);
let prior_row = before_page
.terminal_rows
.iter()
.position(|row| row == &prior_reader_source)
.expect("reader source before older page");
let accepted_row = frame[0]
.observation
.terminal_rows
.iter()
.position(|row| row == &prior_reader_source)
.expect("reader source after older page");
let prior_offset = i128::try_from(prior_row).expect("row fits i128")
- i128::try_from(before_page.viewport_top).expect("top fits i128");
let accepted_offset = i128::try_from(accepted_row).expect("row fits i128")
- i128::try_from(frame[0].observation.viewport_top).expect("top fits i128");
let requested_movement = accepted_offset - prior_offset;
assert!(requested_movement >= 0);
let prior_rows = before_page
.terminal_rows
.iter()
.collect::<std::collections::BTreeSet<_>>();
let page_rows_above_reader = frame[0]
.observation
.terminal_rows
.iter()
.take(accepted_row)
.filter(|row| row.0.is_some() && !prior_rows.contains(row))
.count();
assert!(
requested_movement
<= i128::try_from(page_rows_above_reader).expect("rows fit i128"),
"older page may reveal only newly navigable rows: movement={requested_movement}, new_rows_above_reader={page_rows_above_reader}"
);
}
assert!(model.chat.app.resident_transcript_event_count() <= 1_024);
assert!(
model.chat.app.newer_history_cursor().is_some(),
"natural eviction: resident={}, oldest={:?}, follows={}",
model.chat.app.resident_transcript_event_count(),
model.chat.app.resident_transcript_oldest_sequence(),
model.chat.app.frame_observation().scroll_mode,
);
let evicted = model
.chat
.app
.newer_history_cursor()
.expect("naturally evicted newer suffix");
let at_older_boundary = model.chat.app.frame_observation();
let anchor = at_older_boundary
.anchor
.as_ref()
.expect("source-backed older reader");
let reader_source = at_older_boundary.terminal_rows
[at_older_boundary.viewport_top + anchor.viewport_row_offset]
.clone();
assert!(reader_source.0.is_some());
let forward = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "reverse to newer boundary",
input: TranscriptFrameInput::ScrollDown(usize::MAX / 4),
},
TranscriptFrameStep {
label: "boundary settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(forward[0].observation, forward[1].observation);
assert!(
forward[0]
.observation
.terminal_rows
.contains(&reader_source)
);
assert!(model.chat.app.should_load_newer_history());
let requested = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "reverse one row to cancel outstanding forward movement",
input: TranscriptFrameInput::ScrollUp(1),
},
TranscriptFrameStep {
label: "request three rows past newer boundary",
input: TranscriptFrameInput::ScrollDown(3),
},
TranscriptFrameStep {
label: "forward request settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(requested[1].observation, requested[2].observation);
assert!(model.chat.app.should_load_newer_history());
assert!(
model
.chat
.app
.newer_history_cursor()
.expect("forward cursor")
.sequence
>= evicted.sequence
);
let mut fetched = 0;
while model.chat.app.newer_history_cursor().is_some() {
assert!(fetched < 96, "forward pages must make bounded progress");
let before_page = model.chat.app.frame_observation();
let prior_anchor = before_page.anchor.as_ref().expect("forward source reader");
let prior_reader_source = before_page.terminal_rows
[before_page.viewport_top + prior_anchor.viewport_row_offset]
.clone();
let cursor = model
.chat
.app
.newer_history_cursor()
.expect("forward cursor");
let generation = model.chat.app.history_window_generation();
model.loop_state.prepare_runtime_work(
&mut model.chat,
bmux_tui::geometry::Rect::new(0, 0, 80, 12),
);
let (scheduled, _) = model.chat.pending_effects.drain_runtime();
let effect = scheduled.into_iter().find_map(|(_, effect)| {
matches!(&effect, TuiEffect::LoadNewerHistory { session_id, cursor: requested, generation: current, .. }
if *session_id == session.id && *requested == cursor && *current == generation)
.then_some(effect)
}).expect("scheduled newer page");
let result = effect.run_for_test(client.clone()).await;
if let super::super::effects::TuiEffectResult::NewerHistoryLoaded {
result: page,
..
} = &result
{
let (page, _) = page.as_ref().expect("canonical newer page");
assert!(!page.events.is_empty());
assert!(page.events.len() <= 512);
} else {
panic!("expected newer page");
}
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::EffectCompleted(Box::new(result)),
),
)
.expect("accept newer page");
let frame = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "newer accepted",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "newer settled",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "continue forward",
input: TranscriptFrameInput::ScrollDown(usize::MAX / 4),
},
]);
assert_eq!(frame[0].observation, frame[1].observation);
if fetched == 0 {
assert!(frame[0].observation.terminal_rows.contains(&reader_source));
let prior_row = before_page
.terminal_rows
.iter()
.position(|row| row == &prior_reader_source)
.expect("reader before first forward page");
let accepted_row = frame[0]
.observation
.terminal_rows
.iter()
.position(|row| row == &prior_reader_source)
.expect("reader survives first forward page");
let prior_offset = i128::try_from(prior_row).expect("row fits i128")
- i128::try_from(before_page.viewport_top).expect("top fits i128");
let accepted_offset = i128::try_from(accepted_row).expect("row fits i128")
- i128::try_from(frame[0].observation.viewport_top).expect("top fits i128");
let displacement = prior_offset - accepted_offset;
let page_rows = frame[0]
.observation
.terminal_rows
.len()
.saturating_sub(before_page.terminal_rows.len());
assert!(
displacement >= 0
&& displacement <= i128::try_from(page_rows).expect("rows fit i128"),
"page movement must use only newly navigable rows"
);
assert_eq!(
displacement, 3,
"the first page spends three requested rows exactly once"
);
assert_eq!(frame[0].observation, frame[1].observation);
}
assert!(model.chat.app.resident_transcript_event_count() <= 1_024);
fetched += 1;
}
let tail = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "return to live tail",
input: TranscriptFrameInput::ScrollToBottom,
},
TranscriptFrameStep {
label: "tail settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(tail[0].observation, tail[1].observation);
assert_eq!(tail[0].observation.scroll_mode, "bottom_follow");
shutdown.cancel();
};
let (server_result, ()) = Box::pin(tokio::time::timeout(
std::time::Duration::from_secs(90),
async { tokio::join!(server, exercise) },
))
.await
.expect("server and traversal complete");
server_result.expect("server shutdown");
drop(model);
}
#[cfg(unix)]
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn persisted_history_page_reaches_scheduled_effect_and_accepted_frame() {
use super::super::effects::TuiEffect;
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
use bcode_session_models::{ClientId, SessionEventKind};
let directory = tempfile::tempdir().expect("isolated state");
let state_root = directory.path().join("state");
std::fs::create_dir(&state_root).expect("state directory");
let locations = bcode_config::resolve_state_location_set_with_environment(
&bcode_config::StateConfig::default(),
&bcode_config::StateLocationSelection {
root: Some(state_root),
profile: None,
},
&bcode_config::ConfigEnvironmentSnapshot::new(
BTreeMap::new(),
directory.path().to_path_buf(),
),
)
.expect("resolve isolated state");
let sessions =
bcode_session::SessionManager::persistent(locations.primary().sessions_root())
.expect("persistent sessions");
let session = sessions
.create_session(None, directory.path().to_path_buf())
.await
.expect("session");
let client_id = ClientId::new();
for index in 0..30 {
sessions
.append_user_message(session.id, client_id, format!("persisted {index}"))
.await
.expect("persist user message");
if index == 17 {
for _ in 0..520 {
sessions
.append_event(session.id, SessionEventKind::ClientAttached { client_id })
.await
.expect("persist non-presenting event");
}
}
}
let recent = sessions
.session_history_page(
session.id,
bcode_session_models::SessionHistoryQuery {
cursor: None,
limit: 12,
direction: bcode_session_models::SessionHistoryDirection::Backward,
},
)
.await
.expect("recent bounded history")
.events;
drop(sessions);
let mut model = root_test_model_with_history(session.id, &recent);
model
.chat
.app
.replace_latest_transcript_window(&recent, true);
let resume =
TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([TranscriptFrameStep {
label: "resume",
input: TranscriptFrameInput::Observe,
}]);
let before = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "reach older boundary with two outstanding rows",
input: TranscriptFrameInput::ScrollUp(resume[0].observation.viewport_top + 2),
},
TranscriptFrameStep {
label: "repeat upward input during fetch",
input: TranscriptFrameInput::ScrollUp(1),
},
]);
let reader = &before[1].observation;
assert_eq!(before[0].observation.viewport_top, reader.viewport_top);
let cursor = model.chat.app.older_history_cursor().expect("older cursor");
let generation = model.chat.app.history_window_generation();
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
let (scheduled, _) = model.chat.pending_effects.drain_runtime();
let effect = scheduled.into_iter().find_map(|(_, effect)| {
matches!(&effect, TuiEffect::LoadOlderHistory { session_id, cursor: expected, generation: current, .. }
if *session_id == session.id && *expected == cursor && *current == generation)
.then_some(effect)
}).expect("scheduled older-history effect");
let endpoint = bcode_ipc::IpcEndpoint::unix_socket(directory.path().join("server.sock"));
let shutdown = bcode_agent_runtime::CancellationToken::new();
let client =
bcode_client::BcodeClient::for_state_location(endpoint.clone(), locations.primary());
let server = bcode_server::run_embedded_with_locations_and_shutdown(
endpoint,
bcode_config::BcodeConfig::default(),
bcode_plugin::PluginRuntimeHost::load_defaults_with_static_bundled(
&bcode_plugin::PluginSelection {
mode: bcode_plugin::PluginSelectionMode::Explicit,
enabled: std::collections::BTreeSet::default(),
disabled: std::collections::BTreeSet::default(),
},
&[],
)
.expect("empty plugin host"),
bcode_model_catalog::ModelCatalogResolver::embedded(),
Vec::new(),
shutdown.clone(),
Some(locations),
);
let server = Box::pin(server);
let exercise = async {
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
if client.verified_server_status().await.is_ok() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await
.expect("server ready");
let result = effect.run_for_test(client.clone()).await;
if let super::super::effects::TuiEffectResult::OlderHistoryLoaded {
result: page, ..
} = &result
{
let (page, _) = page.as_ref().expect("persisted first page");
assert!(page.events.len() <= 512);
assert!(
page.events.iter().all(|event| matches!(
&event.kind,
SessionEventKind::ClientAttached { .. }
))
);
assert!(page.has_more);
} else {
panic!("expected older-history completion");
}
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::EffectCompleted(Box::new(result)),
),
)
.expect("accept fetched page");
let after = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "persisted page",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "repeat",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(after[0].observation.semantic_items, reader.semantic_items);
assert_eq!(after[0].observation.viewport_top, reader.viewport_top);
assert_eq!(after[0].observation, after[1].observation);
let empty_page_layout = model.chat.app.transcript_layout_mut().drain_sync_stats();
assert!(empty_page_layout.iter().all(|stats| stats.entries_scanned
<= model.chat.app.resident_transcript_event_count() + 1));
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
let (scheduled, _) = model.chat.pending_effects.drain_runtime();
let next_cursor = model
.chat
.app
.older_history_cursor()
.expect("advanced source cursor");
assert!(next_cursor.sequence < cursor.sequence);
let effect = scheduled.into_iter().find_map(|(_, effect)| {
matches!(&effect, TuiEffect::LoadOlderHistory { session_id, cursor: expected, generation: current, .. }
if *session_id == session.id && *expected == next_cursor && *current == generation)
.then_some(effect)
}).expect("second scheduled history page");
let result = effect.run_for_test(client.clone()).await;
if let super::super::effects::TuiEffectResult::OlderHistoryLoaded {
result: page, ..
} = &result
{
assert!(page.is_ok(), "second persisted projection effect: {page:?}");
} else {
panic!("expected older-history completion");
}
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::EffectCompleted(Box::new(result)),
),
)
.expect("accept presenting page");
let presenting = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "persisted presenting page",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "presenting page settled",
input: TranscriptFrameInput::Observe,
},
]);
assert!(presenting[0].observation.semantic_items.len() > reader.semantic_items.len());
assert_eq!(presenting[0].observation, presenting[1].observation);
let presenting_layout = model.chat.app.transcript_layout_mut().drain_sync_stats();
let resident = model.chat.app.resident_transcript_event_count();
assert!(
presenting_layout
.iter()
.all(|stats| stats.entries_scanned <= resident + 1)
);
assert!(
presenting_layout
.iter()
.map(|stats| stats.rows_regenerated)
.sum::<usize>()
<= presenting[0].observation.terminal_rows.len() * 2
);
let anchor = reader.anchor.as_ref().expect("reader anchor");
let source = &reader.terminal_rows[reader.viewport_top + anchor.viewport_row_offset];
let row = presenting[0]
.observation
.terminal_rows
.iter()
.position(|candidate| candidate == source)
.expect("reader source survives fetched pages");
assert!(row >= presenting[0].observation.viewport_top);
let offset = row - presenting[0].observation.viewport_top;
assert_eq!(
offset.saturating_sub(anchor.viewport_row_offset),
3,
"non-presenting page must not spend three outstanding rows; presenting page reveals them once"
);
assert!(model.chat.app.newer_history_cursor().is_none());
let centered = (5..=25)
.map(|index| root_test_history_event(session.id, index))
.collect::<Vec<_>>();
model
.chat
.app
.replace_transcript_window(¢ered, true, true, 17);
let center = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "centered window",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "centered settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(center[0].observation, center[1].observation);
let newer_cursor = model
.chat
.app
.newer_history_cursor()
.expect("newer cursor from centered window");
let centered_rows = center[0].observation.terminal_rows.len();
let boundary_top = centered_rows.saturating_sub(12);
let to_boundary = boundary_top.saturating_sub(center[0].observation.viewport_top);
let at_boundary = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "move across centered resident rows",
input: TranscriptFrameInput::ScrollDown(to_boundary),
},
TranscriptFrameStep {
label: "newer boundary settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(at_boundary[0].observation, at_boundary[1].observation);
let before_forward = &at_boundary[1].observation;
let previous_anchor = before_forward
.anchor
.as_ref()
.expect("newer-boundary reader");
let previous_source = before_forward.terminal_rows
[before_forward.viewport_top + previous_anchor.viewport_row_offset]
.clone();
model.chat.app.request_newer_history_for_test(2);
let generation = model.chat.app.history_window_generation();
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
let (scheduled, _) = model.chat.pending_effects.drain_runtime();
let effect = scheduled.into_iter().find_map(|(_, effect)| {
matches!(&effect, TuiEffect::LoadNewerHistory { session_id, cursor, generation: current, .. }
if *session_id == session.id && *cursor == newer_cursor && *current == generation)
.then_some(effect)
}).expect("scheduled newer history page");
let result = effect.run_for_test(client.clone()).await;
if let super::super::effects::TuiEffectResult::NewerHistoryLoaded {
result: page, ..
} = &result
{
let (page, _) = page.as_ref().expect("persisted newer projection page");
assert!(page.events.len() <= 512);
assert!(
page.events.iter().all(|event| matches!(
&event.kind,
SessionEventKind::ClientAttached { .. }
))
);
assert!(page.has_more);
} else {
panic!("expected newer-history completion");
}
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::EffectCompleted(Box::new(result)),
),
)
.expect("accept newer page");
let forward = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "persisted newer page",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "newer page settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(forward[0].observation, forward[1].observation);
assert_eq!(
forward[0].observation.semantic_items,
center[0].observation.semantic_items
);
assert_eq!(
forward[0].observation.viewport_top,
before_forward.viewport_top
);
assert!(
forward[0]
.observation
.terminal_rows
.contains(&previous_source)
);
let second_newer_before = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12)
.run([
TranscriptFrameStep {
label: "request newer rows across non-presenting gap",
input: TranscriptFrameInput::ScrollDown(2),
},
TranscriptFrameStep {
label: "newer request settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(
second_newer_before[0].observation,
second_newer_before[1].observation
);
assert!(
second_newer_before[0].observation.viewport_top
- forward[0].observation.viewport_top
<= 2,
"manual movement stays within the two requested rows"
);
let next_cursor = model
.chat
.app
.newer_history_cursor()
.expect("advanced newer cursor");
assert!(next_cursor.sequence > newer_cursor.sequence);
let newer_reader_at_boundary = forward[0]
.observation
.anchor
.as_ref()
.expect("reader at newer edge");
let source_after_nonpresenting =
&forward[0].observation.terminal_rows[forward[0].observation.viewport_top
+ newer_reader_at_boundary.viewport_row_offset];
assert_eq!(source_after_nonpresenting, &previous_source);
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
let (scheduled, _) = model.chat.pending_effects.drain_runtime();
let effect = scheduled.into_iter().find_map(|(_, effect)| {
matches!(&effect, TuiEffect::LoadNewerHistory { session_id, cursor, generation: current, .. }
if *session_id == session.id && *cursor == next_cursor && *current == generation)
.then_some(effect)
}).expect("second scheduled newer page");
let result = effect.run_for_test(client.clone()).await;
if let super::super::effects::TuiEffectResult::NewerHistoryLoaded {
result: page, ..
} = &result
{
assert!(page.is_ok(), "second newer page: {page:?}");
} else {
panic!("expected second newer completion");
}
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::EffectCompleted(Box::new(result)),
),
)
.expect("accept presenting newer page");
let presenting_newer = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "persisted presenting newer page",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "newer page stable",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(
presenting_newer[0].observation,
presenting_newer[1].observation
);
assert!(
presenting_newer[0].observation.semantic_items.len()
> forward[0].observation.semantic_items.len()
);
let reader_row = presenting_newer[0]
.observation
.terminal_rows
.iter()
.position(|row| row == &previous_source)
.expect("centered reader source survives newer pages");
let earlier_row = forward[0]
.observation
.terminal_rows
.iter()
.position(|row| row == &previous_source)
.expect("reader before presenting newer page");
let prior_top = second_newer_before[1].observation.viewport_top;
let moved_after_page = presenting_newer[0]
.observation
.viewport_top
.saturating_sub(prior_top)
.saturating_sub(reader_row.saturating_sub(earlier_row));
assert_eq!(
moved_after_page, 2,
"the page reveals exactly the two outstanding rows once"
);
let centered_reader = center[0]
.observation
.anchor
.as_ref()
.expect("centered source reader");
let centered_source = center[0].observation.terminal_rows
[center[0].observation.viewport_top + centered_reader.viewport_row_offset]
.clone();
assert!(
presenting_newer[0]
.observation
.terminal_rows
.contains(¢ered_source)
);
let tail = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "return to live tail",
input: TranscriptFrameInput::ScrollToBottom,
},
TranscriptFrameStep {
label: "live tail settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(tail[0].observation, tail[1].observation);
assert_eq!(tail[0].observation.scroll_mode, "bottom_follow");
shutdown.cancel();
};
let (server_result, ()) = Box::pin(tokio::time::timeout(
std::time::Duration::from_secs(30),
async { tokio::join!(server, exercise) },
))
.await
.expect("server and effect complete");
server_result.expect("server shutdown");
drop(model);
}
#[tokio::test]
async fn failed_and_empty_older_pages_do_not_reveal_or_reopen_reading_position() {
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
let session_id = bcode_session_models::SessionId::new();
let recent = (30..42)
.map(|sequence| root_test_history_event(session_id, sequence))
.collect::<Vec<_>>();
let mut model = root_test_model_with_history(session_id, &recent);
model
.chat
.app
.replace_latest_transcript_window(&recent, true);
let initial = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "resume",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "reach boundary",
input: TranscriptFrameInput::ScrollUp(100),
},
]);
let first = &initial[1].observation;
let cursor = model.chat.app.older_history_cursor().expect("older cursor");
let generation = model.chat.app.history_window_generation();
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
deliver_older_history_root_test(
&mut model,
session_id,
cursor,
generation,
Err(bcode_client::ClientError::UnexpectedResponse),
);
let failed = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "failure",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "retry",
input: TranscriptFrameInput::ScrollUp(2),
},
]);
assert_eq!(first.terminal_rows, failed[0].observation.terminal_rows);
assert_eq!(first.viewport_top, failed[0].observation.viewport_top);
assert!(!model.chat.app.loading_older_history());
assert!(model.chat.app.older_history_cursor().is_some());
model.chat.pending_effects = super::super::effects::TuiEffectQueue::default();
model.chat.app.request_older_history_for_test(1);
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
assert!(
model.chat.app.loading_older_history(),
"retry must be scheduled"
);
complete_older_history_root_test(&mut model, session_id, cursor, generation, Vec::new());
let exhausted = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "exhausted",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "again",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(
failed[0].observation.terminal_items,
exhausted[0].observation.terminal_items
);
assert_eq!(
failed[0].observation.viewport_top,
exhausted[0].observation.viewport_top
);
assert_eq!(exhausted[0].observation, exhausted[1].observation);
assert!(model.chat.app.older_history_cursor().is_none());
drop(model);
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn failed_and_empty_newer_pages_preserve_reader_and_stop_retrying() {
use super::super::effects::TuiEffectResult;
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
let session_id = bcode_session_models::SessionId::new();
let centered = (30..42)
.map(|sequence| root_test_history_event(session_id, sequence))
.collect::<Vec<_>>();
let mut model = root_test_model_with_history(session_id, ¢ered);
model
.chat
.app
.replace_transcript_window(¢ered, true, true, 35);
let initial = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "centered window",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "reach newer boundary",
input: TranscriptFrameInput::ScrollDown(100),
},
]);
let boundary = &initial[1].observation;
let cursor = model.chat.app.newer_history_cursor().expect("newer cursor");
let generation = model.chat.app.history_window_generation();
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
assert!(
model
.chat
.app
.accepts_history_page(generation, cursor, false)
);
let complete = |model: &mut super::BcodeRuntimeModel, result| {
bmux_tui_runtime::Program::update(
model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::EffectCompleted(Box::new(
TuiEffectResult::NewerHistoryLoaded {
session_id,
cursor,
generation,
result,
},
)),
),
)
.expect("newer history effect completion");
};
complete(
&mut model,
Err(bcode_client::ClientError::UnexpectedResponse),
);
let failed = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "newer page failure",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "failure settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(failed[0].observation, failed[1].observation);
assert_eq!(failed[0].observation.terminal_rows, boundary.terminal_rows);
assert_eq!(failed[0].observation.viewport_top, boundary.viewport_top);
assert!(
!model
.chat
.app
.accepts_history_page(generation, cursor, false)
);
assert_eq!(model.chat.app.newer_history_cursor(), Some(cursor));
model.chat.pending_effects = super::super::effects::TuiEffectQueue::default();
model.chat.app.request_newer_history_for_test(1);
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
assert!(
model
.chat
.app
.accepts_history_page(generation, cursor, false),
"retry must start"
);
complete(
&mut model,
Ok((
bcode_session_models::SessionHistoryPage {
session_id,
events: Vec::new(),
compatibility_issues: Vec::new(),
next_cursor: None,
has_more: false,
},
Vec::new(),
)),
);
let exhausted = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "newer history exhausted",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "exhausted settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(exhausted[0].observation, exhausted[1].observation);
assert_eq!(
exhausted[0].observation.terminal_items,
boundary.terminal_items
);
assert_eq!(exhausted[0].observation.viewport_top, boundary.viewport_top);
assert!(model.chat.app.newer_history_cursor().is_none());
assert!(
!model
.chat
.app
.accepts_history_page(generation, cursor, false)
);
drop(model);
}
#[tokio::test]
async fn older_history_loading_banner_keeps_source_reader_stable() {
use super::super::frame_sequence_harness::{
TranscriptFrameInput, TranscriptFrameSequence, TranscriptFrameStep,
};
let session_id = bcode_session_models::SessionId::new();
let recent = (30..54)
.map(|sequence| root_test_history_event(session_id, sequence))
.collect::<Vec<_>>();
let mut model = root_test_model_with_history(session_id, &recent);
model
.chat
.app
.replace_latest_transcript_window(&recent, true);
let initial = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "resume",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "near older boundary",
input: TranscriptFrameInput::ScrollUp(100),
},
]);
let before = &initial[1].observation;
let anchor = before.anchor.as_ref().expect("source-backed reader");
let source = before.terminal_rows[before.viewport_top + anchor.viewport_row_offset].clone();
let cursor = model
.chat
.app
.older_history_cursor()
.expect("older boundary");
let generation = model.chat.app.history_window_generation();
model
.loop_state
.prepare_runtime_work(&mut model.chat, bmux_tui::geometry::Rect::new(0, 0, 80, 12));
assert!(
model
.chat
.app
.accepts_history_page(generation, cursor, true)
);
let loading = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "loading banner",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "loading banner settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(loading[0].observation, loading[1].observation);
let position = loading[0]
.observation
.terminal_rows
.iter()
.position(|row| row == &source)
.expect("reader retained during loading");
assert_eq!(
position.saturating_sub(loading[0].observation.viewport_top),
anchor.viewport_row_offset
);
complete_older_history_root_test(&mut model, session_id, cursor, generation, Vec::new());
let exhausted = TranscriptFrameSequence::new(&mut model.chat.app, 80, 12).run([
TranscriptFrameStep {
label: "banner removed after exhaustion",
input: TranscriptFrameInput::Observe,
},
TranscriptFrameStep {
label: "banner removal settled",
input: TranscriptFrameInput::Observe,
},
]);
assert_eq!(exhausted[0].observation, exhausted[1].observation);
let position = exhausted[0]
.observation
.terminal_rows
.iter()
.position(|row| row == &source)
.expect("reader retained after exhaustion");
let exhausted_offset = position.saturating_sub(exhausted[0].observation.viewport_top);
assert_eq!(exhausted[0].observation.viewport_top, 0);
assert_eq!(
exhausted_offset,
anchor.viewport_row_offset.saturating_sub(1)
);
assert!(model.chat.app.older_history_cursor().is_none());
drop(model);
}
fn root_test_history_event(
session_id: bcode_session_models::SessionId,
sequence: u64,
) -> bcode_session_models::SessionEvent {
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}"),
},
}
}
fn deliver_older_history_root_test(
model: &mut super::BcodeRuntimeModel,
session_id: bcode_session_models::SessionId,
cursor: bcode_session_models::SessionHistoryCursor,
generation: u64,
result: Result<bcode_session_models::SessionHistoryPage, bcode_client::ClientError>,
) {
bmux_tui_runtime::Program::update(
model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::EffectCompleted(
Box::new(super::super::effects::TuiEffectResult::OlderHistoryLoaded {
session_id,
cursor,
generation,
result: result.map(|page| (page, Vec::new())),
}),
)),
)
.expect("history effect completion");
}
fn complete_older_history_root_test(
model: &mut super::BcodeRuntimeModel,
session_id: bcode_session_models::SessionId,
cursor: bcode_session_models::SessionHistoryCursor,
generation: u64,
events: Vec<bcode_session_models::SessionEvent>,
) {
deliver_older_history_root_test(
model,
session_id,
cursor,
generation,
Ok(bcode_session_models::SessionHistoryPage {
session_id,
events,
compatibility_issues: vec![],
next_cursor: None,
has_more: false,
}),
);
}
struct FailingSelectionOutput {
fail: std::rc::Rc<std::cell::Cell<bool>>,
}
impl std::io::Write for FailingSelectionOutput {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if self.fail.get() {
Err(std::io::Error::other("injected presentation failure"))
} else {
Ok(bytes.len())
}
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn assert_visual_selection_matches_container(app: &super::super::app::BmuxApp) {
let retained = app.transcript_layout();
let mut checked = 0;
for visible in retained.visible_lines_from_top(0, u16::MAX) {
let Some(source) = retained.selection_row(visible.entry_index, visible.row_in_entry)
else {
continue;
};
let Some(cell) = source.cells.first() else {
continue;
};
if source.text.is_empty() {
continue;
}
let painted = retained.line(visible).unwrap().plain_text();
let start = painted
.find(&source.text)
.expect("source appears in painted container");
assert_eq!(
usize::from(cell.column),
bmux_tui::text_width::display_width(&painted[..start])
);
checked += 1;
}
assert!(checked > 0);
}
#[tokio::test]
async fn shell_navigation_uses_committed_clipped_targets() {
use bmux_tui::event::{Event, MouseButton, MouseEvent, MouseEventKind};
use bmux_tui::geometry::{Point, Rect};
use bmux_tui_runtime::Presenter;
let session_id = bcode_session_models::SessionId::new();
let mut model = root_test_model_with_history(session_id, &[]);
let bundled = [bcode_plugin::StaticBundledPlugin::new(
include_str!("../../../plugins/shell-plugin/bcode-plugin.toml"),
bcode_shell_plugin::static_plugin(),
)];
let selected = bcode_plugin::filter_selected_static_plugins(
&bundled,
&bcode_plugin::PluginSelection::all_enabled(),
)
.unwrap();
model.chat.app.set_plugin_host(std::sync::Arc::new(
bcode_plugin::PluginHost::load_static_plugins(&selected).unwrap(),
));
model.chat.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::ToolInvocationResultRecorded {
record: bcode_session_models::ToolInvocationResultRecord {
invocation_id: "shell-navigation".to_owned(), model_output: "fallback".to_owned(), is_error: false, presentation: None,
result: Some(bcode_session_models::ToolInvocationResult::Artifact {
artifact: Box::new(bcode_session_models::ToolArtifact {
artifact_id: "shell-navigation".to_owned(), producer_plugin_id: "bcode.shell".to_owned(),
schema: "bcode.tool.request.shell.run".to_owned(), schema_version: 1, title: None,
tool_call_id: Some("shell-navigation".to_owned()),
metadata: serde_json::json!({"arguments":{"command":"fixture"}, "_bcode_runtime": {
"live_state_key":"shell-navigation", "columns":80, "rows":24, "streaming":true,
"output":"\u{1b}[?1049habcdefghijklmnopqrstuvwxyz0123456789"}}),
refs: Vec::new(),
}),
}),
content: Vec::new(),
},
},
});
model
.chat
.app
.set_effective_keymap(model.settings.keymap().registry().clone());
let registry = model
.chat
.app
.plugin_presentation()
.unwrap()
.registry("bcode.shell")
.unwrap();
let seed = registry.visual_projection(
"shell-run-request-card",
"bcode.tool.request.shell.run",
&serde_json::json!({"arguments":{"command":"fixture"}, "_bcode_runtime": {
"live_state_key":"shell-navigation", "columns":80, "rows":24, "streaming":true,
"output":"\u{1b}[?1049habcdefghijklmnopqrstuvwxyz0123456789"}}),
&bcode_plugin_sdk::tui::PluginTuiVisualRenderContext::new(
24,
bcode_plugin_sdk::tui::PluginTuiDiffLayout::Unified,
None,
),
);
assert!(seed.is_some());
drop(registry);
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, Rect::new(0, 0, 24, 40));
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.unwrap();
let (target, identity) = model
.committed_visual_targets
.iter()
.find(|(_, identity)| identity.contains(":terminal:"))
.cloned()
.expect("visible shell terminal target");
assert!(target.height == 1 && target.width <= 24);
assert!(!model.committed_visual_text.is_empty());
let event = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Right),
Point::new(target.x, target.y),
));
assert_eq!(
model.handle_basic_terminal_event(event),
super::super::invalidation::UiInvalidation::Structural
);
assert_eq!(model.focused_visual.as_deref(), Some(identity.as_str()));
let before = model.committed_visual_text.clone();
assert_eq!(
model.handle_basic_terminal_event(Event::Key(bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Right,
modifiers: bmux_keyboard::Modifiers::default(),
})),
super::super::invalidation::UiInvalidation::Structural
);
model.presentation_damage = bmux_tui::damage::Damage::Full;
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.unwrap();
assert_ne!(before, model.committed_visual_text);
drop(model);
}
#[tokio::test]
async fn visual_selection_text_commits_on_first_frame_and_survives_failed_resize() {
use bmux_tui_runtime::Presenter;
let session_id = bcode_session_models::SessionId::new();
let mut model = root_test_model_with_history(session_id, &[]);
let bundled = [bcode_plugin::StaticBundledPlugin::new(
include_str!("../../../plugins/filesystem-plugin/bcode-plugin.toml"),
bcode_filesystem_plugin::static_plugin(),
)];
let selected = bcode_plugin::filter_selected_static_plugins(
&bundled,
&bcode_plugin::PluginSelection::all_enabled(),
)
.unwrap();
model.chat.app.set_plugin_host(std::sync::Arc::new(
bcode_plugin::PluginHost::load_static_plugins(&selected).unwrap(),
));
model
.chat
.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::ToolInvocationResultRecorded {
record: bcode_session_models::ToolInvocationResultRecord {
invocation_id: "source-selection".to_owned(),
model_output: "fallback".to_owned(),
is_error: false,
presentation: None,
result: Some(bcode_session_models::ToolInvocationResult::Artifact {
artifact: Box::new(bcode_session_models::ToolArtifact {
artifact_id: "source-selection".to_owned(),
producer_plugin_id: "bcode.filesystem".to_owned(),
schema: "bcode.filesystem.read".to_owned(),
schema_version: 1,
tool_call_id: Some("source-selection".to_owned()),
title: Some("File contents".to_owned()),
metadata: serde_json::json!({
"path": "test.rs",
"contents": "pub fn alpha() {}\npub fn beta() {}",
"start_line": 1,
}),
refs: Vec::new(),
}),
}),
content: Vec::new(),
},
},
});
assert!(model.chat.app.preview_theme("terminal-native-structured"));
let fail = std::rc::Rc::new(std::cell::Cell::new(false));
let mut output = FailingSelectionOutput { fail: fail.clone() };
let mut terminal = bmux_tui::terminal::Terminal::new(
&mut output,
bmux_tui::geometry::Rect::new(0, 0, 80, 24),
);
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.unwrap();
assert!(!model.committed_visual_text.is_empty());
assert_visual_selection_matches_container(&model.chat.app);
let targets = model.committed_visual_targets.clone();
assert!(!targets.is_empty());
model.focused_visual = Some("removed-target".to_owned());
let before: Vec<_> = model
.committed_visual_text
.iter()
.map(|row| (row.identity.clone(), row.byte_start, row.text.clone()))
.collect();
fail.set(true);
terminal.resize(bmux_tui::geometry::Rect::new(0, 0, 30, 12));
model.presentation_damage = bmux_tui::damage::Damage::Full;
assert!(
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.is_err()
);
let after: Vec<_> = model
.committed_visual_text
.iter()
.map(|row| (row.identity.clone(), row.byte_start, row.text.clone()))
.collect();
assert_eq!(before, after);
assert_eq!(targets, model.committed_visual_targets);
assert_eq!(model.focused_visual.as_deref(), Some("removed-target"));
fail.set(false);
model.presentation_damage = bmux_tui::damage::Damage::Full;
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.unwrap();
assert!(!model.committed_visual_text.is_empty());
assert!(model.focused_visual.is_none());
assert_ne!(targets, model.committed_visual_targets);
drop(model);
}
#[tokio::test]
async fn normal_present_and_mouse_input_path_creates_transcript_selection() {
use bmux_tui_runtime::Presenter;
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::UserMessage {
client_id: bcode_session_models::ClientId::new(),
text: "normal input path".to_owned(),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
}];
let mut model = root_test_model_with_history(session_id, &history);
let area = bmux_tui::geometry::Rect::new(0, 0, 40, 12);
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, area);
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("startup frame presents");
let fragment = model
.committed_selection
.fragments()
.first()
.cloned()
.expect("startup transcript fragment");
assert_eq!(
model.handle_basic_terminal_event(bmux_tui::event::Event::Mouse(
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(fragment.area.x, fragment.area.y),
),
)),
super::super::invalidation::UiInvalidation::None
);
assert!(matches!(
model.handle_basic_terminal_event(bmux_tui::event::Event::Mouse(
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(
fragment.area.x.saturating_add(fragment.area.width),
fragment.area.y,
),
),
)),
super::super::invalidation::UiInvalidation::Paint
| super::super::invalidation::UiInvalidation::Structural
));
assert_eq!(
model.handle_basic_terminal_event(bmux_tui::event::Event::Mouse(
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Up(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(
fragment.area.x.saturating_add(fragment.area.width),
fragment.area.y,
),
),
)),
super::super::invalidation::UiInvalidation::Paint
);
assert!(model.selected_plain_text.is_some());
assert!(
model
.transcript_selection
.snapshot(&model.committed_selection)
.is_some()
);
drop(model);
}
#[tokio::test]
async fn scrolling_reprojects_selection_highlights_for_still_visible_content() {
use bmux_tui_runtime::Presenter;
let session_id = bcode_session_models::SessionId::new();
let history = (1..=20)
.map(|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::UserMessage {
client_id: bcode_session_models::ClientId::new(),
text: format!("message {sequence}"),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
})
.collect::<Vec<_>>();
let mut model = root_test_model_with_history(session_id, &history);
let area = bmux_tui::geometry::Rect::new(0, 0, 40, 12);
let mut initial_bytes = Vec::new();
let mut initial_terminal = bmux_tui::terminal::Terminal::new(&mut initial_bytes, area);
super::BcodeRuntimePresenter::new(&mut initial_terminal)
.present(&mut model)
.expect("initial frame presents");
let fragment = model
.committed_selection
.fragments()
.iter()
.find(|fragment| {
fragment.area.y
== model
.committed_layout
.expect("committed layout")
.body()
.bottom()
.saturating_sub(2)
})
.cloned()
.expect("penultimate visible fragment");
let _ = model.transcript_selection.handle_mouse(
&model.committed_selection,
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(fragment.area.x, fragment.area.y),
),
);
let _ = model.transcript_selection.handle_mouse(
&model.committed_selection,
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(
fragment.area.x.saturating_add(fragment.area.width),
fragment.area.y,
),
),
);
let before = model
.transcript_selection
.snapshot(&model.committed_selection)
.expect("selection before scroll");
let before_highlights = before.visible_highlights.clone();
assert!(model.chat.app.scroll_transcript_up(1));
model.presentation_damage = bmux_tui::damage::Damage::Full;
let mut scrolled_bytes = Vec::new();
let mut scrolled_terminal = bmux_tui::terminal::Terminal::new(&mut scrolled_bytes, area);
super::BcodeRuntimePresenter::new(&mut scrolled_terminal)
.present(&mut model)
.expect("scrolled frame presents");
let after = model
.transcript_selection
.snapshot(&model.committed_selection)
.expect("selection survives scroll");
assert_eq!(after.slices, before.slices);
assert_ne!(after.visible_highlights, before_highlights);
assert!(after.visible_highlights.iter().all(|highlight| {
model
.committed_layout
.expect("committed layout")
.body()
.intersection(*highlight)
== *highlight
}));
drop(model);
}
#[tokio::test]
async fn presenter_paints_selection_highlight_over_real_transcript_content() {
use bmux_tui_runtime::Presenter;
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::UserMessage {
client_id: bcode_session_models::ClientId::new(),
text: "selectable transcript text".to_owned(),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
}];
let mut model = root_test_model_with_history(session_id, &history);
let area = bmux_tui::geometry::Rect::new(0, 0, 40, 12);
let mut initial_bytes = Vec::new();
let mut initial_terminal = bmux_tui::terminal::Terminal::new(&mut initial_bytes, area);
super::BcodeRuntimePresenter::new(&mut initial_terminal)
.present(&mut model)
.expect("initial frame presents");
let fragment = model
.committed_selection
.fragments()
.first()
.cloned()
.expect("transcript fragment");
let _ = model.transcript_selection.handle_mouse(
&model.committed_selection,
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(fragment.area.x, fragment.area.y),
),
);
let _ = model.transcript_selection.handle_mouse(
&model.committed_selection,
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(
fragment.area.x.saturating_add(fragment.area.width),
fragment.area.y,
),
),
);
model.presentation_damage = bmux_tui::damage::Damage::Full;
let mut selected_bytes = Vec::new();
let mut selected_terminal = bmux_tui::terminal::Terminal::new(&mut selected_bytes, area);
super::BcodeRuntimePresenter::new(&mut selected_terminal)
.present(&mut model)
.expect("selected frame presents");
let selected_cell = selected_terminal
.retained_buffer()
.and_then(|buffer| {
buffer.get(bmux_tui::geometry::Point::new(
fragment.area.x,
fragment.area.y,
))
})
.expect("selected cell");
assert_eq!(
selected_cell.style.bg,
model.chat.app.presented_theme().selection.bg
);
drop(model);
}
#[tokio::test]
async fn committed_presentation_invalidates_removed_selection_and_unpins_history() {
use bmux_tui_runtime::Presenter;
let mut model = root_test_model();
let area = bmux_tui::geometry::Rect::new(0, 0, 40, 12);
model.committed_selection.push_scope(
bmux_tui::selection::SelectionScope::new(
"bcode.transcript.item.removed",
bmux_tui::geometry::Rect::new(0, 1, 12, 1),
)
.parent("bcode.transcript"),
);
model
.committed_selection
.push_scope(bmux_tui::selection::SelectionScope::new(
"bcode.transcript",
bmux_tui::geometry::Rect::new(0, 1, 40, 6),
));
for fragment in bmux_tui::selection::plain_text_fragments(
"bcode.transcript.item.removed",
"bcode.transcript.item.1.plain",
bmux_tui::geometry::Rect::new(0, 1, 12, 1),
0,
"removed",
0,
1,
) {
model.committed_selection.push_fragment(fragment);
}
let _ = model.transcript_selection.handle_mouse(
&model.committed_selection,
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(0, 1),
),
);
let _ = model.transcript_selection.handle_mouse(
&model.committed_selection,
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(6, 1),
),
);
model.selected_plain_text = Some("removed".to_owned());
model.chat.app.set_transcript_selection_pinned(true);
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, area);
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("replacement frame presents");
assert!(model.selected_plain_text.is_none());
assert_eq!(
model.transcript_selection.phase(),
bmux_tui::selection::SelectionGesturePhase::Idle
);
assert!(
model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
assert!(model.presentation_damage.is_full());
assert!(!model.fast_temporal_presentation);
drop(model);
}
#[tokio::test]
async fn active_transcript_selection_pins_resident_history() {
let mut model = root_test_model();
assert!(
model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
model.chat.app.set_transcript_selection_pinned(true);
assert!(
!model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
model.chat.app.set_transcript_selection_pinned(false);
assert!(
model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
}
fn selected_test_model(config: &bcode_config::TuiConfig) -> super::BcodeRuntimeModel {
let mut model = root_test_model_with_tui_config(config);
model
.chat
.app
.push_ephemeral_system_plain("abcdef".to_owned());
let item = model
.chat
.app
.transcript()
.iter()
.next_back()
.expect("source item");
let item_id = item.id().get();
let item_revision = item.revision();
model.transcript_clipboard = |text| {
assert_eq!(text, "abcdef");
Ok(())
};
let layout = super::super::render::prepare_frame(
&mut model.chat.app,
bmux_tui::geometry::Rect::new(0, 0, 20, 12),
)
.expect("layout");
let body = layout.body();
model.committed_layout = Some(layout);
model
.committed_selection
.push_scope(bmux_tui::selection::SelectionScope::new(
"bcode.transcript",
body,
));
for fragment in bmux_tui::selection::plain_text_fragments(
"bcode.transcript",
format!("bcode.transcript.item.{item_id}.plain"),
bmux_tui::geometry::Rect::new(body.x, body.y + 1, 6, 1),
0,
"abcdef",
0,
item_revision,
) {
model.committed_selection.push_fragment(fragment);
}
assert!(model.committed_selection.validate().is_ok());
for (kind, x) in [
(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
body.x,
),
(
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
body.x + 5,
),
(
bmux_tui::event::MouseEventKind::Up(bmux_tui::event::MouseButton::Left),
body.x + 5,
),
] {
model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
kind,
bmux_tui::geometry::Point::new(x, body.y + 1),
));
}
model
}
fn rendered_selection_test_model(
terminal: &mut bmux_tui::terminal::Terminal<&mut Vec<u8>>,
) -> (super::BcodeRuntimeModel, bmux_tui::geometry::Point) {
use bmux_tui_runtime::Presenter;
let mut config = bcode_config::TuiConfig::default();
config.mouse.selection_autoscroll = false;
let mut model = root_test_model_with_tui_config(&config);
model
.chat
.app
.push_ephemeral_system_markdown("abcdef".to_owned());
let content_id = format!(
"bcode.transcript.item.{}.markdown",
model
.chat
.app
.transcript()
.iter()
.next_back()
.expect("item")
.id()
.get()
);
model.transcript_clipboard = |text| {
assert_eq!(text, "abcdef");
Ok(())
};
super::BcodeRuntimePresenter::new(terminal)
.present(&mut model)
.expect("initial frame");
let fragment = model
.committed_selection
.fragments()
.iter()
.find(|fragment| {
fragment.content_id.as_str() == content_id && fragment.source_range.start == 0
})
.expect("source fragment");
let point = bmux_tui::geometry::Point::new(fragment.area.x, fragment.area.y);
assert_eq!(
terminal
.retained_buffer()
.expect("frame")
.get(point)
.expect("cell")
.symbol,
"a"
);
assert!(model.committed_selection.validate().is_ok());
assert!(model.committed_selection.initiation_scope(point).is_some());
(model, point)
}
#[tokio::test]
async fn starting_a_click_repaints_old_highlight_and_removes_timer_registry_entry() {
use bmux_tui_runtime::Presenter;
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(
&mut bytes,
bmux_tui::geometry::Rect::new(0, 0, 40, 12),
);
let (mut model, point) = rendered_selection_test_model(&mut terminal);
let initial_style = terminal
.retained_buffer()
.expect("frame")
.get(point)
.expect("cell")
.style;
for (kind, position) in [
(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
point,
),
(
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(point.x + 5, point.y),
),
(
bmux_tui::event::MouseEventKind::Up(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(point.x + 5, point.y),
),
] {
model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
kind, position,
));
}
assert_eq!(model.selected_plain_text.as_deref(), Some("abcdef"));
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("selected frame");
assert_ne!(
terminal
.retained_buffer()
.expect("frame")
.get(point)
.expect("cell")
.style,
initial_style
);
let timer = super::RootTimer::SelectionClear(model.selection_clear_generation).id();
model
.scheduled_deadlines
.insert(timer.clone(), model.selection_clear_at.expect("deadline"));
assert_eq!(
model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
point,
)),
Some(super::super::invalidation::UiInvalidation::Paint)
);
assert!(!model.scheduled_deadlines.contains_key(&timer));
assert!(model.selected_plain_text.is_none());
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("cleared frame");
assert_eq!(
terminal
.retained_buffer()
.expect("frame")
.get(point)
.expect("cell")
.style,
initial_style
);
assert_eq!(
model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Up(bmux_tui::event::MouseButton::Left),
point,
)),
None
);
assert!(
model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
drop(model);
}
#[tokio::test]
async fn release_copies_source_and_clears_only_when_due() {
let mut model = selected_test_model(&bcode_config::TuiConfig::default());
assert_eq!(model.chat.app.status(), "transcript selection copied");
assert_eq!(model.selected_plain_text.as_deref(), Some("abcdef"));
assert!(
!model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
let deadline = model.selection_clear_at.expect("scheduled clear");
assert_eq!(
model.clear_transcript_selection_if_due(
deadline
.checked_sub(std::time::Duration::from_millis(1))
.expect("earlier instant")
),
super::super::invalidation::UiInvalidation::None
);
assert_eq!(
model.clear_transcript_selection_if_due(deadline),
super::super::invalidation::UiInvalidation::Paint
);
assert!(model.selected_plain_text.is_none());
assert_eq!(
model.transcript_selection.phase(),
bmux_tui::selection::SelectionGesturePhase::Idle
);
assert!(
model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
drop(model);
}
#[tokio::test]
async fn manual_copy_obeys_disabled_auto_copy_and_clear_policy() {
let mut config = bcode_config::TuiConfig::default();
config.selection.auto_copy_on_mouse_release = false;
let mut model = selected_test_model(&config);
assert!(model.selection_clear_at.is_none());
model.copy_transcript_selection();
assert!(model.selection_clear_at.is_some());
config.selection.clear_after_copy = false;
model.settings.apply_tui_config(&config);
model.copy_transcript_selection();
assert!(model.selection_clear_at.is_none());
assert_eq!(model.selected_plain_text.as_deref(), Some("abcdef"));
config.selection.clear_after_copy = true;
config.selection.clear_delay_ms = 0;
model.settings.apply_tui_config(&config);
model.copy_transcript_selection();
assert!(model.selected_plain_text.is_none());
assert!(
model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
drop(model);
}
#[tokio::test]
async fn release_clear_policy_supports_disabled_and_immediate_clearing() {
let mut config = bcode_config::TuiConfig::default();
config.selection.clear_after_copy = false;
let retained = selected_test_model(&config);
assert_eq!(retained.chat.app.status(), "transcript selection copied");
assert_eq!(retained.selected_plain_text.as_deref(), Some("abcdef"));
assert!(retained.selection_clear_at.is_none());
drop(retained);
config.selection.clear_after_copy = true;
config.selection.clear_delay_ms = 0;
let cleared = selected_test_model(&config);
assert!(cleared.selected_plain_text.is_none());
assert!(cleared.selection_clear_at.is_none());
assert!(
cleared
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
drop(cleared);
}
#[tokio::test]
async fn click_without_drag_does_not_copy() {
let mut config = bcode_config::TuiConfig::default();
config.selection.auto_copy_on_mouse_release = false;
let mut model = selected_test_model(&config);
config.selection.auto_copy_on_mouse_release = true;
model.settings.apply_tui_config(&config);
model.transcript_clipboard = |_| panic!("click must not copy");
let body = model.committed_layout.expect("layout").body();
let point = bmux_tui::geometry::Point::new(body.x, body.y + 1);
model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
point,
));
assert_eq!(
model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Up(bmux_tui::event::MouseButton::Left),
point,
)),
None
);
assert!(model.selection_clear_at.is_none());
assert!(model.selected_plain_text.is_none());
assert!(
model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
drop(model);
}
#[tokio::test]
async fn failed_copy_retains_selection_and_cancels_prior_clear() {
let mut model = selected_test_model(&bcode_config::TuiConfig::default());
let deadline = model.selection_clear_at.expect("deadline");
model.transcript_clipboard = |_| Err("fake failure".to_owned());
model.copy_transcript_selection();
assert_eq!(model.chat.app.status(), "copy failed: fake failure");
assert_eq!(model.selected_plain_text.as_deref(), Some("abcdef"));
assert!(model.selection_clear_at.is_none());
model.clear_transcript_selection_if_due(deadline);
assert!(
!model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
drop(model);
}
#[tokio::test]
async fn stale_clear_does_not_erase_new_gesture_or_later_copy() {
let mut model = selected_test_model(&bcode_config::TuiConfig::default());
let old = model.selection_clear_at.expect("deadline");
model.selection_clear_at = Some(old + std::time::Duration::from_secs(1));
model.clear_transcript_selection_if_due(old);
assert!(model.selected_plain_text.is_some());
let body = model.committed_layout.expect("layout").body();
model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(body.x, body.y + 1),
));
assert!(model.selection_clear_at.is_none());
model.clear_transcript_selection_if_due(old + std::time::Duration::from_secs(2));
assert_eq!(
model.transcript_selection.phase(),
bmux_tui::selection::SelectionGesturePhase::Armed
);
drop(model);
}
#[tokio::test]
async fn reload_and_session_change_cancel_selection_deadlines() {
let mut model = selected_test_model(&bcode_config::TuiConfig::default());
let mut config = bcode_config::TuiConfig::default();
config.selection.clear_delay_ms = 25;
model.settings.apply_tui_config(&config);
model.synchronize_selection_lifecycle();
assert!(model.selection_clear_at.is_none());
assert!(model.selected_plain_text.is_some());
model.copy_transcript_selection();
let timer = super::RootTimer::SelectionClear(model.selection_clear_generation).id();
model
.scheduled_deadlines
.insert(timer.clone(), model.selection_clear_at.expect("deadline"));
model.chat.app = super::super::app::BmuxApp::new_with_history(
Some(bcode_session_models::SessionId::new()),
&[],
&[],
false,
);
model.synchronize_selection_lifecycle();
assert!(!model.scheduled_deadlines.contains_key(&timer));
assert!(model.selection_clear_at.is_none());
assert!(model.selected_plain_text.is_none());
assert!(model.committed_selection.scopes().is_empty());
assert!(
model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
drop(model);
}
#[tokio::test]
async fn empty_or_invalidated_selection_never_reaches_clipboard() {
let mut model = selected_test_model(&bcode_config::TuiConfig::default());
model.transcript_clipboard = |_| panic!("empty selection must not copy");
model.committed_selection = bmux_tui::selection::SelectionScene::new();
let timer = super::RootTimer::SelectionClear(model.selection_clear_generation).id();
model
.scheduled_deadlines
.insert(timer.clone(), model.selection_clear_at.expect("deadline"));
model.copy_transcript_selection();
assert!(!model.scheduled_deadlines.contains_key(&timer));
assert_eq!(model.chat.app.status(), "no transcript selection");
model.clear_transcript_selection();
assert!(model.selection_clear_at.is_none());
assert!(
model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
model.copy_transcript_selection();
assert!(model.selected_plain_text.is_none());
drop(model);
}
#[tokio::test]
async fn clear_key_routes_without_copying_or_modifying_composer() {
let mut config = bcode_config::TuiConfig::default();
config.keybindings.chat.insert(
"ctrl+shift+x".to_owned(),
"tui.transcript.clearSelection".to_owned(),
);
let mut model = selected_test_model(&config);
model.transcript_clipboard = |_| panic!("clear must not copy");
let input = model.chat.app.composer().text().to_owned();
assert_eq!(
model.handle_basic_terminal_event(bmux_tui::event::Event::Key(
bmux_keyboard::parse_key_stroke("ctrl+shift+x").expect("key"),
)),
super::super::invalidation::UiInvalidation::Paint
);
assert_eq!(model.chat.app.composer().text(), input);
assert!(model.selected_plain_text.is_none());
assert!(model.selection_clear_at.is_none());
assert!(
model
.chat
.app
.can_trim_resident_transcript_window_for_test()
);
drop(model);
}
#[tokio::test]
async fn old_timer_identity_cannot_clear_even_after_new_deadline() {
let mut model = selected_test_model(&bcode_config::TuiConfig::default());
let old = super::RootTimer::SelectionClear(model.selection_clear_generation).id();
model.copy_transcript_selection();
model.selection_clear_at = Some(std::time::Instant::now());
assert_eq!(
model.handle_timer(&old).0,
super::super::invalidation::UiInvalidation::None
);
assert!(model.selected_plain_text.is_some());
let current = super::RootTimer::SelectionClear(model.selection_clear_generation).id();
assert_eq!(
model.handle_timer(¤t).0,
super::super::invalidation::UiInvalidation::Paint
);
assert!(model.selected_plain_text.is_none());
drop(model);
}
#[tokio::test]
async fn clipboard_failure_reports_user_visible_status() {
let mut model = root_test_model();
model.report_transcript_copy_result(Err("clipboard unavailable"));
assert_eq!(
model.chat.app.status(),
"copy failed: clipboard unavailable"
);
drop(model);
}
#[test]
fn clear_selection_is_bindable_without_a_default_binding() {
use super::super::keymap::{BmuxAction, BmuxKeyMap, BmuxScope};
let mut config = bcode_config::TuiConfig::default();
let key = bmux_keyboard::parse_key_stroke("ctrl+shift+x").expect("key");
assert_eq!(
BmuxKeyMap::from_config(&config).action_for_key(BmuxScope::Chat, key),
None
);
config.keybindings.chat.insert(
"ctrl+shift+x".to_owned(),
"tui.transcript.clearSelection".to_owned(),
);
assert_eq!(
BmuxKeyMap::from_config(&config).action_for_key(BmuxScope::Chat, key),
Some(BmuxAction::TranscriptClearSelection)
);
}
#[test]
fn default_ctrl_shift_c_copies_transcript_selection() {
let keymap =
super::super::keymap::BmuxKeyMap::from_config(&bcode_config::TuiConfig::default());
let stroke = bmux_keyboard::parse_key_stroke("ctrl+shift+c").expect("valid key");
assert_eq!(
keymap.action_for_key(super::super::keymap::BmuxScope::Chat, stroke),
Some(super::super::keymap::BmuxAction::TranscriptCopySelection)
);
}
#[test]
fn root_message_contract_is_runtime_admissible() {
assert_runtime_message_is_send::<BcodeRuntimeMessage>();
}
#[test]
fn root_runtime_and_presenter_types_compose() {
fn assert_runtime<P, R>()
where
P: bmux_tui_runtime::Program<Message = BcodeRuntimeMessage>,
R: bmux_tui_runtime::Presenter<P>,
{
}
assert_runtime::<
super::BcodeRuntimeModel,
super::BcodeRuntimePresenter<'static, 'static, Vec<u8>>,
>();
}
fn note(session_id: SessionId, note_id: &str) -> super::super::effects::TuiEffect {
super::super::effects::TuiEffect::AppendPresentationNote {
session_id,
source_id: "test".to_owned(),
note_id: note_id.to_owned(),
text: note_id.to_owned(),
format: CommandTextFormat::PlainText,
}
}
struct StandaloneFocusedOwnerFixture {
model: super::BcodeRuntimeModel,
field: std::sync::Arc<std::sync::Mutex<bcode_plugin_sdk::tui::FocusedTextFieldSnapshot>>,
submits: std::sync::Arc<std::sync::atomic::AtomicUsize>,
editor_calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
fn standalone_focused_owner_fixture() -> StandaloneFocusedOwnerFixture {
standalone_focused_owner_fixture_with_outcome(bmux_tui::event::EventOutcome::Ignored)
}
fn standalone_focused_field()
-> std::sync::Arc<std::sync::Mutex<bcode_plugin_sdk::tui::FocusedTextFieldSnapshot>> {
use bcode_plugin_sdk::tui::FocusedTextFieldSnapshot;
std::sync::Arc::new(std::sync::Mutex::new(FocusedTextFieldSnapshot {
identity: bcode_plugin_sdk::tui::FocusedTextFieldIdentity {
owner_id: "standalone".into(),
field_id: "input".into(),
lifetime: 1,
},
generation: 0,
document: bcode_plugin_sdk::composer::ComposerDocument {
document_id: "standalone-input".into(),
revision: 0,
text: "@topic".into(),
cursor: 6,
selection: None,
},
eligible: true,
content_rect: bmux_tui::geometry::Rect::new(0, 4, 40, 1),
anchor: bmux_tui::geometry::Rect::new(6, 4, 1, 1),
}))
}
fn standalone_focused_owner_fixture_with_outcome(
editor_outcome: bmux_tui::event::EventOutcome,
) -> StandaloneFocusedOwnerFixture {
use bcode_plugin_sdk::tui::{
FocusedTextFieldEdit, FocusedTextFieldSnapshot, PluginTuiAction, PluginTuiEventResult,
};
use bmux_tui::event::{Event, EventOutcome};
struct Owner {
field: std::sync::Arc<std::sync::Mutex<FocusedTextFieldSnapshot>>,
submits: std::sync::Arc<std::sync::atomic::AtomicUsize>,
editor_calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
editor_outcome: EventOutcome,
}
impl bcode_plugin_sdk::tui::PluginTuiSurface for Owner {
fn id(&self) -> &'static str {
"test.focused-owner"
}
fn title(&self) -> &'static str {
"Focused owner"
}
fn render(
&mut self,
_: bmux_tui::geometry::Rect,
_: &mut bmux_tui::paint::PaintCx<'_, '_>,
) {
}
fn handle_event(
&mut self,
event: &Event,
_: &dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> PluginTuiAction {
if matches!(event, Event::Key(key) if key.key == bmux_keyboard::KeyCode::Enter) {
self.submits
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
PluginTuiAction::None
}
fn focused_text_field_event(
&mut self,
_: &Event,
_: &dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> PluginTuiEventResult {
self.editor_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
PluginTuiEventResult {
outcome: self.editor_outcome,
action: PluginTuiAction::None,
}
}
fn focused_text_field(&self) -> Option<FocusedTextFieldSnapshot> {
Some(self.field.lock().unwrap().clone())
}
fn apply_focused_text_field_edit_result(
&mut self,
edit: &FocusedTextFieldEdit,
_: &dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> bcode_plugin_sdk::tui::PluginTuiFocusedTextFieldEditResult {
let mut field = self.field.lock().unwrap();
let document = edit.validate(&field);
if let Ok(Some(document)) = document {
field.document = document;
field.generation += 1;
drop(field);
bcode_plugin_sdk::tui::PluginTuiFocusedTextFieldEditResult {
edit_result: bcode_plugin_sdk::tui::FocusedTextFieldEditResult::Applied,
event: PluginTuiEventResult {
outcome: EventOutcome::Redraw,
action: PluginTuiAction::Redraw,
},
}
} else {
drop(field);
bcode_plugin_sdk::tui::PluginTuiFocusedTextFieldEditResult::rejected(
bcode_plugin_sdk::composer::ComposerValidationError::Stale,
)
}
}
}
let mut model = root_test_model();
let field = standalone_focused_field();
let submits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let editor_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
model.loop_state.queue_root_plugin_surface(
"test.owner",
Box::new(Owner {
field: field.clone(),
submits: submits.clone(),
editor_calls: editor_calls.clone(),
editor_outcome,
}),
);
StandaloneFocusedOwnerFixture {
model,
field,
submits,
editor_calls,
}
}
#[tokio::test]
async fn standalone_completion_scope_uses_launch_workspace_without_session() {
let mut fixture = standalone_focused_owner_fixture();
let launch = std::env::current_dir().unwrap();
fixture.model.settings =
super::super::chat_loop::TuiRuntimeSettings::bootstrap(launch.clone(), &[]);
fixture
.model
.chat
.app
.set_plugin_presentation(std::sync::Arc::new(
super::super::plugin_tui::PluginTuiPresentation::new(
bcode_plugin::PluginHost::default(),
),
));
let scope = fixture.model.composer_completion_scope(true).unwrap();
assert_eq!(scope.session, None);
assert_eq!(scope.workspace, Some(launch));
fixture.model.loop_state.close_root_plugin_surface();
assert!(fixture.model.composer_completion_scope(true).is_none());
drop(fixture);
}
#[tokio::test]
async fn standalone_completion_escape_dismisses_before_parent_navigation() {
let mut fixture = standalone_focused_owner_fixture();
fixture.model.focused_input_owner = Some(super::FocusedInputOwner::RootSurface);
fixture
.model
.loop_state
.composer_interaction
.observe_focused_field(
super::super::composer_interaction::ComposerScope {
session: fixture.model.chat.attached_session_id(),
agent_id: None,
workspace: None,
registry_identity: 1,
registry_revision: 0,
},
fixture.field.lock().unwrap().clone(),
);
let request = super::super::composer_interaction::tests::begin_observed(
&mut fixture.model.loop_state.composer_interaction,
);
fixture.model.loop_state.composer_interaction.complete(
super::super::composer_interaction::tests::completion(&request),
);
let escape =
bmux_tui::event::Event::Key(bmux_keyboard::parse_key_stroke("escape").unwrap());
assert_ne!(
fixture.model.compose_focused_input_event(&escape),
bmux_tui::event::EventOutcome::Ignored,
);
assert!(!fixture.model.loop_state.composer_interaction.visible());
assert!(fixture.model.loop_state.has_root_plugin_surface());
assert_eq!(
fixture
.editor_calls
.load(std::sync::atomic::Ordering::SeqCst),
0
);
assert_eq!(fixture.submits.load(std::sync::atomic::Ordering::SeqCst), 0);
assert_eq!(
fixture.model.compose_focused_input_event(&escape),
bmux_tui::event::EventOutcome::Ignored,
);
}
#[tokio::test]
async fn standalone_focused_completion_routes_before_owner_submit() {
let StandaloneFocusedOwnerFixture {
mut model,
field,
submits,
..
} = standalone_focused_owner_fixture();
model.focused_input_owner = Some(super::FocusedInputOwner::RootSurface);
let observed = field.lock().unwrap().clone();
model.loop_state.composer_interaction.observe_focused_field(
super::super::composer_interaction::ComposerScope {
session: model.chat.attached_session_id(),
agent_id: None,
workspace: None,
registry_identity: 1,
registry_revision: 0,
},
observed,
);
let request = super::super::composer_interaction::tests::begin_observed(
&mut model.loop_state.composer_interaction,
);
model.loop_state.composer_interaction.complete(
super::super::composer_interaction::tests::completion(&request),
);
composer_root_key(&mut model, "enter");
assert_eq!(field.lock().unwrap().document.text, "主题 👩💻");
drop(field);
assert_eq!(submits.load(std::sync::atomic::Ordering::SeqCst), 0);
assert!(model.chat.app.take_pending_submission().is_empty());
assert!(!model.loop_state.composer_interaction.visible());
composer_root_key(&mut model, "enter");
drop(model);
assert_eq!(submits.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn ordinary_focused_input_is_delivered_once_and_only_ignored_reaches_surface() {
use bmux_tui::event::{Event, EventOutcome};
use bmux_tui_runtime::Program;
for outcome in [
EventOutcome::Ignored,
EventOutcome::Handled,
EventOutcome::Redraw,
] {
let mut fixture = standalone_focused_owner_fixture_with_outcome(outcome);
fixture
.model
.update(bmux_tui_runtime::RuntimeEvent::Terminal(Event::Key(
bmux_keyboard::parse_key_stroke("enter").unwrap(),
)))
.unwrap();
assert_eq!(
fixture
.editor_calls
.load(std::sync::atomic::Ordering::SeqCst),
1
);
assert_eq!(
fixture.submits.load(std::sync::atomic::Ordering::SeqCst),
usize::from(outcome == EventOutcome::Ignored)
);
assert!(fixture.model.chat.app.take_pending_submission().is_empty());
}
}
fn begin_root_composer(
model: &mut super::BcodeRuntimeModel,
) -> bcode_plugin_sdk::composer::ComposerSearchRequest {
model.focused_input_owner = Some(super::FocusedInputOwner::Composer);
model
.composer_field
.observe(model.chat.app.composer(), true);
let area = bmux_tui::geometry::Rect::new(0, 16, 90, 2);
model
.composer_field
.painted(model.chat.app.composer(), area, area);
model.loop_state.composer_interaction.observe_focused_field(
super::super::composer_interaction::tests::scope(),
model
.composer_field
.snapshot(model.chat.app.composer())
.unwrap(),
);
super::super::composer_interaction::tests::begin_observed(
&mut model.loop_state.composer_interaction,
)
}
fn composer_root_fixture(config: &bcode_config::TuiConfig) -> super::BcodeRuntimeModel {
let mut model = root_test_model_with_tui_config(config);
model.chat.app.replace_composer_with("#topic");
let request = begin_root_composer(&mut model);
let mut reply = super::super::composer_interaction::tests::completion(&request);
let candidates = &mut reply.snapshot.as_mut().unwrap().candidates;
let mut second = candidates[0].clone();
second.id = "second".into();
second.label = "Second topic".into();
candidates.push(second);
model.loop_state.composer_interaction.complete(reply);
paint_composer_root_fixture(&mut model, 90, 18);
model
}
fn paint_composer_root_fixture(model: &mut super::BcodeRuntimeModel, width: u16, height: u16) {
let area = bmux_tui::geometry::Rect::new(0, 0, width, height);
let mut buffer = bmux_tui::buffer::Buffer::empty(area);
let mut frame = bmux_tui::frame::Frame::new(&mut buffer);
super::super::composer_interaction_render::paint(
&mut model.loop_state.composer_interaction,
bmux_tui::geometry::Rect::new(0, height.saturating_sub(2), width, 2),
&mut bmux_tui::paint::PaintCx::new(&mut frame),
super::super::render::TuiTheme::for_theme_id("terminal-native"),
);
}
fn composer_root_key(model: &mut super::BcodeRuntimeModel, key: &str) {
let event = bmux_tui::event::Event::Key(bmux_keyboard::parse_key_stroke(key).unwrap());
if model.compose_focused_input_event(&event) == bmux_tui::event::EventOutcome::Ignored {
model.handle_basic_terminal_event(event);
}
}
#[tokio::test]
async fn composer_root_live_actions_use_effective_keys_and_replay_during_loading() {
for (overrides, active_key, hint) in [
(
std::collections::BTreeMap::new(),
Some("ctrl+r"),
Some("ctrl+r"),
),
(
std::collections::BTreeMap::from([
("ctrl+r".into(), String::new()),
("ctrl+x".into(), "example.refresh".into()),
]),
Some("ctrl+x"),
Some("ctrl+x"),
),
(
std::collections::BTreeMap::from([("ctrl+r".into(), String::new())]),
None,
None,
),
] {
exercise_live_composer_action(overrides, active_key, hint);
}
}
fn live_composer_action_fixture(
overrides: std::collections::BTreeMap<String, String>,
) -> (
super::BcodeRuntimeModel,
super::super::composer_interaction::ComposerCompletion,
) {
use bcode_plugin_sdk::composer::{ComposerAction, ComposerPresentation};
use bcode_plugin_sdk::tui::keymap::KeymapAction;
let mut config = bcode_config::TuiConfig::default();
config
.keybindings
.scopes
.insert("composer_interaction".into(), overrides);
let mut model = composer_root_fixture(&config);
model
.settings
.integrate_keymap_contributors(Ok(vec![KeymapAction {
id: "example.refresh".into(),
scope: "composer_interaction".into(),
description: "Refresh".into(),
defaults: vec!["ctrl+r".into()],
}]));
let request = super::super::composer_interaction::tests::begin_observed(
&mut model.loop_state.composer_interaction,
);
let mut reply = super::super::composer_interaction::tests::completion(&request);
let snapshot = reply.snapshot.as_mut().unwrap();
snapshot
.interaction_state
.insert("mode".into(), "first".into());
snapshot.presentation = Some(ComposerPresentation {
version: 1,
title: "Topics".into(),
hint: None,
actions: vec![ComposerAction {
id: "refresh".into(),
label: "Refresh".into(),
binding: Some("example.refresh".into()),
}],
});
assert!(
model
.loop_state
.composer_interaction
.complete(reply.clone())
);
model
.loop_state
.composer_interaction
.resolve_hints(model.settings.keymap().registry());
(model, reply)
}
fn exercise_live_composer_action(
overrides: std::collections::BTreeMap<String, String>,
active_key: Option<&str>,
hint: Option<&str>,
) {
use bmux_tui::event::EventOutcome;
let (mut model, reply) = live_composer_action_fixture(overrides);
let before = model.chat.app.composer().edit_state();
let history = (
model.chat.app.composer().can_undo(),
model.chat.app.composer().can_redo(),
);
if let Some(hint) = hint {
assert!(model.loop_state.composer_interaction.hint().contains(hint));
} else {
assert!(model.loop_state.composer_interaction.hint().is_empty());
}
if active_key != Some("ctrl+r") {
assert_eq!(
model.composer_interaction_key_outcome(
bmux_keyboard::parse_key_stroke("ctrl+r").unwrap()
),
EventOutcome::Ignored
);
assert!(!model.loop_state.composer_interaction.needs_activation());
}
if let Some(key) = active_key {
assert_eq!(
model.compose_focused_input_event(&bmux_tui::event::Event::Key(
bmux_keyboard::parse_key_stroke(key).unwrap()
)),
EventOutcome::Handled
);
assert!(model.loop_state.composer_interaction.visible());
assert!(
model
.loop_state
.composer_interaction
.selected_edit()
.is_none()
);
assert!(
!model
.loop_state
.composer_interaction
.complete(reply.clone())
);
let next = super::super::composer_interaction::tests::begin_observed(
&mut model.loop_state.composer_interaction,
);
assert_eq!(next.action_id.as_deref(), Some("refresh"));
assert_eq!(
next.interaction_state.get("mode").map(String::as_str),
Some("first")
);
assert_eq!(next.document.text, before.text);
assert_ne!(next.request_id, reply.request_id);
assert_eq!(
model.composer_interaction_key_outcome(
bmux_keyboard::parse_key_stroke(key).unwrap()
),
EventOutcome::Handled
);
let last = super::super::composer_interaction::tests::begin_observed(
&mut model.loop_state.composer_interaction,
);
assert_eq!(last.action_id.as_deref(), Some("refresh"));
assert_eq!(last.document, next.document);
assert_ne!(last.request_id, next.request_id);
assert!(
!model
.loop_state
.composer_interaction
.complete(super::super::composer_interaction::tests::completion(&next))
);
assert_eq!(
model.composer_interaction_key_outcome(
bmux_keyboard::parse_key_stroke("enter").unwrap()
),
EventOutcome::Handled
);
assert!(model.loop_state.composer_interaction.hint().contains(key));
}
assert_eq!(model.chat.app.composer().edit_state(), before);
assert_eq!(
(
model.chat.app.composer().can_undo(),
model.chat.app.composer().can_redo()
),
history
);
assert!(model.chat.app.take_pending_submission().is_empty());
drop(model);
}
#[tokio::test]
async fn composer_root_default_keys_accept_navigate_dismiss_without_chat_fallthrough() {
for key in ["enter", "tab"] {
let mut model = composer_root_fixture(&bcode_config::TuiConfig::default());
composer_root_key(&mut model, key);
assert_eq!(model.chat.app.composer().text(), "主题 👩💻");
assert!(model.chat.app.take_pending_submission().is_empty());
assert!(!model.loop_state.composer_interaction.visible());
drop(model);
}
let mut model = composer_root_fixture(&bcode_config::TuiConfig::default());
composer_root_key(&mut model, "down");
assert_eq!(
model.loop_state.composer_interaction.list.focused(),
Some(1)
);
composer_root_key(&mut model, "up");
assert_eq!(
model.loop_state.composer_interaction.list.focused(),
Some(0)
);
assert_eq!(model.chat.app.composer().text(), "#topic");
assert!(!model.chat.app.input_history_navigation_active());
composer_root_key(&mut model, "escape");
assert!(!model.loop_state.composer_interaction.visible());
assert_eq!(model.chat.app.composer().text(), "#topic");
assert!(model.chat.app.take_pending_submission().is_empty());
drop(model);
}
#[tokio::test]
async fn composer_root_remapped_actions_and_literal_typing_use_effective_registry() {
let mut config = bcode_config::TuiConfig::default();
config.keybindings.scopes.insert(
"composer_interaction".into(),
std::collections::BTreeMap::from([
("ctrl+n".into(), "tui.control.down".into()),
("ctrl+y".into(), "tui.control.submit".into()),
("ctrl+x".into(), "tui.control.cancel".into()),
]),
);
let mut model = composer_root_fixture(&config);
composer_root_key(&mut model, "ctrl+n");
assert_eq!(
model.loop_state.composer_interaction.list.focused(),
Some(1)
);
composer_root_key(&mut model, "ctrl+y");
assert_eq!(model.chat.app.composer().text(), "主题 👩💻");
assert!(model.chat.app.take_pending_submission().is_empty());
drop(model);
let mut model = composer_root_fixture(&config);
composer_root_key(&mut model, "ctrl+x");
assert!(!model.loop_state.composer_interaction.visible());
drop(model);
let mut model = composer_root_fixture(&config);
composer_root_key(&mut model, "x");
assert_eq!(model.chat.app.composer().text(), "#topicx");
assert!(model.chat.app.take_pending_submission().is_empty());
drop(model);
}
#[tokio::test]
async fn composer_root_acceptance_is_one_undoable_transaction() {
for (undo, redo) in [
("ctrl+z", "ctrl+shift+z"),
("super+z", "super+shift+z"),
("ctrl+z", "ctrl+y"),
] {
let mut model = composer_root_fixture(&bcode_config::TuiConfig::default());
model.chat.app.composer_mut().clear_history();
let before = model.chat.app.composer().edit_state();
composer_root_key(&mut model, "enter");
let after = model.chat.app.composer().edit_state();
assert_eq!(after.text, "主题 👩💻");
assert_ne!(before, after);
composer_root_key(&mut model, undo);
assert_eq!(model.chat.app.composer().edit_state(), before);
assert!(!model.chat.app.composer().can_undo());
assert!(model.chat.app.composer().can_redo());
composer_root_key(&mut model, redo);
assert_eq!(model.chat.app.composer().edit_state(), after);
assert!(!model.chat.app.composer().can_redo());
assert!(model.chat.app.take_pending_submission().is_empty());
}
}
#[tokio::test]
async fn composer_root_history_remaps_and_independent_unbinds() {
let mut config = bcode_config::TuiConfig::default();
config
.keybindings
.chat
.insert("ctrl+r".into(), "tui.editor.undo".into());
config
.keybindings
.chat
.insert("ctrl+t".into(), "tui.editor.redo".into());
let mut model = composer_root_fixture(&config);
composer_root_key(&mut model, "enter");
let after = model.chat.app.composer().edit_state();
for key in ["ctrl+z", "super+z"] {
composer_root_key(&mut model, key);
assert_eq!(model.chat.app.composer().edit_state(), after);
}
composer_root_key(&mut model, "ctrl+r");
assert_eq!(model.chat.app.composer().text(), "#topic");
for key in ["ctrl+y", "ctrl+shift+z", "super+shift+z"] {
composer_root_key(&mut model, key);
assert_eq!(model.chat.app.composer().text(), "#topic");
}
composer_root_key(&mut model, "ctrl+t");
assert_eq!(model.chat.app.composer().edit_state(), after);
for (disabled, enabled) in [("ctrl+z", "super+z"), ("super+z", "ctrl+z")] {
let mut config = bcode_config::TuiConfig::default();
config
.keybindings
.chat
.insert(disabled.into(), String::new());
let mut model = composer_root_fixture(&config);
composer_root_key(&mut model, "enter");
let after = model.chat.app.composer().edit_state();
composer_root_key(&mut model, disabled);
assert_eq!(model.chat.app.composer().edit_state(), after);
composer_root_key(&mut model, enabled);
assert_eq!(model.chat.app.composer().text(), "#topic");
}
for disabled in ["ctrl+y", "ctrl+shift+z", "super+shift+z"] {
let mut config = bcode_config::TuiConfig::default();
config
.keybindings
.chat
.insert(disabled.into(), String::new());
let mut model = composer_root_fixture(&config);
composer_root_key(&mut model, "enter");
let after = model.chat.app.composer().edit_state();
composer_root_key(&mut model, "ctrl+z");
composer_root_key(&mut model, disabled);
assert_eq!(model.chat.app.composer().text(), "#topic");
let enabled = if disabled == "ctrl+y" {
"ctrl+shift+z"
} else {
"ctrl+y"
};
composer_root_key(&mut model, enabled);
assert_eq!(model.chat.app.composer().edit_state(), after);
}
}
#[tokio::test]
async fn composer_root_empty_history_commands_are_consumed_without_edits() {
let mut model = root_test_model();
model.chat.app.replace_composer_with("主题 👩💻");
model.chat.app.composer_mut().clear_history();
model.chat.app.composer_mut().select_all();
let before = model.chat.app.composer().edit_state();
for key in [
"ctrl+z",
"super+z",
"ctrl+y",
"ctrl+shift+z",
"super+shift+z",
] {
composer_root_key(&mut model, key);
assert_eq!(model.chat.app.composer().edit_state(), before);
assert!(!model.chat.app.composer().can_undo());
assert!(!model.chat.app.composer().can_redo());
assert!(model.chat.app.take_pending_submission().is_empty());
}
}
#[tokio::test]
async fn composer_root_history_restores_selected_text_and_cursor() {
let mut model = root_test_model();
model.chat.app.replace_composer_with("主题 👩💻");
model.chat.app.composer_mut().clear_history();
model.chat.app.composer_mut().select_all();
let before = model.chat.app.composer().edit_state();
composer_root_key(&mut model, "x");
let after = model.chat.app.composer().edit_state();
assert_eq!(after.text, "x");
composer_root_key(&mut model, "ctrl+z");
assert_eq!(model.chat.app.composer().edit_state(), before);
composer_root_key(&mut model, "ctrl+y");
assert_eq!(model.chat.app.composer().edit_state(), after);
}
#[tokio::test]
async fn composer_root_noop_acceptance_dismisses_without_consuming_redo() {
let mut model = composer_root_fixture(&bcode_config::TuiConfig::default());
model.chat.app.composer_mut().clear_history();
model.chat.app.composer_mut().insert_str("!");
let future = model.chat.app.composer().edit_state();
assert!(model.chat.app.composer_mut().undo());
let before = model.chat.app.composer().edit_state();
let request = begin_root_composer(&mut model);
let mut reply = super::super::composer_interaction::tests::completion(&request);
let edit = &mut reply.snapshot.as_mut().unwrap().candidates[0].edit;
edit.replacement = request.document.text.clone();
edit.cursor = request.document.cursor;
assert!(model.loop_state.composer_interaction.complete(reply));
paint_composer_root_fixture(&mut model, 90, 18);
composer_root_key(&mut model, "enter");
assert_eq!(model.chat.app.composer().edit_state(), before);
assert!(!model.loop_state.composer_interaction.visible());
assert!(model.chat.app.take_pending_submission().is_empty());
assert!(!model.chat.app.composer().can_undo());
assert!(model.chat.app.composer().can_redo());
composer_root_key(&mut model, "ctrl+y");
let restored = model.chat.app.composer().edit_state();
drop(model);
assert_eq!(restored, future);
}
#[tokio::test]
async fn composer_root_rejected_acceptance_consumes_enter_and_tab() {
for key in ["enter", "tab"] {
let mut model = composer_root_fixture(&bcode_config::TuiConfig::default());
model.chat.app.composer_mut().clear_history();
let original = model.chat.app.composer().edit_state();
model.chat.app.composer_mut().insert_str("!");
let changed = model.chat.app.composer().edit_state();
assert!(model.chat.app.composer_mut().undo());
assert_eq!(model.chat.app.composer().edit_state(), original);
model
.chat
.app
.composer_mut()
.move_cursor(bmux_text_edit::TextMotion::Start);
let before = model.chat.app.composer().edit_state();
composer_root_key(&mut model, key);
assert_eq!(model.chat.app.composer().edit_state(), before);
assert!(!model.chat.app.composer().can_undo());
assert!(model.chat.app.composer().can_redo());
assert!(model.chat.app.composer_mut().redo());
assert_eq!(model.chat.app.composer().edit_state(), changed);
assert!(model.chat.app.take_pending_submission().is_empty());
assert!(model.chat.app.status().contains("draft unchanged"));
assert!(!model.loop_state.composer_interaction.visible());
drop(model);
}
}
#[tokio::test]
async fn composer_root_clipped_clicks_and_empty_resize_cannot_accept() {
use bmux_tui::event::{Event, MouseButton, MouseEvent, MouseEventKind};
use bmux_tui::geometry::Point;
let mut model = composer_root_fixture(&bcode_config::TuiConfig::default());
let list = model.loop_state.composer_interaction.painted_list.unwrap();
for point in [
Point::new(list.x.saturating_sub(1), list.y),
Point::new(list.x, list.bottom()),
] {
for kind in [
MouseEventKind::Down(MouseButton::Left),
MouseEventKind::Up(MouseButton::Left),
] {
model.compose_focused_input_event(&Event::Mouse(MouseEvent::new(kind, point)));
}
assert_eq!(model.chat.app.composer().text(), "#topic");
}
paint_composer_root_fixture(&mut model, 0, 0);
for kind in [
MouseEventKind::Down(MouseButton::Left),
MouseEventKind::Up(MouseButton::Left),
] {
model.compose_focused_input_event(&Event::Mouse(MouseEvent::new(
kind,
Point::new(list.x, list.y),
)));
}
assert_eq!(model.chat.app.composer().text(), "#topic");
assert!(model.chat.app.take_pending_submission().is_empty());
drop(model);
}
#[tokio::test]
async fn composer_root_visible_row_click_accepts_only_measured_item() {
use bmux_tui::event::{Event, MouseButton, MouseEvent, MouseEventKind};
let mut model = composer_root_fixture(&bcode_config::TuiConfig::default());
let list = model.loop_state.composer_interaction.painted_list.unwrap();
assert_eq!(model.loop_state.composer_interaction.list.selected(), None);
assert_eq!(
model.loop_state.composer_interaction.list.focused(),
Some(0)
);
let point = bmux_tui::geometry::Point::new(list.x, list.y);
for kind in [
MouseEventKind::Down(MouseButton::Left),
MouseEventKind::Up(MouseButton::Left),
] {
model.compose_focused_input_event(&Event::Mouse(MouseEvent::new(kind, point)));
}
assert_eq!(model.chat.app.composer().text(), "主题 👩💻");
assert!(model.chat.app.take_pending_submission().is_empty());
drop(model);
}
#[tokio::test]
async fn composer_root_mouse_click_accepts_a_navigated_candidate_after_redraw() {
use bmux_tui::event::{Event, MouseButton, MouseEvent, MouseEventKind};
let mut model = composer_root_fixture(&bcode_config::TuiConfig::default());
composer_root_key(&mut model, "down");
assert_eq!(
model.loop_state.composer_interaction.list.focused(),
Some(1)
);
assert_eq!(model.loop_state.composer_interaction.list.selected(), None);
paint_composer_root_fixture(&mut model, 90, 18);
let list = model.loop_state.composer_interaction.painted_list.unwrap();
let point = bmux_tui::geometry::Point::new(list.x, list.y.saturating_add(1));
model.compose_focused_input_event(&Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
point,
)));
paint_composer_root_fixture(&mut model, 90, 18);
model.compose_focused_input_event(&Event::Mouse(MouseEvent::new(
MouseEventKind::Up(MouseButton::Left),
point,
)));
assert_eq!(model.chat.app.composer().text(), "主题 👩💻");
assert!(model.chat.app.take_pending_submission().is_empty());
drop(model);
}
#[tokio::test]
async fn composer_root_normal_typing_is_observed_without_an_interactive_surface() {
use bmux_tui_runtime::Program;
let mut model = root_test_model();
model.settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::env::current_dir().unwrap(),
&[],
);
model.chat.app.set_plugin_presentation(std::sync::Arc::new(
super::super::plugin_tui::PluginTuiPresentation::new(
bcode_plugin::PluginHost::default(),
),
));
let (runtime, handle) = bmux_tui_runtime::Runtime::new(
root_test_model(),
bmux_tui_runtime::HeadlessPresenter::default(),
bmux_tui_runtime::RuntimeConfig::default(),
);
model.runtime_handle = Some(handle);
assert!(!model.loop_state.has_interactive_surface());
for key in ["@", "r"] {
model
.update(bmux_tui_runtime::RuntimeEvent::Terminal(
bmux_tui::event::Event::Key(bmux_keyboard::parse_key_stroke(key).unwrap()),
))
.unwrap();
}
let observed = model
.loop_state
.composer_interaction
.document()
.expect("normal typing reaches contributor observation")
.text
.clone();
drop(model);
drop(runtime);
assert_eq!(observed, "@r");
}
#[tokio::test]
async fn composer_root_retains_popup_until_edited_owner_has_painted() {
let mut model = root_test_model();
model.settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::env::current_dir().unwrap(),
&[],
);
model.chat.app.set_plugin_presentation(std::sync::Arc::new(
super::super::plugin_tui::PluginTuiPresentation::new(
bcode_plugin::PluginHost::default(),
),
));
let (runtime, handle) = bmux_tui_runtime::Runtime::new(
root_test_model(),
bmux_tui_runtime::HeadlessPresenter::default(),
bmux_tui_runtime::RuntimeConfig::default(),
);
model.runtime_handle = Some(handle);
model.synchronize_composer_interaction();
model.chat.app.replace_composer_with("#topic");
let scope = model.composer_completion_scope(false).unwrap();
model
.loop_state
.composer_interaction
.observe(scope, model.chat.app.composer());
let request = super::super::composer_interaction::tests::begin_observed(
&mut model.loop_state.composer_interaction,
);
assert!(model.loop_state.composer_interaction.complete(
super::super::composer_interaction::tests::completion(&request),
));
paint_composer_root_fixture(&mut model, 90, 18);
let geometry = model.loop_state.composer_interaction.painted_list;
model.chat.app.replace_composer_with("#topics");
model.synchronize_composer_interaction();
assert!(model.loop_state.composer_interaction.visible());
assert!(
model
.loop_state
.composer_interaction
.selected_edit()
.is_none()
);
assert!(!model.loop_state.composer_interaction.complete(
super::super::composer_interaction::tests::completion(&request),
));
paint_composer_root_fixture(&mut model, 90, 18);
assert_eq!(model.loop_state.composer_interaction.painted_list, geometry);
model.synchronize_composer_interaction_with_paint(true);
assert!(!model.loop_state.composer_interaction.visible());
drop(model);
drop(runtime);
}
#[tokio::test]
async fn composer_root_pending_reply_is_fenced_when_plugin_backend_is_disabled() {
let mut model = root_test_model();
model.chat.app.replace_composer_with("#topic");
let request = begin_root_composer(&mut model);
assert!(model.chat.app.plugin_presentation().is_none());
model.synchronize_composer_interaction();
assert!(!model.loop_state.composer_interaction.complete(
super::super::composer_interaction::tests::completion(&request)
));
assert_eq!(model.chat.app.composer().text(), "#topic");
assert!(!model.loop_state.composer_interaction.visible());
drop(model);
}
#[tokio::test]
async fn composer_root_modal_foreground_has_priority_over_acceptance() {
let mut model = composer_root_fixture(&bcode_config::TuiConfig::default());
model.loop_state.open_command_palette(&mut model.chat);
assert_eq!(
model.compose_focused_input_event(&bmux_tui::event::Event::Key(
bmux_keyboard::parse_key_stroke("enter").unwrap(),
)),
bmux_tui::event::EventOutcome::Ignored,
);
assert_eq!(model.chat.app.composer().text(), "#topic");
assert!(model.chat.app.take_pending_submission().is_empty());
drop(model);
}
fn root_test_model() -> super::BcodeRuntimeModel {
root_test_model_with_tui_config(&bcode_config::TuiConfig::default())
}
fn root_test_model_with_tui_config(
tui_config: &bcode_config::TuiConfig,
) -> super::BcodeRuntimeModel {
let chat = root_test_chat();
let mut settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
settings.apply_tui_config(tui_config);
let mut actions = bcode_question_plugin::question_tui_registry()
.keymap_actions()
.to_vec();
actions.extend_from_slice(bcode_shell_plugin::shell_tui_registry().keymap_actions());
settings.integrate_keymap_contributors(Ok(actions));
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let loop_state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
super::BcodeRuntimeModel::new(chat, settings, loop_state)
}
#[tokio::test]
async fn markdown_click_is_released_to_activation_while_drag_is_consumed() {
let mut model = root_test_model();
let layout = super::super::render::prepare_frame(
&mut model.chat.app,
bmux_tui::geometry::Rect::new(0, 0, 20, 8),
)
.expect("layout");
let body = layout.body();
model.committed_layout = Some(layout);
model
.committed_selection
.push_scope(bmux_tui::selection::SelectionScope::new(
"bcode.transcript",
body,
));
for fragment in bmux_tui::selection::plain_text_fragments(
"bcode.transcript",
"link.label",
bmux_tui::geometry::Rect::new(body.x, body.y, 8, 1),
0,
"linktext",
0,
1,
) {
model.committed_selection.push_fragment(fragment);
}
assert_eq!(
model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(body.x, body.y),
)),
Some(super::super::invalidation::UiInvalidation::None)
);
assert_eq!(
model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Up(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(body.x, body.y),
)),
None
);
let _ = model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(body.x, body.y),
));
assert!(
model
.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(body.x.saturating_add(4), body.y),
))
.is_some()
);
assert!(
model
.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Up(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(body.x.saturating_add(4), body.y),
))
.is_some()
);
drop(model);
}
#[tokio::test]
async fn transcript_selection_arbitrates_drag_before_other_mouse_actions() {
let mut model = root_test_model();
assert!(model.settings.selection_autoscroll());
let layout = super::super::render::prepare_frame(
&mut model.chat.app,
bmux_tui::geometry::Rect::new(0, 0, 20, 8),
)
.expect("layout");
let body = layout.body();
model.committed_layout = Some(layout);
model
.committed_selection
.push_scope(bmux_tui::selection::SelectionScope::new(
"bcode.transcript",
body,
));
model.committed_selection.push_scope(
bmux_tui::selection::SelectionScope::new("item", body).parent("bcode.transcript"),
);
for fragment in bmux_tui::selection::plain_text_fragments(
"item",
"item.plain",
bmux_tui::geometry::Rect::new(0, 1, 6, 1),
0,
"abcdef",
0,
1,
) {
model.committed_selection.push_fragment(fragment);
}
assert_eq!(
model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(1, 1),
)),
Some(super::super::invalidation::UiInvalidation::None)
);
assert_eq!(
model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(5, 1),
)),
Some(super::super::invalidation::UiInvalidation::Structural)
);
assert_eq!(
model.transcript_selection.phase(),
bmux_tui::selection::SelectionGesturePhase::Dragging
);
drop(model);
}
#[tokio::test]
async fn disabled_selection_autoscroll_keeps_viewport_stable() {
let mut tui_config = bcode_config::TuiConfig::default();
tui_config.mouse.selection_autoscroll = false;
let mut model = root_test_model_with_tui_config(&tui_config);
let layout = super::super::render::prepare_frame(
&mut model.chat.app,
bmux_tui::geometry::Rect::new(0, 0, 20, 8),
)
.expect("layout");
let body = layout.body();
model.committed_layout = Some(layout);
model
.committed_selection
.push_scope(bmux_tui::selection::SelectionScope::new(
"bcode.transcript",
body,
));
model.committed_selection.push_scope(
bmux_tui::selection::SelectionScope::new("item", body).parent("bcode.transcript"),
);
for fragment in bmux_tui::selection::plain_text_fragments(
"item",
"item.plain",
bmux_tui::geometry::Rect::new(body.x, body.y, body.width, 1),
0,
"selection",
0,
1,
) {
model.committed_selection.push_fragment(fragment);
}
let _ = model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(body.x, body.y),
));
assert_eq!(
model.handle_transcript_selection_mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(body.x.saturating_add(4), body.y),
)),
Some(super::super::invalidation::UiInvalidation::Paint)
);
drop(model);
}
#[test]
fn response_local_selection_clamps_before_sibling_transcript_items() {
let mut scene = bmux_tui::selection::SelectionScene::new();
let body = bmux_tui::geometry::Rect::new(0, 0, 12, 3);
scene.push_scope(bmux_tui::selection::SelectionScope::new(
"bcode.transcript",
body,
));
for (scope, content, y, order, text) in [
("item.first", "item.first.plain", 0, 0, "first"),
("item.second", "item.second.plain", 2, 1, "second"),
] {
scene.push_scope(
bmux_tui::selection::SelectionScope::new(
scope,
bmux_tui::geometry::Rect::new(0, y, 12, 1),
)
.parent("bcode.transcript")
.order(order),
);
for fragment in bmux_tui::selection::plain_text_fragments(
scope,
content,
bmux_tui::geometry::Rect::new(0, y, 12, 1),
order,
text,
0,
0,
) {
scene.push_fragment(fragment);
}
}
let mut controller = bmux_tui::selection::SelectionController::new();
assert_eq!(
controller.handle_mouse(
&scene,
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(1, 0),
),
),
bmux_tui::selection::SelectionOutcome::Armed
);
let _ = controller.handle_mouse(
&scene,
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(5, 2),
),
);
let snapshot = controller.snapshot(&scene).expect("selection snapshot");
assert_eq!(snapshot.scope_id.as_str(), "item.first");
assert!(
snapshot
.slices
.iter()
.all(|slice| slice.content_id.as_str() == "item.first.plain")
);
}
#[test]
fn transcript_chrome_selection_spans_items_in_document_order() {
let mut scene = bmux_tui::selection::SelectionScene::new();
let body = bmux_tui::geometry::Rect::new(0, 0, 12, 3);
scene.push_scope(bmux_tui::selection::SelectionScope::new(
"bcode.transcript",
body,
));
for (scope, content, y, order, text) in [
("item.first", "item.first.plain", 0, 0, "first"),
("item.second", "item.second.plain", 2, 1, "second"),
] {
scene.push_scope(
bmux_tui::selection::SelectionScope::new(
scope,
bmux_tui::geometry::Rect::new(0, y, 12, 1),
)
.parent("bcode.transcript")
.order(order),
);
for fragment in bmux_tui::selection::plain_text_fragments(
scope,
content,
bmux_tui::geometry::Rect::new(0, y, 12, 1),
order,
text,
0,
0,
) {
scene.push_fragment(fragment);
}
}
let mut controller = bmux_tui::selection::SelectionController::new();
let _ = controller.handle_mouse(
&scene,
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(0, 1),
),
);
let _ = controller.handle_mouse(
&scene,
bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(5, 2),
),
);
let snapshot = controller.snapshot(&scene).expect("selection snapshot");
assert_eq!(snapshot.scope_id.as_str(), "bcode.transcript");
assert_eq!(snapshot.slices.len(), 2);
assert_eq!(snapshot.slices[0].content_id.as_str(), "item.first.plain");
assert_eq!(snapshot.slices[1].content_id.as_str(), "item.second.plain");
}
#[tokio::test]
async fn transcript_selection_scene_is_registered_before_highlight_projection() {
let mut model = root_test_model();
let layout = super::super::render::prepare_frame(
&mut model.chat.app,
bmux_tui::geometry::Rect::new(0, 0, 20, 8),
)
.expect("layout");
let scene = super::transcript_selection_scene(&model.chat.app, layout.body());
assert!(
scene
.scopes()
.iter()
.any(|scope| scope.id.as_str() == "bcode.transcript")
);
assert!(scene.validate().is_ok());
}
#[test]
fn transcript_selection_edge_scroll_intensity_is_bounded() {
let body = bmux_tui::geometry::Rect::new(2, 3, 20, 4);
assert_eq!(
super::selection_edge_scroll_rows(body, bmux_tui::geometry::Point::new(3, body.y),),
Some((true, 1))
);
assert_eq!(
super::selection_edge_scroll_rows(body, bmux_tui::geometry::Point::new(3, 0)),
Some((true, 4))
);
assert_eq!(
super::selection_edge_scroll_rows(
body,
bmux_tui::geometry::Point::new(3, body.bottom()),
),
Some((false, 2))
);
assert_eq!(
super::selection_edge_scroll_rows(
body,
bmux_tui::geometry::Point::new(3, body.y.saturating_add(1)),
),
None
);
}
#[test]
fn plain_transcript_selection_exports_canonical_item_order() {
let first = super::super::transcript::TranscriptItem::new("You", "first".to_owned());
let second =
super::super::transcript::TranscriptItem::new("Assistant", "second".to_owned());
let first_id = first.id().get();
let second_id = second.id().get();
let items = [first, second];
let snapshot = bmux_tui::selection::SelectionSnapshot {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
anchor: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(format!(
"bcode.transcript.item.{second_id}.plain"
)),
offset: 6,
order: 1,
affinity: bmux_tui::selection::SelectionAffinity::After,
revision: 0,
},
focus: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(format!(
"bcode.transcript.item.{first_id}.plain"
)),
offset: 0,
order: 0,
affinity: bmux_tui::selection::SelectionAffinity::Before,
revision: 0,
},
reversed: true,
slices: vec![
bmux_tui::selection::SelectionSlice {
content_id: bmux_tui::selection::SelectionContentId::new(format!(
"bcode.transcript.item.{second_id}.plain"
)),
source_range: 0..6,
revision: 0,
},
bmux_tui::selection::SelectionSlice {
content_id: bmux_tui::selection::SelectionContentId::new(format!(
"bcode.transcript.item.{first_id}.plain"
)),
source_range: 0..5,
revision: 0,
},
],
visible_highlights: Vec::new(),
};
assert_eq!(
super::export_plain_transcript_selection(items.iter(), &snapshot).as_deref(),
Some("first\n\nsecond")
);
}
#[test]
fn mixed_canonical_and_ephemeral_selection_exports_visible_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("local issue".to_owned());
app.absorb_session_event(&event(2, "second"));
let slices = app
.transcript()
.iter()
.map(|item| bmux_tui::selection::SelectionSlice {
content_id: bmux_tui::selection::SelectionContentId::new(format!(
"bcode.transcript.item.{}.plain",
item.id().get()
)),
source_range: 0..item.text().len(),
revision: item.revision(),
})
.collect::<Vec<_>>();
let first = &slices[0];
let last = &slices[2];
let snapshot = bmux_tui::selection::SelectionSnapshot {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
anchor: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: first.content_id.clone(),
offset: 0,
order: 0,
affinity: bmux_tui::selection::SelectionAffinity::Before,
revision: first.revision,
},
focus: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: last.content_id.clone(),
offset: last.source_range.end,
order: 2,
affinity: bmux_tui::selection::SelectionAffinity::After,
revision: last.revision,
},
reversed: false,
slices,
visible_highlights: Vec::new(),
};
assert_eq!(
super::export_plain_transcript_selection(app.transcript(), &snapshot).as_deref(),
Some("first\n\nlocal issue\n\nsecond")
);
drop(app);
}
#[test]
fn pending_tool_draft_export_is_explicit_and_uses_bounded_fallback() {
let draft = bcode_session_view_models::ToolRequestDraftView {
output_location: None,
turn_id: "turn-1".to_owned(),
tool_call_id: "call-1".to_owned(),
tool_name: "shell".to_owned(),
producer_plugin_id: Some("shell-plugin".to_owned()),
schema: "shell.request".to_owned(),
schema_version: 1,
placement: bcode_session_models::ToolContributionPlacement::Request,
generation: 1,
revision: 1,
argument_bytes: 100,
preview_start_offset: 0,
preview: "secret preview".to_owned(),
truncated: true,
};
let item = super::super::transcript::TranscriptItem::with_kind(
"Tool",
"shell request · 100 bytes · truncated".to_owned(),
true,
super::super::transcript::TranscriptItemKind::ToolRequestDraft {
draft: Box::new(draft),
},
);
let item_id = item.id().get();
let content_id = format!("bcode.transcript.item.{item_id}.plain");
let snapshot = bmux_tui::selection::SelectionSnapshot {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
anchor: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(content_id.clone()),
offset: 0,
order: 0,
affinity: bmux_tui::selection::SelectionAffinity::Before,
revision: 0,
},
focus: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(content_id.clone()),
offset: item.text.len(),
order: 0,
affinity: bmux_tui::selection::SelectionAffinity::After,
revision: 0,
},
reversed: false,
slices: vec![bmux_tui::selection::SelectionSlice {
content_id: bmux_tui::selection::SelectionContentId::new(content_id),
source_range: 0..item.text.len(),
revision: 0,
}],
visible_highlights: Vec::new(),
};
let exported = super::export_plain_transcript_selection(std::iter::once(&item), &snapshot)
.expect("draft export");
assert!(exported.starts_with("Tool request draft [pending; rendered-text fallback]\n"));
assert!(exported.contains("100 bytes · truncated"));
assert!(!exported.contains("secret preview"));
}
#[test]
fn selected_tool_items_export_canonical_context_and_status() {
let request = super::super::transcript::tool_request_item(
"call-1",
Some("filesystem"),
"read_file",
r#"{"path":"src/lib.rs"}"#,
None,
)
.with_event_metadata(1, 123);
let result = super::super::transcript::tool_result_item(
"call-1",
Some("read_file"),
Some(r#"{"path":"src/lib.rs"}"#),
"contents",
false,
)
.with_event_metadata(2, 456);
let request_id = request.id().get();
let result_id = result.id().get();
let snapshot = bmux_tui::selection::SelectionSnapshot {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
anchor: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(format!(
"bcode.transcript.item.{request_id}.plain"
)),
offset: 0,
order: 0,
affinity: bmux_tui::selection::SelectionAffinity::Before,
revision: 0,
},
focus: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(format!(
"bcode.transcript.item.{result_id}.plain"
)),
offset: result.text.len(),
order: 1,
affinity: bmux_tui::selection::SelectionAffinity::After,
revision: 0,
},
reversed: false,
slices: vec![
bmux_tui::selection::SelectionSlice {
content_id: bmux_tui::selection::SelectionContentId::new(format!(
"bcode.transcript.item.{result_id}.plain"
)),
source_range: 0..result.text.len(),
revision: 0,
},
bmux_tui::selection::SelectionSlice {
content_id: bmux_tui::selection::SelectionContentId::new(format!(
"bcode.transcript.item.{request_id}.plain"
)),
source_range: 0..request.text.len(),
revision: 0,
},
],
visible_highlights: Vec::new(),
};
let exported = super::export_plain_transcript_selection([&request, &result], &snapshot)
.expect("tool export");
assert!(exported.starts_with("Tool request: read_file [requested] @ 123ms"));
assert!(exported.contains("src/lib.rs"));
assert!(exported.contains("Tool result: read_file [finished] @ 456ms\ncontents"));
}
#[test]
fn markdown_plain_ranges_export_exact_delimiters_and_link_destination() {
let source = "**strong** [label](https://example.com)";
let item = super::super::transcript::TranscriptItem::with_format(
"Assistant",
source.to_owned(),
bcode_session_view_models::TextFormat::Markdown,
);
let item_id = item.id().get();
let export = |range: std::ops::Range<usize>| {
let content_id = format!("bcode.transcript.item.{item_id}.markdown");
let snapshot = bmux_tui::selection::SelectionSnapshot {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
anchor: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(content_id.clone()),
offset: range.start,
order: 0,
affinity: bmux_tui::selection::SelectionAffinity::Before,
revision: 0,
},
focus: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(content_id.clone()),
offset: range.end,
order: 0,
affinity: bmux_tui::selection::SelectionAffinity::After,
revision: 0,
},
reversed: false,
slices: vec![bmux_tui::selection::SelectionSlice {
content_id: bmux_tui::selection::SelectionContentId::new(content_id),
source_range: range,
revision: 0,
}],
visible_highlights: Vec::new(),
};
super::export_plain_transcript_selection(std::iter::once(&item), &snapshot)
};
assert_eq!(export(0..10).as_deref(), Some("**strong**"));
assert_eq!(export(2..8).as_deref(), Some("strong"));
assert_eq!(export(12..17).as_deref(), Some("label"));
assert_eq!(export(19..38).as_deref(), Some("https://example.com"));
}
fn dispatch_selection_gesture(
model: &mut super::BcodeRuntimeModel,
start: bmux_tui::geometry::Point,
end: bmux_tui::geometry::Point,
) {
use bmux_tui::event::{Event, MouseButton, MouseEvent, MouseEventKind};
for (kind, point) in [
(MouseEventKind::Down(MouseButton::Left), start),
(MouseEventKind::Drag(MouseButton::Left), end),
(MouseEventKind::Up(MouseButton::Left), end),
] {
model.handle_basic_terminal_event(Event::Mouse(MouseEvent::new(kind, point)));
}
}
fn production_code_gesture(
source: &str,
width: u16,
selected: std::ops::Range<usize>,
reverse: bool,
) -> String {
use bmux_tui::{
event::{Event, MouseButton, MouseEvent, MouseEventKind},
geometry::{Point, Rect},
};
use bmux_tui_runtime::Presenter;
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: source.to_owned(),
},
}];
let mut model = root_test_model_with_history(session_id, &history);
let area = Rect::new(0, 0, width, 40);
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, area);
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("production presentation");
let fragments = model.committed_selection.fragments();
let first = fragments
.iter()
.find(|fragment| {
fragment.source_range.start == selected.start
&& (fragment.content_id.as_str().rsplit('.').next() == Some("body")
|| fragment.content_id.as_str().rsplit('.').next() == Some("header")
|| fragment.content_id.as_str().rsplit('.').next() == Some("footer"))
})
.expect("visible first source unit");
let last = fragments
.iter()
.find(|fragment| {
fragment.source_range.end == selected.end
&& (fragment.content_id.as_str().rsplit('.').next() == Some("body")
|| fragment.content_id.as_str().rsplit('.').next() == Some("footer")
|| fragment.content_id.as_str().rsplit('.').next() == Some("header"))
})
.expect("visible last source unit");
let body_only = first.content_id.as_str().rsplit('.').next() == Some("body")
&& last.content_id.as_str().rsplit('.').next() == Some("body");
let start = Point::new(first.area.x, first.area.y);
let end = Point::new(last.area.right().saturating_sub(1), last.area.y);
let reverse_start = fragments
.iter()
.find(|fragment| fragment.source_range.start == selected.end)
.map(|fragment| Point::new(fragment.area.x, fragment.area.y));
assert!(
fragments
.iter()
.any(|fragment| fragment.content_id.as_str().rsplit('.').next() == Some("header"))
);
assert!(
fragments
.iter()
.any(|fragment| fragment.content_id.as_str().rsplit('.').next() == Some("footer"))
);
assert!(
!fragments
.iter()
.any(|fragment| fragment.content_id.as_str().rsplit('.').next() == Some("whole"))
);
let (start, end) = if reverse {
(
reverse_start.expect("reverse gesture requires a visible exclusive end boundary"),
start,
)
} else {
(start, end)
};
for (kind, point) in [
(MouseEventKind::Down(MouseButton::Left), start),
(MouseEventKind::Drag(MouseButton::Left), end),
(MouseEventKind::Up(MouseButton::Left), end),
] {
model.handle_basic_terminal_event(Event::Mouse(MouseEvent::new(kind, point)));
}
let snapshot = model
.transcript_selection
.snapshot(&model.committed_selection)
.expect("complete-scene gesture");
assert!(
!body_only
|| snapshot.anchor.content_id != snapshot.focus.content_id
|| snapshot
.slices
.iter()
.all(|slice| slice.content_id.as_str().rsplit('.').next() == Some("body")),
"body gesture selected chrome: {snapshot:?}"
);
model.selected_plain_text.take().expect("production export")
}
#[tokio::test]
async fn production_previous_markdown_projection_is_not_selectable() {
use bmux_tui::geometry::{Point, Rect};
use bmux_tui_runtime::Presenter;
let session_id = bcode_session_models::SessionId::new();
let mut model = root_test_model_with_history(session_id, &[]);
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::AssistantDelta {
text: text.to_owned(),
},
};
model
.chat
.app
.absorb_session_event(&event(1, "```text\nold body\n"));
let area = Rect::new(0, 0, 70, 40);
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, area);
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("accepted streaming frame");
let old_fragments = model.committed_selection.fragments().to_vec();
let item = model
.chat
.app
.transcript()
.iter()
.next_back()
.expect("stream");
let id = item.id().get();
let width = model.committed_layout.expect("layout").body().width;
let previous = super::super::render::transcript_markdown_selection_projection(
&model.chat.app,
item,
width,
)
.expect("accepted projection");
let previous_revision = item.revision();
model.chat.app.absorb_session_event(&event(2, "new tail"));
let item = model
.chat
.app
.transcript()
.iter()
.next_back()
.expect("new stream revision");
assert_eq!(id, item.id().get());
let options = super::super::render::markdown_render_options(&model.chat.app, item, width);
model.chat.app.transcript_markdown_cache().install(
id,
previous_revision,
options,
std::sync::Arc::clone(&previous),
);
let frame_options =
super::super::render::markdown_render_options(&model.chat.app, item, area.width);
model.chat.app.transcript_markdown_cache().install(
id,
previous_revision,
frame_options,
previous,
);
model.presentation_damage = bmux_tui::damage::Damage::Full;
assert!(
super::super::render::transcript_markdown_selection_projection(
&model.chat.app,
item,
width
)
.is_none()
);
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("previous compatible frame");
assert!(
model
.committed_selection
.fragments()
.iter()
.all(|fragment| {
!fragment
.content_id
.as_str()
.starts_with(&format!("bcode.transcript.item.{id}."))
})
);
for fragment in old_fragments {
dispatch_selection_gesture(
&mut model,
Point::new(fragment.area.x, fragment.area.y),
Point::new(fragment.area.right(), fragment.area.y),
);
assert!(model.selected_plain_text.is_none());
}
drop(model);
}
#[tokio::test]
async fn production_clipped_code_unicode_fails_closed() {
use bmux_tui::geometry::{Point, Rect};
use bmux_tui_runtime::Presenter;
let mut model = root_test_model_with_history(bcode_session_models::SessionId::new(), &[]);
model
.chat
.app
.push_ephemeral_system_markdown("```\na界b\n```\n".to_owned());
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, Rect::new(0, 0, 7, 30));
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("narrow frame");
let fragments = model.committed_selection.fragments();
let first = fragments
.iter()
.find(|fragment| {
fragment.content_id.as_str().rsplit('.').next() == Some("body")
&& fragment.source_range == (4..5)
})
.cloned()
.expect("narrow first ASCII source unit");
let last = fragments
.iter()
.find(|fragment| {
fragment.content_id.as_str().rsplit('.').next() == Some("body")
&& fragment.source_range == (8..9)
})
.cloned()
.expect("narrow last ASCII source unit");
{
dispatch_selection_gesture(
&mut model,
Point::new(first.area.x, first.area.y),
Point::new(last.area.right(), last.area.y),
);
let wide_visible = model
.committed_selection
.fragments()
.iter()
.any(|fragment| {
fragment.content_id.as_str().rsplit('.').next() == Some("body")
&& fragment.source_range == (5..8)
});
if wide_visible {
assert_eq!(model.selected_plain_text.as_deref(), Some("a界b"));
} else {
assert!(
model.selected_plain_text.is_none(),
"clipped interior source must not be restored"
);
}
}
let fragment = model
.committed_selection
.fragments()
.iter()
.find(|fragment| fragment.content_id.as_str().rsplit('.').next() == Some("header"))
.cloned()
.expect("narrow fence geometry");
let footer = model
.committed_selection
.fragments()
.iter()
.find(|fragment| fragment.content_id.as_str().rsplit('.').next() == Some("footer"))
.cloned()
.expect("narrow footer geometry");
model.clear_transcript_selection();
dispatch_selection_gesture(
&mut model,
Point::new(fragment.area.x, fragment.area.y),
Point::new(footer.area.x, footer.area.y),
);
let wide_visible = model
.committed_selection
.fragments()
.iter()
.any(|fragment| {
fragment.content_id.as_str().rsplit('.').next() == Some("body")
&& fragment.source_range == (5..8)
});
if wide_visible {
assert_eq!(
model.selected_plain_text.as_deref(),
Some("```\na界b\n```\n")
);
} else {
assert!(
model.selected_plain_text.is_none(),
"fence traversal may not restore clipped code"
);
}
drop(model);
}
#[tokio::test]
async fn production_mixed_plain_markdown_gesture_preserves_item_order() {
use bmux_tui::{
event::{Event, MouseButton, MouseEvent, MouseEventKind},
geometry::{Point, Rect},
};
use bmux_tui_runtime::Presenter;
for reverse in [false, true] {
let mut model =
root_test_model_with_history(bcode_session_models::SessionId::new(), &[]);
model
.chat
.app
.push_ephemeral_system_plain("plain-before".to_owned());
model
.chat
.app
.push_ephemeral_system_markdown("```\ncode-middle\n```\n".to_owned());
model
.chat
.app
.push_ephemeral_system_plain("plain-after!".to_owned());
let mut bytes = Vec::new();
let mut terminal =
bmux_tui::terminal::Terminal::new(&mut bytes, Rect::new(0, 0, 70, 40));
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("mixed frame");
let items = model.chat.app.transcript().iter().collect::<Vec<_>>();
let first_id = format!("bcode.transcript.item.{}.plain", items[0].id().get());
let last_id = format!("bcode.transcript.item.{}.plain", items[2].id().get());
let fragments = model.committed_selection.fragments();
let first = fragments
.iter()
.find(|fragment| {
fragment.content_id.as_str() == first_id && fragment.source_range.start == 0
})
.expect("first plain source");
let last = fragments
.iter()
.find(|fragment| {
fragment.content_id.as_str() == last_id
&& fragment.source_range.end == "plain-after".len()
})
.expect("last plain source");
let start = Point::new(first.area.x, first.area.y);
let end = Point::new(last.area.x, last.area.y);
let reverse_start = Point::new(last.area.right(), last.area.y);
let (start, end) = if reverse {
(reverse_start, start)
} else {
(start, end)
};
for (kind, point) in [
(MouseEventKind::Down(MouseButton::Left), start),
(MouseEventKind::Drag(MouseButton::Left), end),
(MouseEventKind::Up(MouseButton::Left), end),
] {
model.handle_basic_terminal_event(Event::Mouse(MouseEvent::new(kind, point)));
}
assert_eq!(
model.selected_plain_text.as_deref(),
Some("plain-before\n\n```\ncode-middle\n```\n\n\nplain-after")
);
drop(model);
}
}
#[tokio::test]
async fn production_body_prose_body_selection_checks_each_source_interval() {
let source = "```\nfirst\n```\n\nprose between\n\n```\nsecond!\n```\n";
let start = source.find("first").expect("first body");
let end = source.find("second!").expect("second body") + "second".len();
for reverse in [false, true] {
assert_eq!(
production_code_gesture(source, 70, start..end, reverse),
&source[start..end]
);
}
}
#[tokio::test]
async fn production_mermaid_does_not_steal_following_code_fences() {
let source = "```mermaid\ngraph LR; A-->B\n```\n\n```rust\nlet value = 1;\n```\n";
let header = source.find("```rust").expect("ordinary header");
let body = source.find("let value").expect("ordinary body");
let footer = source.rfind("```").expect("ordinary footer");
assert_eq!(
production_code_gesture(source, 70, header..body, false),
"```rust\n"
);
assert_eq!(
production_code_gesture(source, 70, footer..source.len(), false),
"```\n"
);
}
#[tokio::test]
async fn production_code_drag_preserves_source_intervals() {
for newline in ["\n", "\r\n"] {
let body = format!("first 界e\u{301}{newline} {newline}\tsecond{newline}third");
let source = format!("```text{newline}{body}!{newline}```{newline}\nfollowing prose");
let start = source.find("first").expect("body start");
for reverse in [false, true] {
assert_eq!(
production_code_gesture(&source, 70, 0..start + body.len(), reverse),
&source[..start + body.len()]
);
let footer_end = source
.find("following prose")
.expect("prose")
.saturating_sub(1);
if !reverse {
assert_eq!(
production_code_gesture(&source, 70, start + 2..footer_end, reverse),
&source[start + 2..footer_end]
);
}
assert_eq!(
production_code_gesture(&source, 70, start..start + body.len(), reverse),
body
);
assert_eq!(
production_code_gesture(&source, 70, start + 2..start + body.len(), reverse),
&body[2..]
);
}
}
let body = "abcdefghijklmnopqrstuvwxyz0123456789";
let source = format!("```text\n{body}!\n```\n");
for reverse in [false, true] {
assert_eq!(
production_code_gesture(&source, 24, 8..8 + body.len(), reverse),
body
);
}
}
#[test]
fn production_export_rejects_stale_or_unresolved_source() {
let item = crate::transcript::TranscriptItem::with_format(
"Assistant",
"```\nbody\n```\n".to_owned(),
bcode_session_view_models::TextFormat::Markdown,
);
let id = format!(
"bcode.transcript.item.{}.markdown.code.0.body",
item.id().get()
);
let endpoint = |offset| bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(&id),
offset,
order: u64::try_from(offset).expect("offset"),
affinity: bmux_tui::selection::SelectionAffinity::Before,
revision: item.revision(),
};
let mut snapshot = bmux_tui::selection::SelectionSnapshot {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
anchor: endpoint(4),
focus: endpoint(8),
reversed: false,
slices: vec![bmux_tui::selection::SelectionSlice {
content_id: bmux_tui::selection::SelectionContentId::new(&id),
source_range: 4..8,
revision: item.revision(),
}],
visible_highlights: Vec::new(),
};
assert_eq!(
super::export_plain_transcript_selection([&item], &snapshot).as_deref(),
Some("body")
);
assert!(super::export_plain_transcript_selection(std::iter::empty(), &snapshot).is_none());
snapshot.slices[0].revision += 1;
assert!(super::export_plain_transcript_selection([&item], &snapshot).is_none());
}
#[test]
fn markdown_code_body_selection_exports_exact_canonical_bytes() {
let source = "```rust\r\nfn main() {\r\n\tprintln!(\"hi\");\r\n}\r\n```\r\n";
let item = super::super::transcript::TranscriptItem::with_format(
"Assistant",
source.to_owned(),
bcode_session_view_models::TextFormat::Markdown,
);
let item_id = item.id().get();
let snapshot = bmux_tui::selection::SelectionSnapshot {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
anchor: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(format!(
"bcode.transcript.item.{item_id}.markdown.code.0.body"
)),
offset: 0,
order: 0,
affinity: bmux_tui::selection::SelectionAffinity::Before,
revision: 0,
},
focus: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(format!(
"bcode.transcript.item.{item_id}.markdown.code.0.body"
)),
offset: source.len(),
order: 1,
affinity: bmux_tui::selection::SelectionAffinity::After,
revision: 0,
},
reversed: false,
slices: vec![bmux_tui::selection::SelectionSlice {
content_id: bmux_tui::selection::SelectionContentId::new(format!(
"bcode.transcript.item.{item_id}.markdown.code.0.body"
)),
source_range: 0..source.len(),
revision: 0,
}],
visible_highlights: Vec::new(),
};
assert_eq!(
super::export_plain_transcript_selection(std::iter::once(&item), &snapshot).as_deref(),
Some("fn main() {\r\n\tprintln!(\"hi\");\r\n}\r\n")
);
}
#[test]
fn transcript_export_preserves_indented_and_incomplete_code_source() {
for (source, expected) in [
(" first\n\tsecond\n\nnext", " first\n\tsecond\n\n"),
("```\npartial", "partial"),
] {
let item = super::super::transcript::TranscriptItem::with_format(
"Assistant",
source.to_owned(),
bcode_session_view_models::TextFormat::Markdown,
);
let item_id = item.id().get();
let content_id = format!("bcode.transcript.item.{item_id}.markdown.code.0.body");
let snapshot = bmux_tui::selection::SelectionSnapshot {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
anchor: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(content_id.clone()),
offset: 0,
order: 0,
affinity: bmux_tui::selection::SelectionAffinity::Before,
revision: 0,
},
focus: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(content_id.clone()),
offset: source.len(),
order: 0,
affinity: bmux_tui::selection::SelectionAffinity::After,
revision: 0,
},
reversed: false,
slices: vec![bmux_tui::selection::SelectionSlice {
content_id: bmux_tui::selection::SelectionContentId::new(content_id),
source_range: 0..source.len(),
revision: 0,
}],
visible_highlights: Vec::new(),
};
assert_eq!(
super::export_plain_transcript_selection(std::iter::once(&item), &snapshot)
.as_deref(),
Some(expected)
);
}
}
#[test]
fn markdown_code_whole_selection_exports_original_fences_and_language() {
let source = "```rust\nfn main() {}\n```\n";
let item = super::super::transcript::TranscriptItem::with_format(
"Assistant",
source.to_owned(),
bcode_session_view_models::TextFormat::Markdown,
);
let item_id = item.id().get();
let content_id = format!("bcode.transcript.item.{item_id}.markdown.code.0.whole");
let snapshot = bmux_tui::selection::SelectionSnapshot {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
anchor: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(content_id.clone()),
offset: 0,
order: 0,
affinity: bmux_tui::selection::SelectionAffinity::Before,
revision: 0,
},
focus: bmux_tui::selection::SelectionEndpoint {
scope_id: bmux_tui::selection::SelectionScopeId::new("bcode.transcript"),
content_id: bmux_tui::selection::SelectionContentId::new(content_id.clone()),
offset: source.len(),
order: 1,
affinity: bmux_tui::selection::SelectionAffinity::After,
revision: 0,
},
reversed: false,
slices: vec![bmux_tui::selection::SelectionSlice {
content_id: bmux_tui::selection::SelectionContentId::new(content_id),
source_range: 0..source.len(),
revision: 0,
}],
visible_highlights: Vec::new(),
};
assert_eq!(
super::export_plain_transcript_selection(std::iter::once(&item), &snapshot).as_deref(),
Some(source)
);
}
#[test]
fn plain_transcript_rows_map_to_item_local_utf8_offsets_only_when_exact() {
assert_eq!(
super::transcript_plain_row_source_ranges("a界\nsecond", 2),
vec![Some(0..4), Some(5..11)]
);
assert_eq!(
super::transcript_plain_row_source_ranges("wrapped source", 2),
vec![None, None]
);
}
#[test]
fn transcript_parent_scope_uses_committed_body_geometry() {
let body = bmux_tui::geometry::Rect::new(3, 4, 20, 8);
let mut scene = bmux_tui::selection::SelectionScene::new();
super::ensure_transcript_parent_scope(&mut scene, body);
super::ensure_transcript_parent_scope(&mut scene, body);
assert_eq!(scene.scopes().len(), 1);
assert_eq!(scene.scopes()[0].id.as_str(), "bcode.transcript");
assert_eq!(scene.scopes()[0].area, body);
assert_eq!(scene.scopes()[0].initiation_area, body);
}
#[tokio::test]
async fn root_model_starts_with_empty_committed_selection_state() {
let model = root_test_model();
assert!(model.committed_selection.scopes().is_empty());
assert!(model.committed_selection.fragments().is_empty());
assert_eq!(
model.transcript_selection.phase(),
bmux_tui::selection::SelectionGesturePhase::Idle
);
drop(model);
}
fn root_filesystem_plugin_host() -> bcode_plugin::PluginHost {
let bundled = [bcode_plugin::StaticBundledPlugin::new(
include_str!("../../../plugins/filesystem-plugin/bcode-plugin.toml"),
bcode_filesystem_plugin::static_plugin(),
)];
let selected = bcode_plugin::filter_selected_static_plugins(
&bundled,
&bcode_plugin::PluginSelection::all_enabled(),
)
.expect("static filesystem plugin manifest should parse");
bcode_plugin::PluginHost::load_static_plugins(&selected)
.expect("static filesystem plugin should load")
}
#[derive(Debug)]
struct RecordedRootFrame {
text: String,
invocation_primary_items: usize,
invocation_item_ids: Vec<bcode_session_view_models::TranscriptViewItemId>,
}
struct RecordingRootPresenter {
frames: Vec<RecordedRootFrame>,
}
impl RecordingRootPresenter {
const fn new() -> Self {
Self { frames: Vec::new() }
}
}
impl bmux_tui_runtime::Presenter<super::BcodeRuntimeModel> for RecordingRootPresenter {
type Error = super::TuiError;
fn reset(&mut self, _reason: bmux_tui_runtime::ResetReason) {}
fn present(
&mut self,
program: &mut super::BcodeRuntimeModel,
) -> Result<bmux_tui_runtime::PresentReport, Self::Error> {
let area = bmux_tui::geometry::Rect::new(0, 0, 100, 40);
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, area);
program.presentation_damage = bmux_tui::damage::Damage::Full;
let report = bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut terminal),
program,
)?;
let text = terminal
.retained_buffer()
.map_or_else(String::new, |buffer| {
(0..buffer.area().height)
.filter_map(|row| buffer.row_symbols(row))
.collect::<Vec<_>>()
.join("\n")
});
let invocation_item_ids = program
.chat
.app
.session_view_snapshot()
.transcript
.items
.iter()
.filter(|item| {
matches!(
item.kind,
bcode_session_view_models::TranscriptViewItemKind::ToolRequestDraft { .. }
| bcode_session_view_models::TranscriptViewItemKind::ToolInvocation { .. }
)
})
.map(|item| item.id.clone())
.collect::<Vec<_>>();
let invocation_primary_items = invocation_item_ids.len();
self.frames.push(RecordedRootFrame {
text,
invocation_primary_items,
invocation_item_ids,
});
Ok(report)
}
}
#[tokio::test]
async fn status_partial_presentation_matches_full_production_presenter() {
use bmux_tui_runtime::Presenter;
let area = bmux_tui::geometry::Rect::new(0, 0, 80, 24);
let mut partial_model = root_test_model();
let mut full_model = root_test_model();
let mut partial_bytes = Vec::new();
let mut full_bytes = Vec::new();
let mut partial_terminal = bmux_tui::terminal::Terminal::new(&mut partial_bytes, area);
let mut full_terminal = bmux_tui::terminal::Terminal::new(&mut full_bytes, area);
{
super::BcodeRuntimePresenter::new(&mut partial_terminal)
.present(&mut partial_model)
.expect("initial partial frame");
super::BcodeRuntimePresenter::new(&mut full_terminal)
.present(&mut full_model)
.expect("initial full frame");
}
for model in [&mut partial_model, &mut full_model] {
model.chat.app.set_status("timer advanced".to_owned());
model.invalidation = super::super::invalidation::UiInvalidation::Paint;
}
partial_model.presentation_damage = partial_model.select_presentation_damage(
super::super::invalidation::UiInvalidation::Paint,
&[super::super::app::TemporalDamage::Status],
);
partial_model.fast_temporal_presentation = true;
full_model.presentation_damage = bmux_tui::damage::Damage::Full;
super::BcodeRuntimePresenter::new(&mut partial_terminal)
.present(&mut partial_model)
.expect("partial status frame");
super::BcodeRuntimePresenter::new(&mut full_terminal)
.present(&mut full_model)
.expect("full status frame");
drop((partial_model, full_model));
assert_eq!(
partial_terminal.retained_buffer(),
full_terminal.retained_buffer()
);
assert_eq!(partial_terminal.cursor(), full_terminal.cursor());
assert_eq!(partial_terminal.image_scene(), full_terminal.image_scene());
}
#[tokio::test]
async fn modal_foreground_owns_input_and_metadata_across_frames() {
use bmux_tui_runtime::{Presenter, Program};
let mut model = root_test_model();
let surface = question_surface_for_root_test(model.settings.keymap()).await;
model
.loop_state
.install_interactive_surface_for_test(surface);
model.chat.app.replace_composer_with("preserved draft");
let area = bmux_tui::geometry::Rect::new(0, 0, 80, 24);
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, area);
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("chat");
model.loop_state.open_command_palette(&mut model.chat);
model.synchronize_screen();
model.presentation_damage = bmux_tui::damage::Damage::Full;
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("modal");
assert!(
terminal
.hits()
.regions()
.iter()
.all(|hit| hit.id.as_str() != "composer")
);
assert!(terminal.selection().fragments().is_empty());
assert_eq!(terminal.image_scene().placements().len(), 0);
let snapshot = terminal.retained_buffer().cloned();
let cursor = terminal.cursor();
model
.update(bmux_tui_runtime::RuntimeEvent::Terminal(
bmux_tui::event::Event::Paste("must not reach draft".into()),
))
.expect("modal paste");
assert_eq!(model.chat.app.composer().text(), "preserved draft");
let frozen_work = model
.loop_state
.active_interactive_surface_work_shape_for_test();
for _ in 0..3 {
model.presentation_damage = model.select_presentation_damage(
super::super::invalidation::UiInvalidation::Paint,
&[super::super::app::TemporalDamage::Composer],
);
model.fast_temporal_presentation = true;
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("modal timer");
assert_eq!(terminal.retained_buffer(), snapshot.as_ref());
assert_eq!(terminal.cursor(), cursor);
assert!(terminal.selection().fragments().is_empty());
}
assert_eq!(
model
.loop_state
.active_interactive_surface_work_shape_for_test(),
frozen_work
);
terminal.reset();
model.fast_temporal_presentation = true;
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("reset modal");
assert_eq!(terminal.retained_buffer(), snapshot.as_ref());
assert_eq!(terminal.cursor(), cursor);
let escape = bmux_tui::event::Event::Key(bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Escape,
modifiers: bmux_keyboard::Modifiers::NONE,
});
model
.update(bmux_tui_runtime::RuntimeEvent::Terminal(escape))
.expect("close modal");
assert!(!model.loop_state.has_command_palette());
assert!(model.loop_state.has_interactive_surface());
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("restored chat");
assert!(
terminal
.hits()
.regions()
.iter()
.any(|hit| hit.id.as_str() == "composer")
);
drop(model);
}
#[tokio::test]
async fn modal_resize_and_replacement_reconcile_foreground_scene() {
use bmux_tui_runtime::Presenter;
let mut model = root_test_model();
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(
&mut bytes,
bmux_tui::geometry::Rect::new(0, 0, 80, 24),
);
model.loop_state.open_command_palette(&mut model.chat);
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("palette");
model.loop_state.open_session_picker(&mut model.chat);
assert_eq!(
model.loop_state.foreground(),
Some(crate::foreground::Foreground::Sessions)
);
model.synchronize_screen();
terminal.resize(bmux_tui::geometry::Rect::new(0, 0, 100, 30));
model.presentation_damage = bmux_tui::damage::Damage::Full;
model.fast_temporal_presentation = true;
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("resized sessions");
let partial = terminal.retained_buffer().cloned();
let hits = terminal.hits().clone();
let semantics = terminal.semantics().clone();
model.presentation_damage = bmux_tui::damage::Damage::Full;
model.fast_temporal_presentation = false;
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("reference sessions");
assert_eq!(terminal.retained_buffer(), partial.as_ref());
assert_eq!(terminal.hits(), &hits);
assert_eq!(terminal.semantics(), &semantics);
assert!(
terminal
.hits()
.regions()
.iter()
.all(|hit| hit.id.as_str() != "composer")
);
drop(model);
}
#[tokio::test]
async fn sessions_cursor_frames_preserve_foreground_composition() {
use bmux_tui_runtime::Presenter;
let area = bmux_tui::geometry::Rect::new(0, 0, 80, 24);
let mut partial = root_test_model();
let mut full = root_test_model();
for model in [&mut partial, &mut full] {
model.loop_state.open_session_picker(&mut model.chat);
}
let mut partial_bytes = Vec::new();
let mut full_bytes = Vec::new();
let mut partial_terminal = bmux_tui::terminal::Terminal::new(&mut partial_bytes, area);
let mut full_terminal = bmux_tui::terminal::Terminal::new(&mut full_bytes, area);
super::BcodeRuntimePresenter::new(&mut partial_terminal)
.present(&mut partial)
.expect("initial picker");
super::BcodeRuntimePresenter::new(&mut full_terminal)
.present(&mut full)
.expect("initial reference picker");
let picker_cursor = partial_terminal.cursor();
for tick in 1..=4 {
let now = std::time::Instant::now() + std::time::Duration::from_secs(tick);
for model in [&mut partial, &mut full] {
let key = model
.chat
.app
.invalidation_requests(now, std::time::SystemTime::now())
.into_iter()
.next()
.expect("cursor timer")
.key;
model.chat.app.handle_invalidations(&[key], now);
}
partial.presentation_damage = partial.select_presentation_damage(
super::super::invalidation::UiInvalidation::Paint,
&[super::super::app::TemporalDamage::Composer],
);
partial.fast_temporal_presentation = true;
full.presentation_damage = bmux_tui::damage::Damage::Full;
super::BcodeRuntimePresenter::new(&mut partial_terminal)
.present(&mut partial)
.expect("partial picker");
super::BcodeRuntimePresenter::new(&mut full_terminal)
.present(&mut full)
.expect("full picker");
assert_eq!(
partial_terminal.retained_buffer(),
full_terminal.retained_buffer()
);
assert_eq!(partial_terminal.cursor(), picker_cursor);
assert_eq!(partial_terminal.cursor(), full_terminal.cursor());
assert_eq!(partial_terminal.semantics(), full_terminal.semantics());
assert_eq!(partial_terminal.image_scene(), full_terminal.image_scene());
}
drop((partial, full));
}
#[tokio::test]
async fn cursor_partial_presentation_matches_full_production_presenter() {
use bmux_tui_runtime::Presenter;
let area = bmux_tui::geometry::Rect::new(0, 0, 80, 24);
let mut partial_model = root_test_model();
let mut full_model = root_test_model();
let mut partial_bytes = Vec::new();
let mut full_bytes = Vec::new();
let mut partial_terminal = bmux_tui::terminal::Terminal::new(&mut partial_bytes, area);
let mut full_terminal = bmux_tui::terminal::Terminal::new(&mut full_bytes, area);
{
let mut presenter = super::BcodeRuntimePresenter::new(&mut partial_terminal);
presenter
.present(&mut partial_model)
.expect("initial partial-side frame presents");
}
{
let mut presenter = super::BcodeRuntimePresenter::new(&mut full_terminal);
presenter
.present(&mut full_model)
.expect("initial full-side frame presents");
}
let now = std::time::Instant::now() + std::time::Duration::from_secs(1);
for model in [&mut partial_model, &mut full_model] {
let cursor_key = model
.chat
.app
.invalidation_requests(now, std::time::SystemTime::now())
.into_iter()
.next()
.expect("cursor invalidation is always scheduled")
.key;
assert_eq!(
model.chat.app.handle_invalidations(&[cursor_key], now),
super::super::invalidation::UiInvalidation::Paint
);
model.invalidation = super::super::invalidation::UiInvalidation::Paint;
}
let temporal_damage = [super::super::app::TemporalDamage::Composer];
partial_model.presentation_damage = partial_model.select_presentation_damage(
super::super::invalidation::UiInvalidation::Paint,
&temporal_damage,
);
partial_model.fast_temporal_presentation = true;
full_model.presentation_damage = bmux_tui::damage::Damage::Full;
{
let mut presenter = super::BcodeRuntimePresenter::new(&mut partial_terminal);
presenter
.present(&mut partial_model)
.expect("cursor partial frame presents");
}
{
let mut presenter = super::BcodeRuntimePresenter::new(&mut full_terminal);
presenter
.present(&mut full_model)
.expect("cursor full frame presents");
}
drop((partial_model, full_model));
assert_eq!(
partial_terminal.retained_buffer(),
full_terminal.retained_buffer()
);
assert_eq!(partial_terminal.cursor(), full_terminal.cursor());
assert_eq!(partial_terminal.image_scene(), full_terminal.image_scene());
for point in [
bmux_tui::geometry::Point::new(0, 0),
bmux_tui::geometry::Point::new(2, 22),
bmux_tui::geometry::Point::new(79, 23),
] {
assert_eq!(
partial_terminal
.hits()
.hit_test(point)
.map(|hit| hit.id().as_str().to_owned()),
full_terminal
.hits()
.hit_test(point)
.map(|hit| hit.id().as_str().to_owned())
);
}
}
#[tokio::test]
async fn presentation_damage_localizes_stable_temporal_regions() {
use bmux_tui_runtime::Presenter;
let area = bmux_tui::geometry::Rect::new(0, 0, 80, 24);
let mut model = root_test_model();
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, area);
{
let mut presenter = super::BcodeRuntimePresenter::new(&mut terminal);
presenter
.present(&mut model)
.expect("initial frame presents");
}
for temporal_damage in [
super::super::app::TemporalDamage::Composer,
super::super::app::TemporalDamage::Status,
super::super::app::TemporalDamage::Transcript,
] {
assert!(matches!(
model.select_presentation_damage(
super::super::invalidation::UiInvalidation::Paint,
&[temporal_damage],
),
bmux_tui::damage::Damage::Regions(_)
));
}
assert!(
model
.select_presentation_damage(
super::super::invalidation::UiInvalidation::Paint,
&[super::super::app::TemporalDamage::Full],
)
.is_full()
);
for invalidation in [
super::super::invalidation::UiInvalidation::Items,
super::super::invalidation::UiInvalidation::Structural,
super::super::invalidation::UiInvalidation::Full,
] {
assert!(
model
.select_presentation_damage(
invalidation,
&[super::super::app::TemporalDamage::Status],
)
.is_full()
);
}
drop(model);
}
#[test]
fn ordered_note_queue_releases_one_per_session_until_typed_completion() {
let first_session = SessionId::new();
let second_session = SessionId::new();
let mut notes = BTreeMap::new();
notes.insert(
first_session,
VecDeque::from([note(first_session, "0001"), note(first_session, "0002")]),
);
notes.insert(
second_session,
VecDeque::from([note(second_session, "0003")]),
);
let mut queue = OrderedPresentationQueue::default();
queue.append(notes);
let first = queue.take_ready();
assert_eq!(first.len(), 2);
assert!(queue.take_ready().is_empty());
queue.complete(first_session);
let second = queue.take_ready();
assert_eq!(second.len(), 1);
assert!(queue.take_ready().is_empty());
}
fn root_test_chat_with_input_history(
input_history: &[bcode_session_models::SessionInputHistoryEntry],
) -> super::super::session_flow::ActiveChat {
let (event_sender, event_receiver) = super::super::history_flow::session_stream_channel();
super::super::session_flow::ActiveChat {
app: super::super::app::BmuxApp::new_with_history(None, &[], input_history, false),
agents: super::super::session_flow::AgentCatalog::default(),
attachment: super::super::session_flow::ChatSessionAttachment::Draft,
event_sender,
event_receiver,
event_task: None,
opening_session_progress: None,
pending_effects: super::super::effects::TuiEffectQueue::default(),
}
}
fn root_test_chat_with_history(
session_id: bcode_session_models::SessionId,
history: &[bcode_session_models::SessionEvent],
) -> super::super::session_flow::ActiveChat {
let (event_sender, event_receiver) = super::super::history_flow::session_stream_channel();
super::super::session_flow::ActiveChat {
app: super::super::app::BmuxApp::new_with_history(
Some(session_id),
history,
&[],
false,
),
agents: super::super::session_flow::AgentCatalog::default(),
attachment: super::super::session_flow::ChatSessionAttachment::Attached { session_id },
event_sender,
event_receiver,
event_task: None,
opening_session_progress: None,
pending_effects: super::super::effects::TuiEffectQueue::default(),
}
}
pub(super) fn root_test_model_with_history(
session_id: bcode_session_models::SessionId,
history: &[bcode_session_models::SessionEvent],
) -> super::BcodeRuntimeModel {
let chat = root_test_chat_with_history(session_id, history);
let mut settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let mut actions = bcode_question_plugin::question_tui_registry()
.keymap_actions()
.to_vec();
actions.extend_from_slice(bcode_shell_plugin::shell_tui_registry().keymap_actions());
settings.integrate_keymap_contributors(Ok(actions));
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let loop_state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
super::BcodeRuntimeModel::new(chat, settings, loop_state)
}
fn root_test_chat() -> super::super::session_flow::ActiveChat {
root_test_chat_with_input_history(&[])
}
fn paint_focused_owner_for_root_test(
model: &mut super::BcodeRuntimeModel,
terminal: &mut bmux_tui::terminal::Terminal<&mut Vec<u8>>,
) {
bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(terminal),
model,
)
.expect("real focused owner presentation");
}
#[tokio::test]
async fn focused_completion_accepts_through_question_owner_without_submission() {
use bmux_tui::event::Event;
use bmux_tui::geometry::Rect;
let mut model = root_test_model();
let mut config = model.chat.app.tui_config().clone();
config.interactions.placement = bcode_config::TuiInteractionPlacement::Pinned;
model.chat.app.apply_tui_config(config);
let surface = question_surface_with_questions_for_root_test(
model.settings.keymap(),
serde_json::json!([{
"header": null, "question": "Path", "options": [],
"control": "radio", "selection_mode": "single", "custom": true,
"custom_mode": "additional", "required": true
}]),
)
.await;
model
.loop_state
.install_interactive_surface_for_test(surface);
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, Rect::new(0, 0, 80, 30));
paint_focused_owner_for_root_test(&mut model, &mut terminal);
assert!(model.loop_state.interactive_focused_text_field().is_some());
for key in ["@", "a"] {
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Terminal(Event::Key(
bmux_keyboard::parse_key_stroke(key).unwrap(),
)),
)
.unwrap();
}
paint_focused_owner_for_root_test(&mut model, &mut terminal);
let field = model
.loop_state
.interactive_focused_text_field()
.expect("visible question field after real paint");
assert_eq!(field.document.text, "@a");
let destination = model
.loop_state
.active_interactive_surface_geometry()
.expect("presented owner geometry")
.destination;
assert!(!destination.is_empty());
assert_eq!(field.anchor.intersection(destination), field.anchor);
assert_eq!(
field.content_rect.intersection(destination),
field.content_rect
);
let scope = super::super::composer_interaction::ComposerScope {
session: model.chat.attached_session_id(),
agent_id: Some("build".into()),
workspace: Some(std::path::PathBuf::from("/workspace")),
registry_identity: 1,
registry_revision: 0,
};
model
.loop_state
.composer_interaction
.observe_focused_field(scope, field);
model.focused_input_owner = Some(super::FocusedInputOwner::InteractiveSurface);
let request = super::super::composer_interaction::tests::begin_observed(
&mut model.loop_state.composer_interaction,
);
model.loop_state.composer_interaction.complete(
super::super::composer_interaction::tests::completion(&request),
);
assert_ne!(
model.compose_focused_input_event(&Event::Key(
bmux_keyboard::parse_key_stroke("enter").unwrap(),
)),
bmux_tui::event::EventOutcome::Ignored,
);
assert!(model.chat.app.take_pending_submission().is_empty());
assert!(
model
.loop_state
.latched_surface_outcome_for_test()
.is_none()
);
assert!(!model.loop_state.composer_interaction.visible());
assert_completion_escape_retains_question(&mut model);
let outcome = model
.loop_state
.handle_interactive_surface_event(&Event::Key(
bmux_keyboard::parse_key_stroke("enter").unwrap(),
));
drop(model);
let super::super::interactive_surface::InteractiveSurfaceEventOutcome::Resolved(
bcode_session_models::ToolExchangeResolution::Responded { payload },
) = outcome
else {
panic!("owner submission must resolve");
};
assert!(payload.to_string().contains("主题"));
}
fn assert_completion_escape_retains_question(model: &mut super::BcodeRuntimeModel) {
let request = super::super::composer_interaction::tests::begin_observed(
&mut model.loop_state.composer_interaction,
);
model.loop_state.composer_interaction.complete(
super::super::composer_interaction::tests::completion(&request),
);
bmux_tui_runtime::Program::update(
model,
bmux_tui_runtime::RuntimeEvent::Terminal(bmux_tui::event::Event::Key(
bmux_keyboard::parse_key_stroke("escape").unwrap(),
)),
)
.unwrap();
assert!(model.loop_state.has_interactive_surface());
assert!(
model
.loop_state
.latched_surface_outcome_for_test()
.is_none()
);
assert!(!model.loop_state.composer_interaction.visible());
}
async fn question_surface_with_questions_for_root_test(
keymap: &super::super::keymap::BmuxKeyMap,
questions: serde_json::Value,
) -> super::super::interactive_surface::InteractiveSurfaceState {
let plugin = bcode_plugin::StaticBundledPlugin::new(
include_str!("../../../plugins/question-plugin/bcode-plugin.toml"),
bcode_question_plugin::static_plugin(),
);
let runtime = bcode_plugin::PluginRuntimeHost::load_defaults_with_static_bundled(
&bcode_plugin::PluginSelection::all_enabled(),
&[plugin],
)
.expect("question plugin runtime");
let mut effective_keymap = keymap.clone();
effective_keymap.integrate_contributors(Ok(bcode_question_plugin::question_tui_registry()
.keymap_actions()
.to_vec()));
super::super::interactive_surface::InteractiveSurfaceState::open(
&runtime,
"question-root-test",
"bcode.question.inline",
&serde_json::json!({ "questions": questions }).to_string(),
&effective_keymap,
)
.await
.expect("question surface")
}
async fn question_surface_for_root_test(
keymap: &super::super::keymap::BmuxKeyMap,
) -> super::super::interactive_surface::InteractiveSurfaceState {
question_surface_with_questions_for_root_test(
keymap,
serde_json::json!([{
"header": null,
"question": "Choose one",
"options": [
{"label": "One", "value": "one", "description": null},
{"label": "Two", "value": "two", "description": null}
],
"control": "radio",
"selection_mode": "single",
"custom": false,
"custom_mode": "additional",
"required": true
}]),
)
.await
}
#[tokio::test]
async fn failed_delivery_preserves_surface_and_clears_only_pending_resolution() {
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let client = bcode_client::BcodeClient::default_endpoint();
let passive = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let mut loop_state = super::super::chat_loop::ChatLoopState::new(&client, &passive, false);
loop_state.install_interactive_surface_for_test(
question_surface_for_root_test(settings.keymap()).await,
);
assert!(matches!(
loop_state.handle_interactive_surface_event(&bmux_tui::event::Event::Key(
bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Enter,
modifiers: bmux_keyboard::Modifiers::NONE,
},
)),
super::super::interactive_surface::InteractiveSurfaceEventOutcome::Redraw
));
let tab = bmux_tui::event::Event::Key(bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Tab,
modifiers: bmux_keyboard::Modifiers::NONE,
});
let enter = bmux_tui::event::Event::Key(bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Enter,
modifiers: bmux_keyboard::Modifiers::NONE,
});
let _ = loop_state.handle_interactive_surface_event(&tab);
let _ = loop_state.handle_interactive_surface_event(&tab);
assert!(matches!(
loop_state.handle_interactive_surface_event(&enter),
super::super::interactive_surface::InteractiveSurfaceEventOutcome::Resolved(_)
));
assert!(loop_state.latched_surface_outcome_for_test().is_some());
loop_state.complete_interactive_surface_resolution(false);
assert_eq!(
loop_state.active_interactive_surface_id(),
Some("question-root-test")
);
assert!(loop_state.latched_surface_outcome_for_test().is_none());
let _ = loop_state.handle_interactive_surface_event(&enter);
assert!(loop_state.latched_surface_outcome_for_test().is_some());
loop_state.complete_interactive_surface_resolution(true);
assert!(loop_state.active_interactive_surface_id().is_none());
}
#[tokio::test]
async fn hidden_transcript_geometry_applies_retain_and_suspend_routing() {
let client = bcode_client::BcodeClient::default_endpoint();
let passive = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let mut loop_state = super::super::chat_loop::ChatLoopState::new(&client, &passive, false);
loop_state.set_interactive_surface_geometry_for_test(Some(
super::super::chat_loop::InteractiveSurfaceGeometry {
placement: super::super::chat_loop::InteractiveSurfacePlacement::Transcript,
logical_height: 20,
visible_logical_offset: 0,
destination: bmux_tui::geometry::Rect::new(0, 0, 80, 0),
},
));
let mut config = bcode_config::TuiInteractionConfig::default();
assert!(loop_state.routes_non_mouse_event_to_interactive_surface(config));
config.offscreen_focus = bcode_config::TuiInteractionOffscreenFocus::Suspend;
assert!(!loop_state.routes_non_mouse_event_to_interactive_surface(config));
loop_state.set_interactive_surface_geometry_for_test(Some(
super::super::chat_loop::InteractiveSurfaceGeometry {
placement: super::super::chat_loop::InteractiveSurfacePlacement::Pinned,
logical_height: 20,
visible_logical_offset: 0,
destination: bmux_tui::geometry::Rect::new(0, 0, 80, 0),
},
));
assert!(loop_state.routes_non_mouse_event_to_interactive_surface(config));
}
#[tokio::test]
#[allow(clippy::too_many_lines)] async fn committed_geometry_routes_top_and_bottom_clipped_pointer_input() {
use std::sync::{Arc, Mutex};
struct RecordingSurface(
Arc<Mutex<Vec<(bmux_tui::event::MouseEventKind, bmux_tui::geometry::Point)>>>,
);
impl bcode_plugin_sdk::tui::PluginTuiSurface for RecordingSurface {
fn id(&self) -> &'static str {
"recording"
}
fn title(&self) -> &'static str {
"recording"
}
fn render(
&mut self,
_area: bmux_tui::geometry::Rect,
_frame: &mut bmux_tui::paint::PaintCx<'_, '_>,
) {
}
fn handle_event(
&mut self,
event: &bmux_tui::event::Event,
_host: &dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> bcode_plugin_sdk::tui::PluginTuiAction {
if let bmux_tui::event::Event::Mouse(mouse) = event {
self.0
.lock()
.expect("events")
.push((mouse.kind, mouse.position));
bcode_plugin_sdk::tui::PluginTuiAction::Redraw
} else {
bcode_plugin_sdk::tui::PluginTuiAction::None
}
}
}
let client = bcode_client::BcodeClient::default_endpoint();
let passive = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let mut loop_state = super::super::chat_loop::ChatLoopState::new(&client, &passive, false);
let events = Arc::new(Mutex::new(Vec::new()));
loop_state.install_interactive_surface_for_test(
super::super::interactive_surface::InteractiveSurfaceState::from_surface_for_test(
"interaction",
Box::new(RecordingSurface(Arc::clone(&events))),
&super::super::keymap::BmuxKeyMap::from_config(&bcode_config::TuiConfig::default()),
),
);
for (visible_logical_offset, destination, screen_point, expected_logical_point) in [
(
0,
bmux_tui::geometry::Rect::new(5, 7, 20, 4),
bmux_tui::geometry::Point::new(8, 10),
bmux_tui::geometry::Point::new(3, 3),
),
(
26,
bmux_tui::geometry::Rect::new(5, 7, 20, 4),
bmux_tui::geometry::Point::new(8, 9),
bmux_tui::geometry::Point::new(3, 28),
),
] {
loop_state.set_interactive_surface_geometry_for_test(Some(
super::super::chat_loop::InteractiveSurfaceGeometry {
placement: super::super::chat_loop::InteractiveSurfacePlacement::Transcript,
logical_height: 30,
visible_logical_offset,
destination,
},
));
for kind in [
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::event::MouseEventKind::Drag(bmux_tui::event::MouseButton::Left),
bmux_tui::event::MouseEventKind::Up(bmux_tui::event::MouseButton::Left),
] {
let event = bmux_tui::event::Event::Mouse(bmux_tui::event::MouseEvent::new(
kind,
screen_point,
));
assert!(matches!(
loop_state.handle_interactive_surface_event(&event),
super::super::interactive_surface::InteractiveSurfaceEventOutcome::Redraw
));
}
let outside = bmux_tui::event::Event::Mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(destination.x.saturating_sub(1), screen_point.y),
));
assert!(matches!(
loop_state.handle_interactive_surface_event(&outside),
super::super::interactive_surface::InteractiveSurfaceEventOutcome::Ignored
));
let recorded = events.lock().expect("events");
let recent = &recorded[recorded.len().saturating_sub(3)..];
assert!(
recent
.iter()
.all(|(_, point)| *point == expected_logical_point)
);
drop(recorded);
}
assert_eq!(events.lock().expect("events").len(), 6);
loop_state.set_interactive_surface_geometry_for_test(None);
let event = bmux_tui::event::Event::Mouse(bmux_tui::event::MouseEvent::new(
bmux_tui::event::MouseEventKind::Down(bmux_tui::event::MouseButton::Left),
bmux_tui::geometry::Point::new(8, 9),
));
assert!(matches!(
loop_state.handle_interactive_surface_event(&event),
super::super::interactive_surface::InteractiveSurfaceEventOutcome::Ignored
));
}
#[tokio::test]
async fn interaction_host_keys_keep_application_and_transcript_precedence() {
let model = root_test_model();
let key = |key, modifiers| bmux_keyboard::KeyStroke { key, modifiers };
let ctrl = bmux_keyboard::Modifiers {
ctrl: true,
..bmux_keyboard::Modifiers::NONE
};
for (stroke, expected) in [
(
key(
bmux_keyboard::KeyCode::PageUp,
bmux_keyboard::Modifiers::NONE,
),
super::super::keymap::BmuxAction::TranscriptPageUp,
),
(
key(
bmux_keyboard::KeyCode::PageDown,
bmux_keyboard::Modifiers::NONE,
),
super::super::keymap::BmuxAction::TranscriptPageDown,
),
(
key(bmux_keyboard::KeyCode::Home, ctrl),
super::super::keymap::BmuxAction::TranscriptTop,
),
(
key(bmux_keyboard::KeyCode::End, ctrl),
super::super::keymap::BmuxAction::TranscriptBottom,
),
(
key(bmux_keyboard::KeyCode::Up, ctrl),
super::super::keymap::BmuxAction::TranscriptLineUp,
),
(
key(bmux_keyboard::KeyCode::Down, ctrl),
super::super::keymap::BmuxAction::TranscriptLineDown,
),
(
key(bmux_keyboard::KeyCode::Char('d'), ctrl),
super::super::keymap::BmuxAction::AppExit,
),
] {
assert_eq!(
model.root_interactive_surface_host_key(stroke),
Some(expected)
);
}
assert_eq!(
model.root_interactive_surface_host_key(key(
bmux_keyboard::KeyCode::Up,
bmux_keyboard::Modifiers::NONE,
)),
None
);
drop(model);
}
#[tokio::test]
async fn stale_surface_open_completion_does_not_report_a_retry_failure() {
let mut model = root_test_model();
model.chat.app.set_status("steady".to_owned());
let surface = question_surface_for_root_test(model.settings.keymap()).await;
let _update = bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(
super::BcodeRuntimeMessage::InteractiveSurfaceOpened(Ok(surface)),
),
)
.expect("stale open completion is handled");
assert_eq!(model.chat.app.status(), "steady");
assert!(!model.loop_state.has_interactive_surface());
drop(model);
}
#[tokio::test]
#[allow(clippy::too_many_lines)] async fn normal_question_path_covers_radio_checkbox_custom_validation_and_both_placements() {
for placement in [
bcode_config::TuiInteractionPlacement::Transcript,
bcode_config::TuiInteractionPlacement::Pinned,
] {
let mut model = root_test_model();
let mut config = model.chat.app.tui_config().clone();
config.interactions.placement = placement;
model.chat.app.apply_tui_config(config);
let keymap = super::super::keymap::BmuxKeyMap::from_config(model.chat.app.tui_config());
model.loop_state.install_interactive_surface_for_test(
question_surface_with_questions_for_root_test(
&keymap,
serde_json::json!([
{
"header": null,
"question": "Choose radio",
"options": [
{"label": "One", "value": "one", "description": null},
{"label": "Two", "value": "two", "description": null}
],
"control": "radio",
"selection_mode": "single",
"custom": false,
"custom_mode": "additional",
"required": true
},
{
"header": null,
"question": "Choose checks",
"options": [
{"label": "Alpha", "value": "alpha", "description": null},
{"label": "Beta", "value": "beta", "description": null}
],
"control": "checkbox",
"selection_mode": "multiple",
"custom": false,
"custom_mode": "additional",
"required": false
},
{
"header": null,
"question": "Explain",
"options": [],
"control": "radio",
"selection_mode": "single",
"custom": true,
"custom_mode": "additional",
"required": true
}
]),
)
.await,
);
let area = bmux_tui::geometry::Rect::new(0, 0, 80, 24);
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, area);
bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut terminal),
&mut model,
)
.expect("initial question matrix frame");
let event = |key| {
bmux_tui_runtime::RuntimeEvent::Terminal(bmux_tui::event::Event::Key(
bmux_keyboard::KeyStroke {
key,
modifiers: bmux_keyboard::Modifiers::NONE,
},
))
};
for key in [
bmux_keyboard::KeyCode::Enter,
bmux_keyboard::KeyCode::Tab,
bmux_keyboard::KeyCode::Space,
bmux_keyboard::KeyCode::Tab,
bmux_keyboard::KeyCode::Tab,
bmux_keyboard::KeyCode::Char('x'),
] {
bmux_tui_runtime::Program::update(&mut model, event(key))
.expect("question matrix input");
bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut terminal),
&mut model,
)
.expect("question matrix committed presentation");
}
drop(model);
}
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let mut validation_surface = question_surface_with_questions_for_root_test(
settings.keymap(),
serde_json::json!([{
"header": null,
"question": "Required",
"options": [],
"control": "radio",
"selection_mode": "single",
"custom": true,
"custom_mode": "additional",
"required": true
}]),
)
.await;
let before = validation_surface.preferred_height(40);
assert!(matches!(
validation_surface.handle_event_outcome(&bmux_tui::event::Event::Key(
bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Enter,
modifiers: bmux_keyboard::Modifiers::NONE,
},
)),
super::super::interactive_surface::InteractiveSurfaceEventOutcome::Relayout
));
assert!(validation_surface.preferred_height(40) > before);
}
#[tokio::test]
async fn interactive_surface_navigation_repaints_without_composer_history_fallthrough() {
let history = [bcode_session_models::SessionInputHistoryEntry {
timestamp_ms: 1,
sequence: 1,
text: "previous prompt".to_owned(),
}];
let mut model = {
let mut chat = root_test_chat_with_input_history(&history);
chat.app.replace_composer_with("draft prompt");
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let mut loop_state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
loop_state.install_interactive_surface_for_test(
question_surface_for_root_test(settings.keymap()).await,
);
super::BcodeRuntimeModel::new(chat, settings, loop_state)
};
let down = bmux_tui::event::Event::Key(bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Down,
modifiers: bmux_keyboard::Modifiers::NONE,
});
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Terminal(down),
)
.expect("root update handles question navigation");
assert_eq!(model.chat.app.composer().text(), "draft prompt");
assert!(!model.chat.app.input_history_navigation_active());
assert_eq!(
model
.loop_state
.active_interactive_surface_native_id_for_test(),
Some("question-inline")
);
let key = |key| {
bmux_tui::event::Event::Key(bmux_keyboard::KeyStroke {
key,
modifiers: bmux_keyboard::Modifiers::NONE,
})
};
assert!(matches!(
model
.loop_state
.handle_interactive_surface_event(&key(bmux_keyboard::KeyCode::Enter)),
super::super::interactive_surface::InteractiveSurfaceEventOutcome::Redraw
));
assert!(matches!(
model
.loop_state
.handle_interactive_surface_event(&key(bmux_keyboard::KeyCode::Tab)),
super::super::interactive_surface::InteractiveSurfaceEventOutcome::Redraw
));
let outcome = model
.loop_state
.handle_interactive_surface_event(&key(bmux_keyboard::KeyCode::Enter));
drop(model);
let super::super::interactive_surface::InteractiveSurfaceEventOutcome::Resolved(
bcode_session_models::ToolExchangeResolution::Responded { payload },
) = outcome
else {
panic!("question should submit after selecting the second option");
};
assert_eq!(payload["questions"][0]["selected"][0], "two");
}
#[tokio::test]
async fn consumed_interaction_navigation_does_not_reach_composer_history() {
struct ConsumingSurface;
impl bcode_plugin_sdk::tui::PluginTuiSurface for ConsumingSurface {
fn id(&self) -> &'static str {
"consuming-test"
}
fn title(&self) -> &'static str {
"Consuming test"
}
fn render(
&mut self,
_area: bmux_tui::geometry::Rect,
_frame: &mut bmux_tui::paint::PaintCx<'_, '_>,
) {
}
fn handle_event(
&mut self,
event: &bmux_tui::event::Event,
_host: &dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> bcode_plugin_sdk::tui::PluginTuiAction {
if matches!(
event,
bmux_tui::event::Event::Key(bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Up | bmux_keyboard::KeyCode::Down,
..
})
) {
bcode_plugin_sdk::tui::PluginTuiAction::Redraw
} else {
bcode_plugin_sdk::tui::PluginTuiAction::None
}
}
}
let history = [bcode_session_models::SessionInputHistoryEntry {
timestamp_ms: 1,
sequence: 1,
text: "previous prompt".to_owned(),
}];
let mut model = {
let mut chat = root_test_chat_with_input_history(&history);
chat.app.replace_composer_with("draft prompt");
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let mut loop_state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
loop_state.install_interactive_surface_for_test(
super::super::interactive_surface::InteractiveSurfaceState::from_surface_for_test(
"test-interaction",
Box::new(ConsumingSurface),
settings.keymap(),
),
);
super::BcodeRuntimeModel::new(chat, settings, loop_state)
};
model.invalidation = super::super::invalidation::UiInvalidation::None;
let _update = bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Terminal(bmux_tui::event::Event::Key(
bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Up,
modifiers: bmux_keyboard::Modifiers::NONE,
},
)),
)
.expect("root update handles consumed interaction input");
assert_eq!(model.chat.app.composer().text(), "draft prompt");
assert!(!model.chat.app.input_history_navigation_active());
assert_eq!(
model.invalidation,
super::super::invalidation::UiInvalidation::Paint
);
drop(model);
}
#[tokio::test]
async fn stable_interaction_redraw_matches_full_presentation() {
use bmux_tui::{geometry::Rect, paint::PaintCx, prelude::Line};
struct CountingSurface(usize);
impl bcode_plugin_sdk::tui::PluginTuiSurface for CountingSurface {
fn id(&self) -> &'static str {
"counting-test"
}
fn title(&self) -> &'static str {
"Counting test"
}
fn render(&mut self, area: Rect, frame: &mut PaintCx<'_, '_>) {
frame.write_line(
bmux_tui::paint::LocalRect::terminal(area),
&Line::from(self.0.to_string()),
);
}
fn handle_event(
&mut self,
event: &bmux_tui::event::Event,
_host: &dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> bcode_plugin_sdk::tui::PluginTuiAction {
if matches!(event, bmux_tui::event::Event::Key(_)) {
self.0 = self.0.saturating_add(1);
bcode_plugin_sdk::tui::PluginTuiAction::Redraw
} else {
bcode_plugin_sdk::tui::PluginTuiAction::None
}
}
}
fn model() -> super::BcodeRuntimeModel {
let mut chat = root_test_chat();
let mut config = chat.app.tui_config().clone();
config.interactions.placement = bcode_config::TuiInteractionPlacement::Pinned;
chat.app.apply_tui_config(config);
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let mut loop_state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
loop_state.install_interactive_surface_for_test(
super::super::interactive_surface::InteractiveSurfaceState::from_surface_for_test(
"counting-interaction",
Box::new(CountingSurface(0)),
settings.keymap(),
),
);
super::BcodeRuntimeModel::new(chat, settings, loop_state)
}
let area = Rect::new(0, 0, 80, 24);
let mut partial = model();
let mut full = model();
let mut partial_bytes = Vec::new();
let mut full_bytes = Vec::new();
let mut partial_terminal = bmux_tui::terminal::Terminal::new(&mut partial_bytes, area);
let mut full_terminal = bmux_tui::terminal::Terminal::new(&mut full_bytes, area);
bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut partial_terminal),
&mut partial,
)
.expect("initial partial presentation");
bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut full_terminal),
&mut full,
)
.expect("initial full presentation");
let event = || {
bmux_tui_runtime::RuntimeEvent::Terminal(bmux_tui::event::Event::Key(
bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Down,
modifiers: bmux_keyboard::Modifiers::NONE,
},
))
};
bmux_tui_runtime::Program::update(&mut partial, event()).expect("partial input");
bmux_tui_runtime::Program::update(&mut full, event()).expect("full input");
assert!(partial.fast_temporal_presentation);
full.presentation_damage = bmux_tui::damage::Damage::Full;
full.fast_temporal_presentation = false;
bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut partial_terminal),
&mut partial,
)
.expect("partial redraw");
bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut full_terminal),
&mut full,
)
.expect("full redraw");
drop((partial, full));
assert_eq!(
partial_terminal.retained_buffer(),
full_terminal.retained_buffer()
);
assert_eq!(partial_terminal.cursor(), full_terminal.cursor());
assert_eq!(partial_terminal.hits(), full_terminal.hits());
}
#[tokio::test]
async fn stable_question_redraw_is_independent_of_unrelated_transcript_length() {
async fn model(history_items: usize) -> super::BcodeRuntimeModel {
let session_id = bcode_session_models::SessionId::new();
let history = (1..=u64::try_from(history_items).unwrap_or(u64::MAX))
.map(|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::UserMessage {
client_id: bcode_session_models::ClientId::new(),
text: format!("unrelated transcript message {sequence}"),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
})
.collect::<Vec<_>>();
let mut model = root_test_model_with_history(session_id, &history);
let mut config = model.chat.app.tui_config().clone();
config.interactions.placement = bcode_config::TuiInteractionPlacement::Pinned;
model.chat.app.apply_tui_config(config);
let keymap = super::super::keymap::BmuxKeyMap::from_config(model.chat.app.tui_config());
model.loop_state.install_interactive_surface_for_test(
question_surface_with_questions_for_root_test(
&keymap,
serde_json::json!([{
"header": null,
"question": "Choose one",
"options": [
{"label": "One", "value": "one", "description": null},
{"label": "Two", "value": "two", "description": "Second choice"}
],
"control": "radio",
"selection_mode": "single",
"custom": false,
"custom_mode": "additional",
"required": true
}]),
)
.await,
);
model
}
let area = bmux_tui::geometry::Rect::new(0, 0, 80, 24);
let mut short = Box::pin(model(0)).await;
let mut long = Box::pin(model(1_000)).await;
let mut short_bytes = Vec::new();
let mut long_bytes = Vec::new();
let mut short_terminal = bmux_tui::terminal::Terminal::new(&mut short_bytes, area);
let mut long_terminal = bmux_tui::terminal::Terminal::new(&mut long_bytes, area);
bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut short_terminal),
&mut short,
)
.expect("short initial frame");
bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut long_terminal),
&mut long,
)
.expect("long initial frame");
let event = || {
bmux_tui_runtime::RuntimeEvent::Terminal(bmux_tui::event::Event::Key(
bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Down,
modifiers: bmux_keyboard::Modifiers::NONE,
},
))
};
bmux_tui_runtime::Program::update(&mut short, event()).expect("short question input");
bmux_tui_runtime::Program::update(&mut long, event()).expect("long question input");
assert!(short.fast_temporal_presentation);
assert!(long.fast_temporal_presentation);
assert_eq!(short.presentation_damage, long.presentation_damage);
drop(short);
drop(long);
}
#[tokio::test]
#[ignore = "manual already-built question interaction committed-presentation latency probe"]
#[allow(clippy::too_many_lines)] async fn question_interaction_to_committed_presentation_latency_report() {
const SAMPLES: usize = 50;
const LOCKED_FRAME_BUDGET: std::time::Duration = std::time::Duration::from_millis(17);
let mut model = root_test_model();
let mut config = model.chat.app.tui_config().clone();
config.interactions.placement = bcode_config::TuiInteractionPlacement::Pinned;
model.chat.app.apply_tui_config(config);
let keymap = super::super::keymap::BmuxKeyMap::from_config(model.chat.app.tui_config());
model
.loop_state
.install_interactive_surface_for_test(question_surface_for_root_test(&keymap).await);
let area = bmux_tui::geometry::Rect::new(0, 0, 80, 24);
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, area);
bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut terminal),
&mut model,
)
.expect("initial question frame");
let request_bytes = serde_json::to_vec(&serde_json::json!({
"questions": [{
"header": null,
"question": "Choose one",
"options": [
{"label": "One", "value": "one", "description": null},
{"label": "Two", "value": "two", "description": "Second choice"}
],
"control": "radio",
"selection_mode": "single",
"custom": false,
"custom_mode": "additional",
"required": true
}]
}))
.expect("question fixture serialization")
.len();
let initial_geometry = model
.loop_state
.active_interactive_surface_geometry()
.expect("committed question geometry");
let initial_height = initial_geometry.logical_height;
let visible_rows = initial_geometry.destination.height;
let mut samples = Vec::with_capacity(SAMPLES);
let mut draw_samples = Vec::with_capacity(SAMPLES);
let mut changed_cells = Vec::with_capacity(SAMPLES);
let mut full_repaints = 0_usize;
for index in 0..SAMPLES {
let started = std::time::Instant::now();
let event = bmux_tui_runtime::RuntimeEvent::Terminal(bmux_tui::event::Event::Key(
bmux_keyboard::KeyStroke {
key: if index.is_multiple_of(2) {
bmux_keyboard::KeyCode::Down
} else {
bmux_keyboard::KeyCode::Up
},
modifiers: bmux_keyboard::Modifiers::NONE,
},
));
bmux_tui_runtime::Program::update(&mut model, event).expect("question input");
let draw_started = std::time::Instant::now();
let report = bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut terminal),
&mut model,
)
.expect("question presentation");
samples.push(started.elapsed());
draw_samples.push(draw_started.elapsed());
changed_cells.push(report.changed_cells);
full_repaints = full_repaints.saturating_add(usize::from(report.full_repaint));
}
let work_shape = model
.loop_state
.active_interactive_surface_work_shape_for_test();
drop(model);
let summary = latency_summary(&samples);
let draw_summary = latency_summary(&draw_samples);
println!(
"{}",
serde_json::json!({
"kind": "bcode_question_interaction_committed_presentation_latency",
"profile": if cfg!(debug_assertions) { "debug" } else { "release" },
"sample_count": SAMPLES,
"locked_p99_budget_ms": LOCKED_FRAME_BUDGET.as_secs_f64() * 1_000.0,
"latency": summary,
"draw_latency": draw_summary,
"work_shape": {
"fixture_request_bytes": request_bytes,
"snapshot_materializations": work_shape.snapshot_materializations,
"copied_request_bytes": request_bytes * usize::try_from(work_shape.snapshot_materializations).unwrap_or(usize::MAX),
"preferred_height_measurements": work_shape.preferred_height_measurements,
"focused_row_measurements": work_shape.focused_row_measurements,
"wrapped_rows_visited": work_shape.wrapped_rows_visited,
"semantic_events": SAMPLES,
"logical_rows": initial_height,
"visible_rows_rendered_per_frame": visible_rows,
"broad_frame_preparations": 0,
"transcript_entries_scanned": 0,
"transcript_entries_rebuilt": 0,
"transcript_rows_regenerated": 0,
"scheduling_delay_ms": 0
},
"changed_cells": changed_cells,
"full_repaints": full_repaints,
})
);
assert!(
summary["p99_ms"].as_f64().expect("numeric p99")
<= LOCKED_FRAME_BUDGET.as_secs_f64() * 1_000.0
);
assert_eq!(full_repaints, 0);
}
#[tokio::test]
#[ignore = "manual inline-question transcript-scroll committed-presentation probe"]
#[allow(clippy::too_many_lines)] async fn inline_question_transcript_scroll_performance_report() {
const SAMPLES: usize = 30;
const LOCKED_FRAME_BUDGET: std::time::Duration = std::time::Duration::from_millis(17);
let session_id = bcode_session_models::SessionId::new();
let history = (1..=1_000)
.map(|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::UserMessage {
client_id: bcode_session_models::ClientId::new(),
text: format!("scroll fixture transcript message {sequence}"),
admission: bcode_session_models::TurnAdmissionMetadata::default(),
},
})
.collect::<Vec<_>>();
let mut model = root_test_model_with_history(session_id, &history);
let mut config = model.chat.app.tui_config().clone();
config.interactions.placement = bcode_config::TuiInteractionPlacement::Transcript;
model.chat.app.apply_tui_config(config);
let questions = (0..20)
.map(|index| {
serde_json::json!({
"header": format!("Question {index}"),
"question": format!(
"Choose an answer for a deliberately long question number {index} while scrolling"
),
"options": [
{"label": "One long answer", "value": "one", "description": "A deliberately long option description that wraps in the inline viewport"},
{"label": "Two long answer", "value": "two", "description": "Another deliberately long option description that wraps in the inline viewport"}
],
"control": "radio",
"selection_mode": "single",
"custom": false,
"custom_mode": "additional",
"required": true
})
})
.collect::<Vec<_>>();
let question_snapshot = serde_json::json!({ "questions": questions });
model.chat.app.set_pending_interactions(vec![
bcode_session_view_models::InteractionViewSummary {
interaction_id: "question-root-test".to_owned(),
producer_id: Some("bcode.question".to_owned()),
exchange_schema: Some("bcode.question.request".to_owned()),
exchange_schema_version: Some(1),
kind: "bcode.question".to_owned(),
tool_call_id: Some("call-question-scroll".to_owned()),
title: Some("Question".to_owned()),
required: true,
snapshot: Some(question_snapshot.clone()),
state: bcode_session_view_models::InteractionViewState::Pending,
status_detail: None,
resolved: false,
resolution: None,
},
]);
let keymap = super::super::keymap::BmuxKeyMap::from_config(model.chat.app.tui_config());
model.loop_state.install_interactive_surface_for_test(
question_surface_with_questions_for_root_test(
&keymap,
question_snapshot["questions"].clone(),
)
.await,
);
let area = bmux_tui::geometry::Rect::new(0, 0, 80, 24);
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, area);
bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut terminal),
&mut model,
)
.expect("initial inline question scroll frame");
let initial_geometry = model.loop_state.active_interactive_surface_geometry();
let surface_work_shape = model
.loop_state
.active_interactive_surface_work_shape_for_test();
let mut total_samples = Vec::with_capacity(SAMPLES);
let mut draw_samples = Vec::with_capacity(SAMPLES);
let mut changed_cells = Vec::with_capacity(SAMPLES);
let mut scroll_offsets = Vec::with_capacity(SAMPLES);
let mut fast_path_frames = 0_usize;
let mut full_repaints = 0_usize;
for index in 0..SAMPLES {
let started = std::time::Instant::now();
let mouse = bmux_tui::event::MouseEvent::new(
if index.is_multiple_of(2) {
bmux_tui::event::MouseEventKind::ScrollUp
} else {
bmux_tui::event::MouseEventKind::ScrollDown
},
bmux_tui::geometry::Point::new(2, 2),
);
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Terminal(bmux_tui::event::Event::Mouse(mouse)),
)
.expect("inline question scroll input");
fast_path_frames =
fast_path_frames.saturating_add(usize::from(model.fast_temporal_presentation));
scroll_offsets.push(model.chat.app.scroll_offset());
let draw_started = std::time::Instant::now();
let report = bmux_tui_runtime::Presenter::present(
&mut super::BcodeRuntimePresenter::new(&mut terminal),
&mut model,
)
.expect("inline question scroll presentation");
total_samples.push(started.elapsed());
draw_samples.push(draw_started.elapsed());
changed_cells.push(report.changed_cells);
full_repaints = full_repaints.saturating_add(usize::from(report.full_repaint));
}
drop(model);
let total = latency_summary(&total_samples);
let draw = latency_summary(&draw_samples);
let within_budget = total["p99_ms"].as_f64().expect("numeric p99")
<= LOCKED_FRAME_BUDGET.as_secs_f64() * 1_000.0;
println!(
"{}",
serde_json::json!({
"kind": "bcode_inline_question_transcript_scroll_performance",
"profile": if cfg!(debug_assertions) { "debug" } else { "release" },
"sample_count": SAMPLES,
"resident_transcript_events": history.len(),
"initial_surface_geometry": format!("{initial_geometry:?}"),
"surface_work_shape": surface_work_shape,
"locked_p99_budget_ms": LOCKED_FRAME_BUDGET.as_secs_f64() * 1_000.0,
"total_latency": total,
"draw_latency": draw,
"changed_cells": changed_cells,
"scroll_offsets": scroll_offsets,
"full_repaints": full_repaints,
"fast_path_frames": fast_path_frames,
"within_budget": within_budget
})
);
}
#[tokio::test]
async fn stable_interaction_redraw_falls_back_without_committed_geometry() {
struct RedrawSurface;
impl bcode_plugin_sdk::tui::PluginTuiSurface for RedrawSurface {
fn id(&self) -> &'static str {
"redraw-test"
}
fn title(&self) -> &'static str {
"Redraw test"
}
fn render(
&mut self,
_area: bmux_tui::geometry::Rect,
_frame: &mut bmux_tui::paint::PaintCx<'_, '_>,
) {
}
fn handle_event(
&mut self,
_event: &bmux_tui::event::Event,
_host: &dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> bcode_plugin_sdk::tui::PluginTuiAction {
bcode_plugin_sdk::tui::PluginTuiAction::Redraw
}
}
let mut model = {
let chat = root_test_chat();
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let mut loop_state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
loop_state.install_interactive_surface_for_test(
super::super::interactive_surface::InteractiveSurfaceState::from_surface_for_test(
"redraw-interaction",
Box::new(RedrawSurface),
settings.keymap(),
),
);
super::BcodeRuntimeModel::new(chat, settings, loop_state)
};
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Terminal(bmux_tui::event::Event::Key(
bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Down,
modifiers: bmux_keyboard::Modifiers::NONE,
},
)),
)
.expect("redraw without committed geometry");
assert!(model.presentation_damage.is_full());
assert!(!model.fast_temporal_presentation);
drop(model);
}
#[tokio::test]
async fn root_submission_queues_runtime_effect_work() {
let mut model = {
let mut chat = root_test_chat();
chat.app.replace_composer_with("hello root runtime");
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let loop_state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
super::BcodeRuntimeModel::new(chat, settings, loop_state)
};
model.chat.app.stage_submission();
model.stage_root_submission(bcode_ipc::PromptPlacement::Steering);
assert_eq!(model.chat.queued_effect_count(), 1);
assert_eq!(model.chat.app.composer().text(), "");
drop(model);
}
#[tokio::test]
async fn root_screen_tracks_owned_routes_and_returns_to_chat() {
let mut model = {
let chat = root_test_chat();
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let loop_state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
super::BcodeRuntimeModel::new(chat, settings, loop_state)
};
model.loop_state.open_command_palette(&mut model.chat);
assert!(model.synchronize_screen().needs_render());
assert_eq!(model.screen, super::BcodeRuntimeScreen::CommandPalette);
let escape = bmux_tui::event::Event::Key(bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Escape,
modifiers: bmux_keyboard::Modifiers::NONE,
});
let _damage = model.route_terminal_event(escape);
assert_eq!(model.screen, super::BcodeRuntimeScreen::Chat);
drop(model);
}
#[tokio::test]
async fn session_scope_keyboard_and_measured_mouse_controls_replace_loads() {
use super::super::effects::TuiEffect;
use bmux_tui::event::{Event, MouseButton, MouseEvent, MouseEventKind};
use bmux_tui::geometry::{Point, Rect};
use bmux_tui_runtime::Presenter as _;
let _scope_guard = super::super::session_picker::SCOPE_TEST_LOCK
.lock()
.unwrap();
let mut model = root_test_model();
model.loop_state.open_session_picker(&mut model.chat);
model
.loop_state
.handle_session_catalog_control(&mut model.chat, "session.scope.local");
let _initial = model.chat.pending_effects.drain_runtime();
let _damage = model.route_terminal_event(Event::Key(bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Tab,
modifiers: bmux_keyboard::Modifiers::NONE,
}));
let (effects, _) = model.chat.pending_effects.drain_runtime();
assert!(effects.iter().any(|(_, effect)| matches!(
effect,
TuiEffect::LoadSessionPicker {
scope: bcode_session_models::SessionDiscoveryScope::AllWorkingDirectories,
..
}
)));
let mut bytes = Vec::new();
let mut terminal = bmux_tui::terminal::Terminal::new(&mut bytes, Rect::new(0, 0, 100, 24));
let _invalidation = model.synchronize_screen();
super::BcodeRuntimePresenter::new(&mut terminal)
.present(&mut model)
.expect("picker");
let hit = model
.committed_hits
.regions()
.iter()
.find(|hit| hit.id.as_str() == "session.scope.local")
.expect("local control");
let point = Point::new(hit.area.x, hit.area.y);
let _damage = model.route_terminal_event(Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
point,
)));
let (effects, _) = model.chat.pending_effects.drain_runtime();
assert!(effects.iter().any(|(_, effect)| matches!(
effect,
TuiEffect::LoadSessionPicker {
scope: bcode_session_models::SessionDiscoveryScope::WorkingDirectory(_),
..
}
)));
drop(model);
}
#[tokio::test]
async fn root_session_picker_is_a_retained_route_and_escape_returns_to_chat() {
let mut model = {
let chat = root_test_chat();
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let loop_state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
super::BcodeRuntimeModel::new(chat, settings, loop_state)
};
model.loop_state.open_session_picker(&mut model.chat);
assert!(model.synchronize_screen().needs_render());
assert_eq!(model.screen, super::BcodeRuntimeScreen::SessionPicker);
let escape = bmux_tui::event::Event::Key(bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Escape,
modifiers: bmux_keyboard::Modifiers::NONE,
});
let _damage = model.route_terminal_event(escape);
assert_eq!(model.screen, super::BcodeRuntimeScreen::Chat);
assert!(!model.loop_state.has_session_picker());
drop(model);
}
#[tokio::test]
async fn root_plugin_session_navigation_retains_and_resumes_surface() {
struct NavigationSurface {
resumed: std::sync::Arc<std::sync::Mutex<Vec<bcode_session_models::SessionId>>>,
}
impl bcode_plugin_sdk::tui::PluginTuiSurface for NavigationSurface {
fn id(&self) -> &'static str {
"test-navigation"
}
fn title(&self) -> &'static str {
"Test Navigation"
}
fn render(
&mut self,
_area: bmux_tui::geometry::Rect,
_frame: &mut bmux_tui::paint::PaintCx<'_, '_>,
) {
}
fn handle_event(
&mut self,
_event: &bmux_tui::event::Event,
_host: &dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> bcode_plugin_sdk::tui::PluginTuiAction {
bcode_plugin_sdk::tui::PluginTuiAction::None
}
fn session_navigation_finished(
&mut self,
session_id: bcode_session_models::SessionId,
result: Result<(), String>,
) {
result.expect("navigation succeeds");
self.resumed.lock().expect("resumed lock").push(session_id);
}
}
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let mut state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
let resumed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
state.queue_root_plugin_surface(
"test.plugin",
Box::new(NavigationSurface {
resumed: std::sync::Arc::clone(&resumed),
}),
);
let session_id = bcode_session_models::SessionId::new();
assert!(state.suspend_root_plugin_surface_for_session(session_id));
assert!(state.root_plugin_surface_is_suspended());
assert!(state.complete_root_plugin_session_navigation(session_id, Ok(())));
assert!(!state.root_plugin_surface_is_suspended());
assert_eq!(
state.active_root_plugin_surface_id(),
Some("test-navigation")
);
assert_eq!(*resumed.lock().expect("resumed lock"), [session_id]);
}
#[tokio::test]
async fn nested_plugin_surface_close_restores_parent_with_workflow_subscription() {
struct TestSurface(&'static str);
impl bcode_plugin_sdk::tui::PluginTuiSurface for TestSurface {
fn id(&self) -> &'static str {
self.0
}
fn title(&self) -> &'static str {
self.0
}
fn render(
&mut self,
_area: bmux_tui::geometry::Rect,
_frame: &mut bmux_tui::paint::PaintCx<'_, '_>,
) {
}
fn handle_event(
&mut self,
_event: &bmux_tui::event::Event,
_host: &dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> bcode_plugin_sdk::tui::PluginTuiAction {
bcode_plugin_sdk::tui::PluginTuiAction::None
}
}
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let mut state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
state.queue_root_plugin_surface("bcode.workflow", Box::new(TestSurface("workflow.status")));
let (_update_sender, updates) = tokio::sync::mpsc::channel(1);
let (request_sender, mut requests) = tokio::sync::mpsc::channel(1);
state.attach_root_plugin_surface_updates(updates, request_sender);
state.queue_root_plugin_surface("bcode.workflow", Box::new(TestSurface("workflow.author")));
assert_eq!(
state.active_root_plugin_surface_id(),
Some("workflow.author")
);
let closed = state
.close_root_plugin_surface_with_outcome(None)
.expect("authoring surface closes");
assert_eq!(closed.0, "bcode.workflow");
assert_eq!(
state.active_root_plugin_surface_id(),
Some("workflow.status")
);
state.request_root_workflow_run("run-7".to_string());
assert!(matches!(
requests.try_recv(),
Ok(super::super::chat_loop::WorkflowViewRequest::SelectRun(run_id))
if run_id == "run-7"
));
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn managed_runtime_commits_progressive_filesystem_write_frames_without_external_wakeup() {
let session_id = bcode_session_models::SessionId::new();
let (runtime, handle) = {
let mut model = root_test_model();
model.chat.mark_attached(session_id);
model.chat.app =
super::super::app::BmuxApp::new_with_history(Some(session_id), &[], &[], false);
model
.chat
.app
.set_plugin_host(std::sync::Arc::new(root_filesystem_plugin_host()));
let config = bmux_tui_runtime::RuntimeConfig {
frame_interval: None,
..bmux_tui_runtime::RuntimeConfig::default()
};
bmux_tui_runtime::Runtime::new(model, RecordingRootPresenter::new(), config)
};
let draft = |revision, text: &str| {
super::super::history_flow::SessionStreamUpdate::Event(Box::new(
bcode_ipc::Event::SessionLive(bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::ToolRequestDraft {
event: bcode_session_models::ToolRequestDraftEvent {
output_position: None,
turn_id: "turn-live".to_owned(),
tool_call_id: "call-write".to_owned(),
tool_name: "filesystem.write".to_owned(),
producer_plugin_id: Some("bcode.filesystem".to_owned()),
schema: "bcode.filesystem.request-draft.write".to_owned(),
schema_version: 1,
placement: bcode_session_models::ToolContributionPlacement::Request,
generation: 1,
revision,
operation:
bcode_session_models::ToolRequestDraftOperation::Checkpoint {
start_offset: 0,
text: text.to_owned(),
},
argument_bytes: text.len(),
truncated: false,
},
},
}),
))
};
let durable = |sequence, kind| {
super::super::history_flow::SessionStreamUpdate::Event(Box::new(
bcode_ipc::Event::Session(bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind,
}),
))
};
for update in [
draft(1, r#"{"path":"src/lib.rs","contents":"first"}"#),
draft(2, r#"{"path":"src/lib.rs","contents":"first second"}"#),
durable(
1,
bcode_session_models::SessionEventKind::ToolCallRequested {
tool_call_id: "call-write".to_owned(),
producer_plugin_id: Some("bcode.filesystem".to_owned()),
tool_name: "filesystem.write".to_owned(),
arguments_json: r#"{"path":"src/lib.rs","contents":"first second"}"#.to_owned(),
working_directory: None,
},
),
durable(
2,
bcode_session_models::SessionEventKind::ToolInvocationResultRecorded {
record: bcode_session_models::ToolInvocationResultRecord {
invocation_id: "call-write".to_owned(),
model_output: "wrote 12 bytes".to_owned(),
is_error: false,
presentation: None,
result: None,
content: Vec::new(),
},
},
),
] {
assert!(
handle
.try_send(super::BcodeRuntimeMessage::SessionStream(Box::new(update)))
.is_ok(),
"session update fits"
);
}
let runtime_task = tokio::spawn(runtime.run());
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while handle.stats().frames_presented < 4 {
tokio::task::yield_now().await;
}
})
.await
.expect("both draft and canonical handoff frames commit without external wakeup");
assert_eq!(handle.stats().frames_presented, 4);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert_eq!(
handle.stats().frames_presented,
4,
"runtime remains idle after the handoff queue drains"
);
handle
.try_send_terminal(bmux_tui::event::Event::Key(
bmux_keyboard::KeyStroke::with_modifiers(
bmux_keyboard::KeyCode::Char('d'),
bmux_keyboard::Modifiers {
ctrl: true,
..bmux_keyboard::Modifiers::NONE
},
),
))
.expect("exit input fits");
let output = tokio::time::timeout(std::time::Duration::from_secs(2), runtime_task)
.await
.expect("managed runtime exits")
.expect("runtime task joins")
.unwrap_or_else(|_| panic!("managed runtime succeeds"));
let first = output
.presenter
.frames
.iter()
.position(|frame| frame.text.contains("first") && !frame.text.contains("first second"))
.expect("first draft committed");
let second = output
.presenter
.frames
.iter()
.position(|frame| frame.text.contains("first second"))
.expect("second draft committed");
let result = output
.presenter
.frames
.iter()
.position(|frame| frame.text.contains("wrote 12 bytes"))
.expect("canonical result committed");
assert!(
first < second && second < result,
"frames: {:?}",
output.presenter.frames
);
assert_eq!(
output.presenter.frames[result]
.text
.matches("wrote 12 bytes")
.count(),
1
);
assert!(!output.presenter.frames[result].text.contains("assembling"));
assert!(
output
.presenter
.frames
.iter()
.all(|frame| frame.invocation_primary_items <= 1),
"more than one primary invocation item was rendered"
);
let invocation_ids = output
.presenter
.frames
.iter()
.flat_map(|frame| frame.invocation_item_ids.iter())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
invocation_ids.len(),
1,
"invocation identity changed across frames"
);
assert_eq!(output.stats.frames_presented, 5);
drop(output);
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn managed_runtime_commits_progressive_filesystem_edit_frames_without_external_wakeup() {
let session_id = bcode_session_models::SessionId::new();
let (runtime, handle) = {
let mut model = root_test_model();
model.chat.mark_attached(session_id);
model.chat.app =
super::super::app::BmuxApp::new_with_history(Some(session_id), &[], &[], false);
model
.chat
.app
.set_plugin_host(std::sync::Arc::new(root_filesystem_plugin_host()));
bmux_tui_runtime::Runtime::new(
model,
RecordingRootPresenter::new(),
bmux_tui_runtime::RuntimeConfig {
frame_interval: None,
..bmux_tui_runtime::RuntimeConfig::default()
},
)
};
let draft = |revision, operation, argument_bytes| {
super::super::history_flow::SessionStreamUpdate::Event(Box::new(
bcode_ipc::Event::SessionLive(bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::ToolRequestDraft {
event: bcode_session_models::ToolRequestDraftEvent {
output_position: None,
turn_id: "turn-edit".to_owned(),
tool_call_id: "call-edit".to_owned(),
tool_name: "filesystem.edit".to_owned(),
producer_plugin_id: Some("bcode.filesystem".to_owned()),
schema: "bcode.filesystem.request-draft.edit".to_owned(),
schema_version: 1,
placement: bcode_session_models::ToolContributionPlacement::Request,
generation: 1,
revision,
operation,
argument_bytes,
truncated: false,
},
},
}),
))
};
let durable = |sequence, kind| {
super::super::history_flow::SessionStreamUpdate::Event(Box::new(
bcode_ipc::Event::Session(bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind,
}),
))
};
let fragments = [
r#"{"path":"src/lib.rs","#,
r#""old_text":"before""#,
r#", "new_text":"aft"#,
r#"er two"}"#,
];
let mut offset: usize = 0;
let mut revision: u64 = 1;
let mut updates = Vec::new();
updates.push(draft(
revision,
bcode_session_models::ToolRequestDraftOperation::Checkpoint {
start_offset: 0,
text: String::new(),
},
0,
));
for fragment in fragments {
revision = revision.saturating_add(1);
offset = offset.saturating_add(fragment.len());
updates.push(draft(
revision,
bcode_session_models::ToolRequestDraftOperation::Append {
offset: offset.saturating_sub(fragment.len()),
text: fragment.to_owned(),
},
offset,
));
}
updates.extend([
durable(
1,
bcode_session_models::SessionEventKind::ToolCallRequested {
tool_call_id: "call-edit".to_owned(),
producer_plugin_id: Some("bcode.filesystem".to_owned()),
tool_name: "filesystem.edit".to_owned(),
arguments_json:
r#"{"path":"src/lib.rs","old_text":"before","new_text":"after two"}"#
.to_owned(),
working_directory: None,
},
),
durable(
2,
bcode_session_models::SessionEventKind::ToolInvocationResultRecorded {
record: bcode_session_models::ToolInvocationResultRecord {
invocation_id: "call-edit".to_owned(),
model_output: "edited src/lib.rs".to_owned(),
is_error: false,
presentation: None,
result: None,
content: Vec::new(),
},
},
),
]);
for update in updates {
assert!(
handle
.try_send(super::BcodeRuntimeMessage::SessionStream(Box::new(update)))
.is_ok(),
"session update fits"
);
}
let runtime_task = tokio::spawn(runtime.run());
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while handle.stats().frames_presented < 7 {
tokio::task::yield_now().await;
}
})
.await
.expect("fragmented edit drafts and canonical handoff commit without external wakeup");
assert_eq!(handle.stats().frames_presented, 7);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert_eq!(
handle.stats().frames_presented,
7,
"runtime remains idle after the edit handoff queue drains"
);
handle
.try_send_terminal(bmux_tui::event::Event::Key(
bmux_keyboard::KeyStroke::with_modifiers(
bmux_keyboard::KeyCode::Char('d'),
bmux_keyboard::Modifiers {
ctrl: true,
..bmux_keyboard::Modifiers::NONE
},
),
))
.expect("exit input fits");
let output = tokio::time::timeout(std::time::Duration::from_secs(2), runtime_task)
.await
.expect("managed runtime exits")
.expect("runtime task joins")
.unwrap_or_else(|_| panic!("managed runtime succeeds"));
let original = output
.presenter
.frames
.iter()
.position(|frame| {
frame.text.contains("receiving original text")
&& frame.text.contains("before")
&& !frame.text.contains("new_text")
})
.expect("original edit text committed before replacement starts");
let partial = output
.presenter
.frames
.iter()
.position(|frame| frame.text.contains("aft") && !frame.text.contains("after two"))
.expect("partial replacement committed");
let complete = output
.presenter
.frames
.iter()
.position(|frame| frame.text.contains("after two"))
.expect("complete replacement committed");
let result = output
.presenter
.frames
.iter()
.position(|frame| frame.text.contains("edited src/lib.rs"))
.expect("canonical edit result committed");
assert!(
original < partial && partial < complete && complete < result,
"frames: {:?}",
output.presenter.frames
);
assert_eq!(
output.presenter.frames[result]
.text
.matches("edited src/lib.rs")
.count(),
1
);
assert!(!output.presenter.frames[result].text.contains("assembling"));
assert!(
output
.presenter
.frames
.iter()
.all(|frame| frame.invocation_primary_items <= 1),
"more than one primary invocation item was rendered"
);
let invocation_ids = output
.presenter
.frames
.iter()
.flat_map(|frame| frame.invocation_item_ids.iter())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
invocation_ids.len(),
1,
"invocation identity changed across frames"
);
assert_eq!(output.stats.frames_presented, 8);
drop(output);
}
#[tokio::test]
async fn root_request_draft_handoff_waits_for_committed_paint() {
let session_id = bcode_session_models::SessionId::new();
let mut model = root_test_model();
model.chat.mark_attached(session_id);
model.chat.app =
super::super::app::BmuxApp::new_with_history(Some(session_id), &[], &[], false);
let draft = |revision, text: &str| {
super::super::history_flow::SessionStreamUpdate::Event(Box::new(
bcode_ipc::Event::SessionLive(bcode_session_models::SessionLiveEvent {
session_id,
kind: bcode_session_models::SessionLiveEventKind::ToolRequestDraft {
event: bcode_session_models::ToolRequestDraftEvent {
output_position: None,
turn_id: "turn-live".to_owned(),
tool_call_id: "call-write".to_owned(),
tool_name: "filesystem.write".to_owned(),
producer_plugin_id: Some("bcode.filesystem".to_owned()),
schema: "bcode.filesystem.request-draft.write".to_owned(),
schema_version: 1,
placement: bcode_session_models::ToolContributionPlacement::Request,
generation: 1,
revision,
operation:
bcode_session_models::ToolRequestDraftOperation::Checkpoint {
start_offset: 0,
text: text.to_owned(),
},
argument_bytes: text.len(),
truncated: false,
},
},
}),
))
};
for update in [draft(1, ""), draft(2, r#"{"path":"live.txt""#)] {
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::SessionStream(
Box::new(update),
)),
)
.expect("root update");
}
let first = model.chat.app.session_view_snapshot().tools["call-write"]
.request_draft
.as_ref()
.expect("first draft awaits presentation");
assert!(first.preview.is_empty());
assert_eq!(
bmux_tui_runtime::Program::presentation_committed(
&mut model,
bmux_tui_runtime::PresentReport::default(),
)
.invalidation,
bmux_tui_runtime::Invalidation::Redraw
);
let second = model.chat.app.session_view_snapshot().tools["call-write"]
.request_draft
.as_ref()
.expect("deferred draft applied");
assert_eq!(second.preview, r#"{"path":"live.txt""#);
}
#[tokio::test]
async fn ralph_surface_effect_updates_root_screen_state() {
struct RalphSurface;
impl bcode_plugin_sdk::tui::PluginTuiSurface for RalphSurface {
fn id(&self) -> &'static str {
"ralph-home"
}
fn title(&self) -> &'static str {
"Ralph"
}
fn render(
&mut self,
_area: bmux_tui::geometry::Rect,
_frame: &mut bmux_tui::paint::PaintCx<'_, '_>,
) {
}
fn handle_event(
&mut self,
_event: &bmux_tui::event::Event,
_host: &dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> bcode_plugin_sdk::tui::PluginTuiAction {
bcode_plugin_sdk::tui::PluginTuiAction::None
}
}
let mut model = {
let chat = root_test_chat();
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
super::BcodeRuntimeModel::new(
chat,
settings,
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false),
)
};
bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Message(super::BcodeRuntimeMessage::EffectCompleted(
Box::new(
super::super::effects::TuiEffectResult::PluginSurfaceOpened {
plugin_id: "bcode.ralph".to_owned(),
result: Ok(Box::new(RalphSurface)),
},
),
)),
)
.expect("root update");
assert!(model.has_plugin_surface());
assert_eq!(model.screen, super::BcodeRuntimeScreen::PluginSurface);
drop(model);
}
#[tokio::test]
async fn temporary_plugin_navigation_restores_root_route() {
struct NavigationSurface;
impl bcode_plugin_sdk::tui::PluginTuiSurface for NavigationSurface {
fn id(&self) -> &'static str {
"test-navigation"
}
fn title(&self) -> &'static str {
"Test Navigation"
}
fn render(
&mut self,
_area: bmux_tui::geometry::Rect,
_frame: &mut bmux_tui::paint::PaintCx<'_, '_>,
) {
}
fn handle_event(
&mut self,
_event: &bmux_tui::event::Event,
_host: &dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> bcode_plugin_sdk::tui::PluginTuiAction {
bcode_plugin_sdk::tui::PluginTuiAction::None
}
}
let mut model = {
let chat = root_test_chat();
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let loop_state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
super::BcodeRuntimeModel::new(chat, settings, loop_state)
};
model.queue_plugin_surface("test.plugin", Box::new(NavigationSurface));
let session_id = bcode_session_models::SessionId::new();
model.suspend_screen_for_session_navigation();
assert!(
model
.loop_state
.suspend_root_plugin_surface_for_session(session_id)
);
assert_eq!(model.screen, super::BcodeRuntimeScreen::Chat);
model.finish_plugin_session_attach(session_id, Ok(()));
let _damage = model.synchronize_screen();
assert_eq!(model.screen, super::BcodeRuntimeScreen::Chat);
assert!(model.loop_state.root_plugin_surface_is_suspended());
assert!(model.loop_state.root_plugin_session_is_attached());
assert!(!model.chat.app.should_exit());
let _update = bmux_tui_runtime::Program::update(
&mut model,
bmux_tui_runtime::RuntimeEvent::Terminal(bmux_tui::event::Event::Key(
bmux_keyboard::KeyStroke {
key: bmux_keyboard::KeyCode::Char('d'),
modifiers: bmux_keyboard::Modifiers {
ctrl: true,
..bmux_keyboard::Modifiers::NONE
},
},
)),
)
.expect("borrowed chat return is admitted without setting exit");
assert_eq!(model.screen, super::BcodeRuntimeScreen::PluginSurface);
assert!(!model.loop_state.root_plugin_surface_is_suspended());
assert!(!model.loop_state.root_plugin_session_is_attached());
assert!(!model.chat.app.should_exit());
drop(model);
}
struct ShutdownSurface {
release: Option<tokio::sync::oneshot::Receiver<()>>,
commits: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl bcode_plugin_sdk::tui::PluginTuiSurface for ShutdownSurface {
fn id(&self) -> &'static str {
"shutdown-test"
}
fn title(&self) -> &'static str {
"Shutdown"
}
fn render(
&mut self,
_area: bmux_tui::geometry::Rect,
_frame: &mut bmux_tui::paint::PaintCx<'_, '_>,
) {
}
fn handle_event(
&mut self,
_event: &bmux_tui::event::Event,
_host: &dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> bcode_plugin_sdk::tui::PluginTuiAction {
bcode_plugin_sdk::tui::PluginTuiAction::None
}
fn drain_effects<'a>(
&'a mut self,
_host: &'a dyn bcode_plugin_sdk::tui::PluginTuiHost,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = bcode_plugin_sdk::tui::PluginTuiAction>
+ Send
+ 'a,
>,
> {
Box::pin(async move {
self.release
.take()
.expect("accepted operation")
.await
.expect("release");
self.commits
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
bcode_plugin_sdk::tui::PluginTuiAction::Close {
outcome: Some(serde_json::json!({"settled": true})),
}
})
}
}
#[tokio::test]
async fn root_shutdown_waits_for_plugin_results_on_normal_and_fatal_exit() {
for exit in ["normal", "program", "presenter"] {
let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (release, receiver) = tokio::sync::oneshot::channel();
let mut outcome = Some({
let mut retained_model = Some(root_test_model());
{
let model = retained_model.as_mut().expect("fixture model available");
model.queue_plugin_surface(
"test",
Box::new(ShutdownSurface {
release: Some(receiver),
commits: commits.clone(),
}),
);
let client = bcode_client::BcodeClient::default_endpoint();
let launch = model.fresh_session_settings();
let _action = model.loop_state.poll_root_plugin_surface(&client, launch);
}
let output = bmux_tui_runtime::RuntimeOutput {
program: retained_model
.take()
.expect("fixture model transfers to runtime"),
presenter: (),
stats: bmux_tui_runtime::RuntimeStats::default(),
};
drop(retained_model);
match exit {
"program" => Err(bmux_tui_runtime::RuntimeError::Program {
error: std::io::Error::other("terminal input failed").into(),
output,
}),
"presenter" => Err(bmux_tui_runtime::RuntimeError::Presenter {
error: std::io::Error::other("terminal writer failed").into(),
output,
}),
_ => Ok(output),
}
});
let finish = super::finish_runtime_result(
outcome
.take()
.expect("runtime outcome transfers to settlement"),
);
drop(outcome);
tokio::pin!(finish);
assert!(
tokio::time::timeout(std::time::Duration::from_millis(10), &mut finish)
.await
.is_err()
);
assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 0);
release.send(()).expect("accepted operation still owned");
let result = finish.await;
assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 1);
if exit == "normal" {
let mut model = result.expect("normal shutdown observes outcome");
assert_eq!(
model.take_plugin_surface_result(),
Some((
"test".to_string(),
Some(serde_json::json!({"settled": true}))
))
);
drop(model);
} else {
assert!(result.is_err());
drop(result);
}
}
}
#[tokio::test]
async fn standalone_plugin_surface_close_exits_with_owned_outcome() {
let mut model = {
let chat = root_test_chat();
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let loop_state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
super::BcodeRuntimeModel::new(chat, settings, loop_state)
};
let expected = serde_json::json!({"selected": "workspace"});
model.exit_after_plugin_surface = true;
let invalidation = model.apply_plugin_surface_close("test.plugin", Some(expected.clone()));
assert!(invalidation.needs_render());
assert!(model.exit_requested);
assert_eq!(
model.take_plugin_surface_result(),
Some(("test.plugin".to_owned(), Some(expected)))
);
drop(model);
}
#[tokio::test]
async fn root_ralph_local_action_returns_to_chat_without_generic_outcome_decode() {
let mut model = {
let chat = root_test_chat();
let settings = super::super::chat_loop::TuiRuntimeSettings::bootstrap(
std::path::PathBuf::from("."),
&[],
);
let client = bcode_client::BcodeClient::default_endpoint();
let passive_client = client
.clone()
.with_daemon_availability(bcode_client::DaemonAvailability::RequireRunning);
let loop_state =
super::super::chat_loop::ChatLoopState::new(&client, &passive_client, false);
super::BcodeRuntimeModel::new(chat, settings, loop_state)
};
let invalidation = model.apply_plugin_surface_close(
"bcode.ralph",
Some(serde_json::json!({"ralph_action": "open"})),
);
assert!(invalidation.needs_render());
assert!(model.chat.app.status().contains("no Ralph loops"));
drop(model);
}
#[derive(Default)]
struct LatencyAcceptanceProgram {
application_messages: usize,
terminal_latency: Option<std::time::Duration>,
timer_latency: Option<std::time::Duration>,
probe_started: std::sync::Arc<std::sync::Mutex<Option<std::time::Instant>>>,
runtime_active: std::sync::Arc<tokio::sync::Notify>,
}
#[derive(Default)]
struct CommittedLatencyProgram;
impl bmux_tui_runtime::Program for CommittedLatencyProgram {
type Message = ();
type Error = Infallible;
fn update(
&mut self,
_event: bmux_tui_runtime::RuntimeEvent<Self::Message>,
) -> Result<bmux_tui_runtime::Update<Self::Message>, Self::Error> {
Ok(bmux_tui_runtime::Update::redraw().merge(bmux_tui_runtime::Update::exit()))
}
}
struct CommittedLatencyPresenter {
admitted_at: std::time::Instant,
committed_latency: std::sync::Arc<std::sync::Mutex<Option<std::time::Duration>>>,
}
impl bmux_tui_runtime::Presenter<CommittedLatencyProgram> for CommittedLatencyPresenter {
type Error = Infallible;
fn reset(&mut self, _reason: bmux_tui_runtime::ResetReason) {}
fn present(
&mut self,
_program: &mut CommittedLatencyProgram,
) -> Result<bmux_tui_runtime::PresentReport, Self::Error> {
*self.committed_latency.lock().expect("latency lock") =
Some(self.admitted_at.elapsed());
Ok(bmux_tui_runtime::PresentReport {
changed_cells: 1,
full_repaint: false,
})
}
}
async fn committed_latency_sample(terminal: bool) -> std::time::Duration {
let committed_latency = std::sync::Arc::new(std::sync::Mutex::new(None));
let admitted_at = std::time::Instant::now();
let (runtime, handle) = bmux_tui_runtime::Runtime::new(
CommittedLatencyProgram,
CommittedLatencyPresenter {
admitted_at,
committed_latency: std::sync::Arc::clone(&committed_latency),
},
bmux_tui_runtime::RuntimeConfig {
frame_interval: None,
messages_per_turn: 1,
..bmux_tui_runtime::RuntimeConfig::default()
},
);
if terminal {
handle
.try_send_terminal(bmux_tui::event::Event::Tick)
.expect("terminal admitted");
} else {
handle.schedule_timer(
bmux_tui_runtime::TimerId::new("product-latency-probe"),
admitted_at,
);
}
runtime
.run()
.await
.unwrap_or_else(|_| panic!("latency probe runtime succeeds"));
let latency = *committed_latency.lock().expect("latency lock");
latency.expect("presentation committed")
}
fn latency_summary(samples: &[std::time::Duration]) -> serde_json::Value {
let mut values = samples
.iter()
.map(|duration| duration.as_secs_f64() * 1_000.0)
.collect::<Vec<_>>();
values.sort_by(f64::total_cmp);
let percentile = |percent: usize| {
let rank = values.len().saturating_mul(percent).div_ceil(100);
values[rank.saturating_sub(1).min(values.len() - 1)]
};
serde_json::json!({
"samples_ms": values,
"min_ms": values[0],
"p50_ms": percentile(50),
"p95_ms": percentile(95),
"p99_ms": percentile(99),
"max_ms": values[values.len() - 1],
})
}
#[tokio::test]
#[ignore = "manual already-built product latency distribution probe"]
async fn product_input_and_timer_to_committed_presentation_latency_report() {
const SAMPLES: usize = 50;
const LOCKED_P99_BUDGET: std::time::Duration = std::time::Duration::from_millis(100);
let mut terminal_samples = Vec::with_capacity(SAMPLES);
let mut timer_samples = Vec::with_capacity(SAMPLES);
for _ in 0..SAMPLES {
terminal_samples.push(committed_latency_sample(true).await);
timer_samples.push(committed_latency_sample(false).await);
}
let terminal = latency_summary(&terminal_samples);
let timer = latency_summary(&timer_samples);
println!(
"BCODE_PERF_CASE {}",
serde_json::json!({
"domain": "product_committed_presentation_latency",
"profile": "test",
"sample_count": SAMPLES,
"locked_p99_budget_ms": LOCKED_P99_BUDGET.as_millis(),
"terminal": terminal,
"timer": timer,
})
);
for (name, samples) in [("terminal", terminal_samples), ("timer", timer_samples)] {
let mut samples = samples;
samples.sort_unstable();
let p99 = samples[SAMPLES.saturating_mul(99).div_ceil(100) - 1];
assert!(
p99 <= LOCKED_P99_BUDGET,
"{name} p99 {p99:?} exceeded {LOCKED_P99_BUDGET:?}"
);
}
}
impl bmux_tui_runtime::Program for LatencyAcceptanceProgram {
type Message = u64;
type Error = Infallible;
fn update(
&mut self,
event: bmux_tui_runtime::RuntimeEvent<Self::Message>,
) -> Result<bmux_tui_runtime::Update<Self::Message>, Self::Error> {
self.runtime_active.notify_one();
let probe_started = *self.probe_started.lock().expect("probe lock");
match event {
bmux_tui_runtime::RuntimeEvent::Message(_) => {
self.application_messages = self.application_messages.saturating_add(1);
}
bmux_tui_runtime::RuntimeEvent::Terminal(_) => {
self.terminal_latency = probe_started.map(|started| started.elapsed());
}
bmux_tui_runtime::RuntimeEvent::Timer(_) => {
self.timer_latency = probe_started.map(|started| started.elapsed());
}
}
Ok(
if self.application_messages == 10_000
&& self.terminal_latency.is_some()
&& self.timer_latency.is_some()
{
bmux_tui_runtime::Update::exit()
} else {
bmux_tui_runtime::Update::none()
},
)
}
}
#[tokio::test]
#[ignore = "run directly outside parallel test-suite contention via capture-tui-product-latency.sh"]
async fn terminal_and_timer_latency_stay_within_flood_acceptance_budget() {
const ACCEPTANCE_BUDGET: std::time::Duration = std::time::Duration::from_millis(100);
let probe_started = std::sync::Arc::new(std::sync::Mutex::new(None));
let runtime_active = std::sync::Arc::new(tokio::sync::Notify::new());
let config = bmux_tui_runtime::RuntimeConfig {
reliable_capacity: 20_000,
messages_per_turn: 4,
processing_time_per_turn: std::time::Duration::from_millis(1),
frame_interval: None,
..bmux_tui_runtime::RuntimeConfig::default()
};
let (runtime, handle) = bmux_tui_runtime::Runtime::new(
LatencyAcceptanceProgram {
probe_started: std::sync::Arc::clone(&probe_started),
runtime_active: std::sync::Arc::clone(&runtime_active),
..LatencyAcceptanceProgram::default()
},
bmux_tui_runtime::HeadlessPresenter::default(),
config,
);
for value in 0..10_000 {
handle.try_send(value).expect("configured flood fits");
}
let runtime_task = tokio::spawn(runtime.run());
runtime_active.notified().await;
*probe_started.lock().expect("probe lock") = Some(std::time::Instant::now());
handle
.try_send_terminal(bmux_tui::event::Event::Tick)
.expect("terminal admission remains independent");
handle.schedule_timer(
bmux_tui_runtime::TimerId::new("acceptance-latency"),
std::time::Instant::now(),
);
let output = runtime_task
.await
.expect("runtime task joins")
.unwrap_or_else(|_| panic!("runtime acceptance succeeds"));
let terminal_latency = output.program.terminal_latency.expect("terminal delivered");
let timer_latency = output.program.timer_latency.expect("timer delivered");
assert!(
terminal_latency <= ACCEPTANCE_BUDGET,
"terminal latency {terminal_latency:?} exceeded {ACCEPTANCE_BUDGET:?}"
);
assert!(
timer_latency <= ACCEPTANCE_BUDGET,
"timer latency {timer_latency:?} exceeded {ACCEPTANCE_BUDGET:?}"
);
}
#[derive(Default)]
struct AdmissionProgram {
received: usize,
}
impl bmux_tui_runtime::Program for AdmissionProgram {
type Message = BcodeRuntimeMessage;
type Error = Infallible;
fn update(
&mut self,
event: bmux_tui_runtime::RuntimeEvent<Self::Message>,
) -> Result<bmux_tui_runtime::Update<Self::Message>, Self::Error> {
if matches!(event, bmux_tui_runtime::RuntimeEvent::Message(_)) {
self.received += 1;
}
Ok(if self.received == 3 {
bmux_tui_runtime::Update::exit()
} else {
bmux_tui_runtime::Update::none()
})
}
}
#[tokio::test]
async fn streaming_configurator_apply_cancel_and_reset_own_input_and_effects() {
let mut apply = root_test_model();
let apply_keymap =
super::super::keymap::BmuxKeyMap::from_config(apply.chat.app.tui_config());
apply
.loop_state
.open_streaming_configurator(&mut apply.chat);
assert!(apply.loop_state.handle_streaming_configurator_key(
&mut apply.chat,
bmux_keyboard::KeyStroke::simple(bmux_keyboard::KeyCode::Char('x')),
&apply_keymap,
));
assert!(apply.loop_state.handle_streaming_configurator_key(
&mut apply.chat,
bmux_keyboard::KeyStroke::simple(bmux_keyboard::KeyCode::Enter),
&apply_keymap,
));
assert!(apply.chat.pending_effects.contains_effect(
&super::super::effects::TuiEffect::PersistStreamingPresentation {
policy: bcode_session_view_models::StreamingPresentationPolicy::default(),
},
));
assert!(
apply
.loop_state
.streaming_configurator_deadline(std::time::Instant::now())
.is_none()
);
drop(apply);
let mut cancel = root_test_model();
let cancel_keymap =
super::super::keymap::BmuxKeyMap::from_config(cancel.chat.app.tui_config());
cancel
.loop_state
.open_streaming_configurator(&mut cancel.chat);
assert!(cancel.loop_state.handle_streaming_configurator_key(
&mut cancel.chat,
bmux_keyboard::KeyStroke::simple(bmux_keyboard::KeyCode::Escape),
&cancel_keymap,
));
assert_eq!(cancel.chat.queued_effect_count(), 0);
drop(cancel);
let mut reset = root_test_model();
let reset_keymap =
super::super::keymap::BmuxKeyMap::from_config(reset.chat.app.tui_config());
reset
.loop_state
.open_streaming_configurator(&mut reset.chat);
for _ in 0..4 {
assert!(reset.loop_state.handle_streaming_configurator_key(
&mut reset.chat,
bmux_keyboard::KeyStroke::simple(bmux_keyboard::KeyCode::Down),
&reset_keymap,
));
}
assert!(reset.loop_state.handle_streaming_configurator_key(
&mut reset.chat,
bmux_keyboard::KeyStroke::simple(bmux_keyboard::KeyCode::Space),
&reset_keymap,
));
assert!(reset.loop_state.handle_streaming_configurator_key(
&mut reset.chat,
bmux_keyboard::KeyStroke::simple(bmux_keyboard::KeyCode::Enter),
&reset_keymap,
));
assert!(
reset
.chat
.pending_effects
.contains_effect(&super::super::effects::TuiEffect::ClearStreamingPresentation,)
);
drop(reset);
}
#[tokio::test]
async fn streaming_configurator_deadline_stops_on_pause_and_close() {
let mut model = root_test_model();
let model_keymap =
super::super::keymap::BmuxKeyMap::from_config(model.chat.app.tui_config());
model
.loop_state
.open_streaming_configurator(&mut model.chat);
let now = std::time::Instant::now();
assert!(
model
.loop_state
.streaming_configurator_deadline(now)
.is_some()
);
assert!(model.loop_state.handle_streaming_configurator_key(
&mut model.chat,
bmux_keyboard::KeyStroke::simple(bmux_keyboard::KeyCode::Char('p')),
&model_keymap,
));
assert!(
model
.loop_state
.streaming_configurator_deadline(now)
.is_none()
);
assert!(model.loop_state.handle_streaming_configurator_key(
&mut model.chat,
bmux_keyboard::KeyStroke::simple(bmux_keyboard::KeyCode::Escape),
&model_keymap,
));
assert!(
model
.loop_state
.streaming_configurator_deadline(now)
.is_none()
);
drop(model);
}
#[tokio::test]
async fn domain_owned_admission_separates_reliable_and_latest_messages() {
let config = bmux_tui_runtime::RuntimeConfig {
frame_interval: None,
..bmux_tui_runtime::RuntimeConfig::default()
};
let (runtime, handle) = bmux_tui_runtime::Runtime::new(
AdmissionProgram::default(),
bmux_tui_runtime::HeadlessPresenter::default(),
config,
);
admit(&handle, BcodeRuntimeMessage::DraftSaveDue)
.await
.expect("reliable message admitted");
admit(&handle, BcodeRuntimeMessage::StreamingPresentationDue)
.await
.expect("latest message admitted");
admit(&handle, BcodeRuntimeMessage::StreamingConfiguratorDue)
.await
.expect("configurator latest message admitted");
let output = runtime
.run()
.await
.unwrap_or_else(|_| panic!("runtime succeeds"));
assert_eq!(output.program.received, 3);
assert_eq!(output.stats.reliable_processed, 1);
assert_eq!(output.stats.latest_processed, 2);
assert_ne!(
BcodeRuntimeAdmissionError::Full,
BcodeRuntimeAdmissionError::Closed
);
}
}