mod activity_motion;
#[cfg(test)]
pub(crate) use super::prompt_editor::MAX_PROMPT_BYTES;
pub(crate) use super::prompt_editor::{
PROMPT_MOVEMENT_FAILED_STATUS, PROMPT_TOO_LARGE_STATUS, PromptEditResult, PromptEditor,
PromptMovementOutcome, PromptTextNormalizer, normalize_prompt_text_bounded,
};
use super::{
activity::{self, ActivityNode},
autocomplete,
selection::{ActiveSelection, TextPosition},
toast::{COPIED_TO_CLIPBOARD, ToastSeverity, ToastState},
transcript,
};
#[cfg(test)]
use crate::output::ActivityEvent;
use crate::output::{
ActivityId, ActivityKind, ActivityMetadata, ActivityStatus, ContextUsageSource, OutputEvent,
StreamingRedactor, redact_sensitive_text,
};
use crate::rendering::DisplayLine;
#[cfg(test)]
use crate::rendering::plain_projection;
use crate::tui::{
PADDED_INLINE_SEPARATOR, layout::TuiPane, normalize_inline_separators,
theme::MissionControlTheme,
};
use chrono::{DateTime, Local};
use ratatui::layout::Position;
#[cfg(test)]
use ratatui::text::Line;
use std::cell::{Cell, Ref, RefCell};
use std::collections::{HashMap, HashSet, VecDeque};
use std::time::Instant;
pub(crate) use super::activity::activity_status_mark;
#[cfg(test)]
pub(crate) use super::activity::compact_activity_metadata;
pub(crate) use super::transcript::{TranscriptEntries, TranscriptRenderCache};
#[cfg(test)]
use super::transcript::{ratatui_wrapped_visual_rows, sanitize_preview};
pub(crate) use super::transcript_cards::TranscriptActivityLink;
pub(crate) const MOUSE_WHEEL_SCROLL_ROWS: u16 = 3;
pub(crate) use super::autocomplete::{AutocompleteCandidate, AutocompleteKind, AutocompleteState};
#[cfg(test)]
pub(crate) const MAX_PROMPT_VISIBLE_ROWS: u16 = super::prompt_editor::MAX_VISIBLE_ROWS;
mod activity_state;
mod layout;
mod modal;
mod output_events;
mod primary_agent_state;
mod prompt_actions;
mod prompt_state;
mod provider_context;
mod subagent_viewer;
mod transcript_state;
use activity_state::ActiveSubagentTranscript;
pub(crate) use activity_state::ActiveToolTranscript;
use modal::ModalState;
#[cfg(test)]
pub(crate) use modal::SessionPickerState;
#[cfg(test)]
pub(crate) use modal::SkillsModalState;
pub(crate) use modal::{
ConnectProviderField, ConnectProviderStage, ConnectProviderState, LoginProviderEntry,
McpServerToggleRow, ModalKind, ModelToggleRow, PreviewMessage, SessionPickerFocus,
SessionPickerRow, SessionPreview, SessionPreviewPartial, SessionPreviewState, SkillToggleRow,
SubagentProfileToggleRow, ThemeSaveState, ToolToggleRow,
};
pub(crate) use primary_agent_state::PrimaryAgentEntry;
pub(crate) use prompt_state::RunningPromptState;
#[cfg(test)]
pub(crate) use prompt_state::RunningPromptStatus;
pub(crate) use transcript_state::ContextUsageState;
#[cfg(test)]
use transcript_state::transcript_tool_metadata_part;
use transcript_state::{
is_transient_status_error, merge_context_usage, transcript_tool_metadata, transcript_tool_row,
};
pub(crate) const MAX_AUTOCOMPLETE_VISIBLE_ROWS: usize = 6;
#[cfg(test)]
const KEYBOARD_DETAIL_SCROLL_ROWS: u16 = 1;
pub(crate) mod session_files;
mod summary;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub(crate) enum RightColumnTab {
#[default]
Activity,
Summary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum ActivityFocusPane {
#[default]
Tree,
Detail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum TuiFocusPane {
Transcript,
ActivityTree,
ActivityDetail,
Summary,
#[default]
Prompt,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum TuiLayoutMode {
#[default]
TranscriptFocused,
ActivityFocused,
}
#[derive(Debug, Default)]
pub(crate) struct ActivityRenderCache {
visible_nodes: Option<Vec<(usize, ActivityId)>>,
selected_detail: Option<ActivityDetailCache>,
}
#[derive(Debug, Clone)]
struct ActivityBodyWrapCache {
total_rows: usize,
visual_starts: Vec<(usize, usize)>,
}
#[derive(Debug, Clone)]
struct ActivityDetailCache {
id: Option<ActivityId>,
summary_lines: Vec<DisplayLine>,
body_lines: Vec<DisplayLine>,
metadata_header: Option<usize>,
body_wrap_cache: HashMap<u16, ActivityBodyWrapCache>,
body_wrap_widths: VecDeque<u16>,
}
impl ActivityRenderCache {
fn clear(&mut self) {
self.visible_nodes = None;
self.selected_detail = None;
}
fn invalidate_visible_nodes(&mut self) {
self.visible_nodes = None;
}
fn invalidate_selected_detail(&mut self) {
self.selected_detail = None;
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct ThinkingTranscriptIdentity {
pub(crate) item_id: String,
pub(crate) turn_id: Option<String>,
}
#[derive(Debug, Default)]
pub(crate) struct ScrollViewStates {
pub(crate) transcript: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) activity_tree: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) detail: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) help: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) system_prompt: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) session_list: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) session_preview: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) connect_provider: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) logout_picker: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) model_picker: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) skills_modal: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) mcp_modal: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) tools_modal: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) subagents_modal: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) models_modal: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) rewind_modal: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) usage_modal: RefCell<tui_scrollview::ScrollViewState>,
pub(crate) theme_modal: RefCell<tui_scrollview::ScrollViewState>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SubagentCardSelection {
pub(crate) task_id: Option<ActivityId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LastSessionHeader {
pub(crate) session_id: Option<String>,
pub(crate) model: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StoredFastObservation {
pub(crate) run_order: Option<u64>,
pub(crate) accept_sequence: u64,
pub(crate) request_sequence: u64,
pub(crate) requested_service_tier: String,
pub(crate) outcome: crate::fast::FastOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) enum FastDisplayState {
#[default]
Off,
Unavailable,
WillRequest,
Confirmed,
Different(String),
Unconfirmed,
}
impl StoredFastObservation {
pub(crate) fn to_fast_observation(&self) -> crate::fast::FastObservation {
crate::fast::FastObservation {
requested_service_tier: self.requested_service_tier.clone(),
outcome: self.outcome.clone(),
request_sequence: self.request_sequence,
run_order: self.run_order,
}
}
}
impl FastDisplayState {
pub(crate) fn resolve(
enabled: bool,
capability: &crate::fast::FastRequestState,
observation: Option<&crate::fast::FastObservation>,
) -> Self {
if !enabled {
return Self::Off;
}
if capability
.service_tier()
.is_none_or(|service_tier| service_tier.trim().is_empty())
{
return Self::Unavailable;
}
let observation = observation.filter(|observation| {
crate::fast::fast_observation_matches_request(observation, capability)
});
match observation.map(|observation| &observation.outcome) {
Some(crate::fast::FastOutcome::Confirmed) => Self::Confirmed,
Some(crate::fast::FastOutcome::Different(service_tier)) => {
Self::Different(crate::fast::display_service_tier(service_tier))
}
Some(crate::fast::FastOutcome::Unconfirmed) => Self::Unconfirmed,
Some(crate::fast::FastOutcome::NotRequested) | None => Self::WillRequest,
}
}
}
#[derive(Debug, Default)]
pub(crate) struct MissionControlState {
pub(crate) layout_mode: TuiLayoutMode,
pub(crate) activity_column_hidden: bool,
pub(crate) right_column_tab: RightColumnTab,
pub(crate) summary: summary::SummaryDisplay,
pub(crate) session_files: session_files::SessionFiles,
pub(crate) nodes: HashMap<ActivityId, ActivityNode>,
pub(crate) roots: Vec<ActivityId>,
pub(crate) children: HashMap<ActivityId, Vec<ActivityId>>,
pub(crate) expanded: HashSet<ActivityId>,
pub(crate) transcript: TranscriptEntries,
pub(crate) transcript_timestamp_context: Option<DateTime<Local>>,
pub(crate) subagent_card_selection: HashMap<ActivityId, SubagentCardSelection>,
pub(crate) activity_update_sequence: u64,
pub(crate) subagent_card_rows: usize,
pub(super) transcript_cache: RefCell<TranscriptRenderCache>,
pub(super) subagent_viewer_timeline_cache:
RefCell<subagent_viewer::SubagentViewerTimelineCache>,
pub(super) final_assistant_plain_first_entries: RefCell<HashSet<usize>>,
pub(super) final_assistant_rich_refresh_entries: RefCell<HashSet<usize>>,
pub(super) final_assistant_plain_first_enabled: Cell<bool>,
pub(super) transcript_visual_generation: Cell<u64>,
pub(crate) theme: MissionControlTheme,
pub(crate) reduced_motion: bool,
#[cfg(any(test, debug_assertions))]
pub(crate) perf: super::perf::TuiPerfCounters,
pub(super) activity_render_cache: RefCell<ActivityRenderCache>,
pub(crate) scroll_views: ScrollViewStates,
pub(crate) detail_scroll_offset: Cell<usize>,
pub(crate) detail_metadata_expanded: bool,
pub(crate) transcript_tail_distance: Cell<usize>,
pub(crate) active_tool_transcripts: HashMap<ActivityId, ActiveToolTranscript>,
pub(crate) assistant_streaming: bool,
pub(crate) active_assistant_text: String,
pub(crate) active_assistant_redactor: StreamingRedactor,
pub(crate) active_assistant_transcript_index: Option<usize>,
pub(crate) pending_assistant_completion_index: Option<usize>,
pub(crate) pending_compaction_transcript_index: Option<usize>,
pub(crate) active_thinking_text: String,
pub(crate) thinking_transcript_identities: HashMap<ThinkingTranscriptIdentity, usize>,
#[cfg(test)]
pub(crate) thinking_append_work_bytes: usize,
pub(crate) active_thinking_redactor: StreamingRedactor,
pub(crate) active_thinking_control_sanitizer: transcript::StreamingControlSanitizer,
pub(crate) active_thinking_transcript_index: Option<usize>,
pub(crate) context_usage: Option<ContextUsageState>,
pub(crate) session_usage: super::session_usage::SessionUsageLedger,
pub(crate) last_known_session_cache_percent: Option<u64>,
pub(crate) auto_compaction: crate::config::AutoCompactionSettings,
pub(crate) selected: usize,
pub(crate) prompt_editor: PromptEditor,
pub(crate) focus_pane: TuiFocusPane,
pub(crate) last_activity_focus: ActivityFocusPane,
pub(crate) last_standalone_ctrl: Option<Instant>,
pub(crate) fast_observations: HashMap<(String, String), StoredFastObservation>,
pub(crate) fast_warning_keys: HashSet<crate::fast::FastWarningKey>,
pub(crate) fast_observation_accept_sequence: u64,
pub(crate) fast_mode_enabled: bool,
pub(crate) fast_mode_request_state: crate::fast::FastRequestState,
pub(crate) fast_mode_capable: bool,
pub(crate) fast_mode_effective: bool,
pub(crate) color_enabled: bool,
pub(crate) fast_mode_rainbow_phase: usize,
pub(crate) welcome_animation_phase: usize,
pub(crate) activity_motion: activity_motion::ActivityMotion,
pub(crate) ignore_next_standalone_ctrl_release: bool,
pub(crate) activity_follow_tail: bool,
pub(crate) status: String,
pub(crate) footer_session: String,
pub(crate) footer_session_id: Option<String>,
pub(crate) footer_cwd: String,
pub(crate) footer_git_branch: Option<String>,
pub(crate) provider: String,
pub(crate) model: String,
pub(crate) last_session_header: Option<LastSessionHeader>,
pub(crate) provider_ready: bool,
pub(crate) thinking_level: crate::thinking::ThinkingLevel,
pub(crate) effective_thinking_level: crate::thinking::ThinkingLevel,
pub(crate) thinking_levels: Vec<crate::thinking::ThinkingLevel>,
pub(crate) running_prompt: Option<RunningPromptState>,
pub(crate) active_worker_id: Option<u64>,
pub(crate) last_run_finished_worker_id: Option<u64>,
pub(crate) pending_steering_count: usize,
pub(crate) show_help: bool,
pub(crate) autocomplete: Option<AutocompleteState>,
pub(crate) autocomplete_dismissed_token: Option<String>,
pub(crate) selection: Option<ActiveSelection>,
pub(crate) toast: Option<ToastState>,
pub(crate) pending_model_catalog_request_id: Option<u64>,
pub(crate) pending_usage_request_id: Option<u64>,
pub(crate) pending_startup_critical_request_id: Option<u64>,
pub(crate) pending_startup_decorative_request_id: Option<u64>,
pub(crate) startup_prompt_queued: bool,
pub(crate) modals: ModalState,
pub(crate) primary_agents: Vec<PrimaryAgentEntry>,
pub(crate) selected_primary_agent: Option<usize>,
}
impl MissionControlState {
pub(crate) fn subagent_card_rows(&self) -> usize {
if self.subagent_card_rows == 0 {
crate::config::DEFAULT_TUI_SUBAGENT_CARD_ROWS
} else {
self.subagent_card_rows
}
}
}
impl MissionControlState {
pub(crate) fn pin_subagent_card_task(&mut self, batch_id: ActivityId, task_id: ActivityId) {
self.subagent_card_selection.insert(
batch_id,
SubagentCardSelection {
task_id: Some(task_id),
},
);
self.invalidate_transcript_cache();
}
#[cfg(test)]
pub(crate) fn subagent_card_selection_for(
&self,
batch_id: &ActivityId,
) -> SubagentCardSelection {
self.subagent_card_selection
.get(batch_id)
.cloned()
.unwrap_or_default()
}
#[cfg(test)]
pub(crate) fn open_subagent_card_transcript(&mut self, task_id: ActivityId) -> bool {
let mut ancestor = self
.nodes
.get(&task_id)
.and_then(|node| node.parent_id.clone());
let mut visited = HashSet::new();
while let Some(parent) = ancestor {
if !visited.insert(parent.clone()) {
break;
}
self.expanded.insert(parent.clone());
ancestor = self
.nodes
.get(&parent)
.and_then(|node| node.parent_id.clone());
}
self.invalidate_activity_visible_nodes_cache();
let Some(index) = self
.cached_visible_nodes()
.iter()
.position(|(_, id)| *id == task_id)
else {
return false;
};
self.selected = index;
self.activity_follow_tail = false;
self.set_scroll_offset(&self.scroll_views.activity_tree, index);
self.focus_activity_detail();
self.reset_detail();
true
}
pub(crate) fn scroll_offset(
&self,
view: &std::cell::RefCell<tui_scrollview::ScrollViewState>,
) -> usize {
usize::from(view.borrow().offset().y)
}
pub(crate) fn set_scroll_offset(
&self,
view: &std::cell::RefCell<tui_scrollview::ScrollViewState>,
offset: usize,
) {
if std::ptr::eq(view, &self.scroll_views.detail) {
self.detail_scroll_offset.set(offset);
return;
}
let offset = offset.min(usize::from(u16::MAX));
if std::ptr::eq(view, &self.scroll_views.transcript) {
self.transcript_tail_distance.set(offset);
}
view.borrow_mut()
.set_offset(Position::new(0, offset as u16));
}
pub(crate) fn transcript_scroll_offset(&self) -> usize {
self.transcript_tail_distance.get()
}
pub(crate) fn activity_scroll_offset(&self) -> usize {
self.scroll_offset(&self.scroll_views.activity_tree)
}
pub(crate) fn detail_offset(&self) -> usize {
self.detail_scroll_offset.get()
}
pub(crate) fn help_offset(&self) -> usize {
self.scroll_offset(&self.scroll_views.help)
}
pub(crate) fn reset_core_scroll_views(&mut self) {
self.set_scroll_offset(&self.scroll_views.transcript, 0);
self.set_scroll_offset(&self.scroll_views.activity_tree, 0);
self.detail_scroll_offset.set(0);
self.set_scroll_offset(&self.scroll_views.help, 0);
}
pub(crate) fn refresh_thinking_levels(
&mut self,
selected: crate::thinking::ThinkingLevel,
available: Vec<crate::thinking::ThinkingLevel>,
) {
self.thinking_level = selected;
self.thinking_levels = crate::thinking::normalize_thinking_levels(&available);
self.effective_thinking_level =
crate::thinking::resolve_thinking_level(&self.thinking_levels, selected);
}
pub(crate) fn set_theme(&mut self, theme: MissionControlTheme, reduced_motion: bool) -> bool {
let presentation_changed = !self.theme.presentation_eq(theme);
let newer_revision = theme.revision() > self.theme.revision();
let motion_changed = self.reduced_motion != reduced_motion;
if !presentation_changed && !newer_revision && !motion_changed {
return false;
}
self.theme = theme;
self.reduced_motion = reduced_motion;
self.fast_mode_rainbow_phase = 0;
self.invalidate_transcript_cache();
true
}
pub(crate) fn theme_revision(&self) -> u64 {
self.theme.revision()
}
pub(crate) fn refresh_fast_mode_state(
&mut self,
enabled: bool,
capability: &crate::fast::FastRequestState,
) {
let capable = capability
.service_tier()
.is_some_and(|service_tier| !service_tier.trim().is_empty());
self.fast_mode_enabled = enabled;
self.fast_mode_request_state = capability.clone();
self.fast_mode_capable = capable;
self.fast_mode_effective = enabled && capable;
}
pub(crate) fn set_fast_mode_enabled(&mut self, enabled: bool) {
self.fast_mode_enabled = enabled;
self.fast_mode_effective = enabled && self.fast_mode_capable;
}
pub(crate) fn selected_fast_observation(&self) -> Option<&StoredFastObservation> {
let key = crate::fast::canonical_fast_observation_key(&self.provider, &self.model);
self.fast_observations.get(&key)
}
#[cfg(test)]
pub(crate) fn selected_fast_observation_outcome(&self) -> Option<&crate::fast::FastOutcome> {
self.selected_fast_observation()
.map(|observation| &observation.outcome)
}
pub(crate) fn fast_display_state(
&self,
enabled: bool,
capability: &crate::fast::FastRequestState,
) -> FastDisplayState {
let observation = self
.selected_fast_observation()
.map(StoredFastObservation::to_fast_observation);
FastDisplayState::resolve(enabled, capability, observation.as_ref())
}
#[cfg(test)]
pub(crate) fn fast_observation(
&self,
provider: &str,
model: &str,
) -> Option<&StoredFastObservation> {
let key = crate::fast::canonical_fast_observation_key(provider, model);
self.fast_observations.get(&key)
}
pub(crate) fn accept_fast_observation(
&mut self,
provider_id: &str,
model: &str,
requested_service_tier: &str,
outcome: &crate::fast::FastOutcome,
request_sequence: u64,
run_order: Option<u64>,
) -> bool {
self.fast_observation_accept_sequence =
self.fast_observation_accept_sequence.saturating_add(1);
let accept_sequence = self.fast_observation_accept_sequence;
let key = crate::fast::canonical_fast_observation_key(provider_id, model);
let candidate_order = crate::fast::FastObservationOrder {
run_order,
accept_sequence,
};
let current_order =
self.fast_observations
.get(&key)
.map(|stored| crate::fast::FastObservationOrder {
run_order: stored.run_order,
accept_sequence: stored.accept_sequence,
});
if crate::fast::compare_fast_observation_order(current_order, candidate_order)
!= std::cmp::Ordering::Greater
{
return false;
}
let observation = crate::fast::FastObservation {
run_order,
request_sequence,
requested_service_tier: requested_service_tier.to_string(),
outcome: outcome.clone(),
};
self.fast_observations.insert(
key,
StoredFastObservation {
run_order,
accept_sequence,
request_sequence,
requested_service_tier: requested_service_tier.to_string(),
outcome: outcome.clone(),
},
);
if let Some(warning) =
crate::fast::fast_observation_warning(provider_id, model, &observation)
{
let warning = crate::output::sanitize_display_text(&warning);
self.status = warning.clone();
if self
.fast_warning_keys
.insert(crate::fast::canonical_fast_warning_key(
provider_id,
model,
&observation,
))
{
self.record_fast_warning_transcript(&warning);
}
}
true
}
pub(crate) fn clear_fast_observations(&mut self) {
self.fast_observations.clear();
self.fast_warning_keys.clear();
}
pub(crate) fn welcome_animation_active(&self) -> bool {
self.transcript.is_empty() && self.color_enabled && !self.reduced_motion
}
pub(crate) fn fast_mode_rainbow_active(&self) -> bool {
self.fast_mode_enabled
&& self.fast_mode_effective
&& self.color_enabled
&& !self.reduced_motion
&& self.provider_ready
&& (!self.provider.trim().is_empty() || !self.model.trim().is_empty())
}
pub(crate) fn advance_fast_mode_rainbow_phase(&mut self) -> bool {
if !self.fast_mode_rainbow_active() {
return false;
}
let palette_len = self.theme.fast_mode_rainbow_len();
if palette_len == 0 {
return false;
}
self.fast_mode_rainbow_phase = self.fast_mode_rainbow_phase.wrapping_add(1) % palette_len;
true
}
pub(crate) fn next_thinking_level(&self) -> Option<crate::thinking::ThinkingLevel> {
crate::thinking::next_thinking_level(&self.thinking_levels, self.thinking_level)
}
pub(crate) fn thinking_label(&self) -> &'static str {
self.effective_thinking_level.label()
}
pub(crate) fn invalidate_transcript_cache(&self) {
self.final_assistant_plain_first_entries
.borrow_mut()
.clear();
self.final_assistant_rich_refresh_entries
.borrow_mut()
.clear();
self.final_assistant_plain_first_enabled.set(false);
self.bump_transcript_visual_generation();
self.transcript_cache.borrow_mut().clear();
}
pub(crate) fn transcript_absolute_index(&self, local_index: usize) -> Option<usize> {
self.transcript.absolute_index(local_index)
}
pub(crate) fn transcript_local_index(&self, absolute_index: usize) -> Option<usize> {
self.transcript.local_index(absolute_index)
}
pub(crate) fn transcript_timestamp(&self, local_index: usize) -> Option<DateTime<Local>> {
self.transcript.timestamp(local_index)
}
#[cfg(test)]
pub(crate) fn set_transcript_timestamp_for_test(
&mut self,
local_index: usize,
timestamp: DateTime<Local>,
) {
if let Some(absolute_index) = self.transcript_absolute_index(local_index) {
self.transcript.set_timestamp(absolute_index, timestamp);
self.invalidate_transcript_entry_cache(local_index);
}
}
fn invalidate_transcript_front_cache(&self, old_base: usize, drained: usize) {
if drained == 0 {
return;
}
for index in old_base..old_base.saturating_add(drained) {
self.final_assistant_plain_first_entries
.borrow_mut()
.remove(&index);
self.final_assistant_rich_refresh_entries
.borrow_mut()
.remove(&index);
}
self.final_assistant_plain_first_enabled.set(false);
self.bump_transcript_visual_generation();
self.transcript_cache
.borrow_mut()
.evict_front(old_base, drained);
}
fn invalidate_transcript_visual_projection_cache(&self) {
self.bump_transcript_visual_generation();
self.transcript_cache.borrow_mut().clear_visual_aggregates();
}
fn invalidate_transcript_entry_cache(&self, entry_index: usize) {
let Some(absolute_index) = self.transcript_absolute_index(entry_index) else {
return;
};
self.mark_transcript_entry_changed(absolute_index);
self.final_assistant_plain_first_entries
.borrow_mut()
.remove(&absolute_index);
self.final_assistant_rich_refresh_entries
.borrow_mut()
.remove(&absolute_index);
self.final_assistant_plain_first_enabled.set(false);
self.bump_transcript_visual_generation();
self.transcript_cache
.borrow_mut()
.evict_card(absolute_index);
}
fn invalidate_transcript_entries_cache(&self, entry_indices: impl IntoIterator<Item = usize>) {
let entry_indices = entry_indices
.into_iter()
.filter_map(|index| self.transcript_absolute_index(index))
.collect::<Vec<_>>();
if entry_indices.is_empty() {
return;
}
for &entry_index in &entry_indices {
self.mark_transcript_entry_changed(entry_index);
}
self.bump_transcript_visual_generation();
self.transcript_cache
.borrow_mut()
.evict_cards(entry_indices);
}
fn mark_transcript_entry_changed(&self, absolute_index: usize) {
self.transcript.mark_changed(absolute_index);
}
pub(crate) fn transcript_entry_revision(&self, local_index: usize) -> u64 {
self.transcript.entry_revision(local_index)
}
pub(crate) fn transcript_visual_generation(&self) -> u64 {
self.transcript_visual_generation.get()
}
fn bump_transcript_visual_generation(&self) {
self.transcript_visual_generation
.set(self.transcript_visual_generation.get().wrapping_add(1));
}
pub(crate) fn final_assistant_plain_first_marker(&self, entry_index: usize) -> u8 {
let Some(entry_index) = self.transcript_absolute_index(entry_index) else {
return 0;
};
if self.final_assistant_plain_first_enabled.get()
&& self
.final_assistant_plain_first_entries
.borrow()
.contains(&entry_index)
{
1
} else if self
.final_assistant_rich_refresh_entries
.borrow()
.contains(&entry_index)
{
2
} else {
0
}
}
pub(crate) fn begin_final_assistant_plain_first_paint(&mut self) -> bool {
if self.transcript_selection_active()
|| self.final_assistant_plain_first_enabled.get()
|| self.final_assistant_plain_first_entries.borrow().is_empty()
{
return false;
}
self.final_assistant_plain_first_enabled.set(true);
self.bump_transcript_visual_generation();
true
}
pub(crate) fn finish_final_assistant_plain_first_paint(&mut self) -> bool {
if !self.final_assistant_plain_first_enabled.get() {
return false;
}
let entries = self
.final_assistant_plain_first_entries
.borrow_mut()
.drain()
.collect::<Vec<_>>();
self.final_assistant_plain_first_enabled.set(false);
if entries.is_empty() {
return false;
}
self.final_assistant_rich_refresh_entries
.borrow_mut()
.extend(entries);
self.bump_transcript_visual_generation();
true
}
fn mark_final_assistant_plain_first(&self, entry_index: Option<usize>) {
let Some(entry_index) = entry_index.and_then(|index| self.transcript_absolute_index(index))
else {
return;
};
self.final_assistant_rich_refresh_entries
.borrow_mut()
.remove(&entry_index);
self.final_assistant_plain_first_entries
.borrow_mut()
.insert(entry_index);
self.bump_transcript_visual_generation();
}
fn invalidate_transcript_append_cache(&self, drained: usize) {
if drained == 0 {
self.invalidate_transcript_visual_projection_cache();
}
}
fn invalidate_transcript_changed_entry_cache(
&self,
drained: usize,
entry_index: Option<usize>,
) {
if drained == 0 {
if let Some(entry_index) = entry_index {
self.invalidate_transcript_entry_cache(entry_index);
} else {
self.invalidate_transcript_visual_projection_cache();
}
}
}
pub(crate) fn transcript_selection_active(&self) -> bool {
self.selection
.as_ref()
.is_some_and(|selection| selection.pane == TuiPane::Transcript)
}
#[cfg(test)]
pub(crate) fn cached_transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
super::transcript_cards::cached_visual_lines_ref(self, width)
.iter()
.map(|line| line.line.clone())
.collect()
}
pub(crate) fn update_context_window(&mut self, max_tokens: usize) {
let current_tokens = self
.context_usage
.map(|usage| usage.current_tokens)
.unwrap_or(0);
let reasoning_tokens = self.context_usage.and_then(|usage| usage.reasoning_tokens);
let source = self
.context_usage
.map(|usage| usage.source)
.unwrap_or(ContextUsageSource::FallbackEstimate);
let request_sequence = self
.context_usage
.map(|usage| usage.request_sequence)
.unwrap_or(0);
self.context_usage = Some(ContextUsageState {
current_tokens,
max_tokens,
reasoning_tokens,
source,
request_sequence,
});
}
pub(crate) fn reset_context_for_model_switch(&mut self) {
self.context_usage = None;
}
pub(crate) fn invalidate_activity_render_cache(&self) {
self.activity_render_cache.borrow_mut().clear();
}
pub(crate) fn invalidate_activity_visible_nodes_cache(&self) {
self.activity_render_cache
.borrow_mut()
.invalidate_visible_nodes();
}
pub(crate) fn invalidate_selected_activity_detail_cache(&self) {
self.activity_render_cache
.borrow_mut()
.invalidate_selected_detail();
}
#[cfg(test)]
pub(crate) fn selected_activity_text(&self) -> String {
self.selected_activity_id()
.as_ref()
.and_then(|id| self.nodes.get(id))
.map(|node| {
let detail = activity::activity_detail_lines(node, true);
activity_detail_plain_text(&detail.summary, &detail.body)
})
.unwrap_or_else(|| no_activity_selected_text().to_string())
}
#[cfg(test)]
pub(crate) fn selected_activity_cached_text(&self) -> String {
self.ensure_selected_activity_detail_cache();
self.activity_render_cache
.borrow()
.selected_detail
.as_ref()
.map(|detail| activity_detail_plain_text(&detail.summary_lines, &detail.body_lines))
.unwrap_or_else(|| no_activity_selected_text().to_string())
}
#[cfg(test)]
pub(crate) fn selected_activity_display_lines(&self) -> Vec<DisplayLine> {
let mut lines = self.selected_activity_summary_lines();
self.ensure_selected_activity_detail_cache();
let body = self
.activity_render_cache
.borrow()
.selected_detail
.as_ref()
.map(|detail| detail.body_lines.clone())
.unwrap_or_default();
if !body.is_empty() {
lines.push(DisplayLine::plain(""));
lines.extend(body);
}
lines
}
pub(crate) fn selected_activity_summary_lines(&self) -> Vec<DisplayLine> {
self.ensure_selected_activity_detail_cache();
self.activity_render_cache
.borrow()
.selected_detail
.as_ref()
.map(|detail| detail.summary_lines.clone())
.unwrap_or_else(|| vec![DisplayLine::plain(no_activity_selected_text())])
}
fn ensure_selected_activity_detail_cache(&self) {
let selected_id = self.selected_activity_id();
if self
.activity_render_cache
.borrow()
.selected_detail
.as_ref()
.is_some_and(|detail| detail.id == selected_id)
{
return;
}
let lines = selected_id
.as_ref()
.and_then(|id| self.nodes.get(id))
.map(|node| {
let mut detail =
activity::activity_detail_lines(node, self.detail_metadata_expanded);
if node.kind == ActivityKind::SubagentTask {
let descendants = self.safe_subagent_descendant_lines(&node.id);
if !descendants.is_empty() {
if !detail.body.is_empty() {
detail.body.push(DisplayLine::plain(""));
}
detail.body.push(DisplayLine::plain("Recent activity"));
detail.body.extend(descendants);
}
}
detail
})
.unwrap_or_else(|| activity::ActivityDetailLines {
summary: vec![DisplayLine::plain(no_activity_selected_text())],
body: Vec::new(),
metadata_header: None,
});
self.activity_render_cache.borrow_mut().selected_detail = Some(ActivityDetailCache {
id: selected_id,
summary_lines: lines.summary,
body_lines: lines.body,
metadata_header: lines.metadata_header,
body_wrap_cache: HashMap::new(),
body_wrap_widths: VecDeque::new(),
});
}
fn safe_subagent_descendant_lines(&self, task_id: &ActivityId) -> Vec<DisplayLine> {
let mut nodes = self
.nodes
.values()
.filter(|node| self.activity_is_descendant_of(&node.id, task_id))
.collect::<Vec<_>>();
nodes.sort_by(|left, right| {
right
.own_update_sequence
.cmp(&left.own_update_sequence)
.then_with(|| right.id.as_str().cmp(left.id.as_str()))
});
let mut lines = Vec::new();
for node in nodes {
let source = if node.preview.trim().is_empty() {
node.metadata.label.as_str()
} else {
node.preview.as_str()
};
let Some(line) = source.lines().rev().find(|line| !line.trim().is_empty()) else {
continue;
};
let line = normalize_inline_separators(&transcript::sanitize_preview(
&redact_sensitive_text(line),
));
if !line.is_empty()
&& lines
.last()
.is_none_or(|last: &DisplayLine| last.plain_text() != line)
{
lines.push(DisplayLine::plain(line));
}
if lines.len() == 6 {
break;
}
}
lines
}
#[cfg(test)]
pub(crate) fn detail_metadata_expanded(&self) -> bool {
self.detail_metadata_expanded
}
pub(crate) fn toggle_detail_metadata(&mut self, visible_rows: u16, wrap_width: u16) -> bool {
self.ensure_selected_activity_detail_cache();
if self
.activity_render_cache
.borrow()
.selected_detail
.as_ref()
.is_none_or(|detail| detail.metadata_header.is_none())
{
return false;
}
let offset = self.detail_offset();
self.detail_metadata_expanded = !self.detail_metadata_expanded;
self.invalidate_selected_activity_detail_cache();
let body_rows = self.detail_body_viewport_rows(visible_rows, wrap_width);
let clamped = offset.min(self.detail_overflow(body_rows, wrap_width));
self.set_scroll_offset(&self.scroll_views.detail, clamped);
true
}
pub(crate) fn detail_metadata_header_visual_rows(
&self,
wrap_width: u16,
) -> Option<std::ops::Range<usize>> {
self.ensure_selected_activity_body_wrap_cache(wrap_width);
let cache = self.activity_render_cache.borrow();
let detail = cache.selected_detail.as_ref()?;
let header = detail.metadata_header?;
let starts = &detail
.body_wrap_cache
.get(&wrap_width.max(1))?
.visual_starts;
let start = starts.iter().position(|(line, _)| *line == header)?;
let end = starts[start..]
.iter()
.position(|(line, _)| *line != header)
.map_or(starts.len(), |offset| start + offset);
Some(start..end)
}
pub(crate) fn start_prompt_selection_at(&mut self, byte: usize) {
self.selection = None;
if self.prompt_editor.start_selection_at(byte) == PromptMovementOutcome::Failed {
self.status = PROMPT_MOVEMENT_FAILED_STATUS.to_string();
}
}
pub(crate) fn update_prompt_selection_at(&mut self, byte: usize) -> bool {
let outcome = self.prompt_editor.update_selection_at(byte);
if outcome == PromptMovementOutcome::Failed {
self.status = PROMPT_MOVEMENT_FAILED_STATUS.to_string();
}
!matches!(outcome, PromptMovementOutcome::Unchanged)
}
pub(crate) fn start_selection(&mut self, pane: TuiPane, position: TextPosition) {
match pane {
TuiPane::Prompt => self.start_prompt_selection_at(position.byte),
TuiPane::Transcript => {
self.prompt_editor.cancel_selection();
self.selection = Some(ActiveSelection::new(pane, position));
}
_ => {
self.clear_selection();
}
}
}
pub(crate) fn update_selection(&mut self, pane: TuiPane, position: TextPosition) -> bool {
match pane {
TuiPane::Prompt => self.update_prompt_selection_at(position.byte),
TuiPane::Transcript => {
let Some(selection) = &mut self.selection else {
return false;
};
if selection.pane != pane {
self.selection = None;
return true;
}
let before = selection.current;
selection.set_current(position);
before != position
}
_ => false,
}
}
pub(crate) fn finish_selection(
&mut self,
pane: TuiPane,
transcript_wrap_width: u16,
) -> Option<String> {
match pane {
TuiPane::Prompt => self.prompt_editor.finish_selection(),
TuiPane::Transcript => {
let selection = self.selection.take()?;
if selection.pane != pane {
return None;
}
let (start, end) = selection.byte_range()?;
super::transcript_cards::visual_text_for_width(self, transcript_wrap_width.max(1))
.get(start..end)
.map(ToOwned::to_owned)
.filter(|text| !text.is_empty())
}
_ => None,
}
}
pub(crate) fn clear_selection(&mut self) -> bool {
self.prompt_editor.cancel_selection() || self.selection.take().is_some()
}
pub(crate) fn transcript_text_position_for_cell(
&self,
row: u16,
column: u16,
visible_rows: u16,
wrap_width: u16,
) -> Option<TextPosition> {
let render_scroll = self.transcript_render_scroll(visible_rows, wrap_width);
super::transcript_cards::text_position_for_visual_cell(
self,
render_scroll.saturating_add(usize::from(row)),
usize::from(column),
wrap_width,
)
.map(|position| TextPosition::new(position.byte, usize::from(row), position.col))
}
pub(crate) fn show_copy_success_toast(&mut self, now: Instant) {
self.toast = Some(ToastState::new(
COPIED_TO_CLIPBOARD,
ToastSeverity::Success,
now,
));
}
pub(crate) fn show_copy_failure_toast(&mut self, now: Instant) {
self.toast = Some(ToastState::new(
"Clipboard copy failed",
ToastSeverity::Error,
now,
));
}
pub(crate) fn set_pending_steering_count(&mut self, count: usize) -> bool {
let changed = self.pending_steering_count != count;
self.pending_steering_count = count;
changed
}
pub(crate) fn show_steering_injected_feedback(&mut self, now: Instant) {
self.status = "Steering injected".to_string();
self.toast = Some(ToastState::new(
"Steering injected",
ToastSeverity::Success,
now,
));
}
pub(crate) fn expire_toast(&mut self, now: Instant) -> bool {
if self.toast.as_ref().is_some_and(|toast| toast.expired(now)) {
self.toast = None;
true
} else {
false
}
}
}
fn no_activity_selected_text() -> &'static str {
"No activity selected."
}
#[cfg(test)]
fn activity_detail_plain_text(summary: &[DisplayLine], body: &[DisplayLine]) -> String {
let mut lines = summary.to_vec();
if !body.is_empty() {
lines.push(DisplayLine::plain(""));
lines.extend_from_slice(body);
}
plain_projection(&lines)
}
#[cfg(test)]
pub(crate) fn render_activity_detail_display_lines(detail_text: &str) -> Vec<DisplayLine> {
if looks_like_git_diff(detail_text) {
crate::rendering::render(detail_text, crate::rendering::RenderInputKind::Diff)
} else {
crate::rendering::render(detail_text, crate::rendering::RenderInputKind::Plain)
}
}
#[cfg(test)]
fn looks_like_git_diff(text: &str) -> bool {
text.lines().any(|line| {
line.starts_with("diff --git ") || line.starts_with("@@ ") || line.starts_with("--- ")
})
}
fn short_session_id(session_id: &str) -> String {
let char_count = session_id.chars().count();
if char_count <= 8 {
return session_id.to_string();
}
let prefix: String = session_id.chars().take(4).collect();
let suffix: String = session_id
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{prefix}...{suffix}")
}
#[cfg(test)]
#[path = "tests/mod.rs"]
mod tests;
#[cfg(test)]
#[path = "primary_agent_state_tests.rs"]
mod primary_agent_state_tests;