#[cfg(test)]
pub(crate) use super::TranscriptDequeExt;
use super::{
activity::{self, ActivityNode},
input::{
autocomplete,
selection::{ActiveSelection, TextPosition},
},
prompt,
toast::{COPIED_TO_CLIPBOARD, ToastSeverity, ToastState},
transcript,
};
#[cfg(test)]
use crate::output::ActivityEvent;
use crate::output::{
ActivityId, ActivityKind, ActivityMetadata, ActivityStatus, ContextUsageSource, OutputEvent,
StreamingRedactor,
};
use crate::rendering::DisplayLine;
#[cfg(test)]
use crate::rendering::plain_projection;
use crate::tui::{PADDED_INLINE_SEPARATOR, layout::TuiPane, normalize_inline_separators};
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, compact_activity_metadata};
pub(crate) use super::transcript::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::input::autocomplete::{
AutocompleteCandidate, AutocompleteKind, AutocompleteState,
};
#[cfg(test)]
pub(crate) const MAX_PROMPT_VISIBLE_ROWS: u16 = prompt::MAX_PROMPT_VISIBLE_ROWS;
pub(crate) const PROMPT_CURSOR_GLYPH: char = prompt::PROMPT_CURSOR_GLYPH;
mod activity_state;
mod layout;
mod modal;
mod output_events;
mod primary_agent_state;
mod prompt_actions;
mod prompt_state;
mod provider_context;
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::{
AuthPickerMode, CustomProviderSetupStep, LoginProviderEntry, McpServerToggleRow,
ModelToggleRow, PreviewMessage, SessionPickerFocus, SessionPickerRow, SessionPreview,
SessionPreviewPartial, SessionPreviewState, SkillToggleRow, SubagentProfileToggleRow,
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;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum TuiFocusPane {
Transcript,
ActivityTree,
ActivityDetail,
#[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>,
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) prompt: 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) login_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>,
}
#[derive(Debug, Default)]
pub(crate) struct MissionControlState {
pub(crate) layout_mode: TuiLayoutMode,
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: VecDeque<String>,
pub(crate) transcript_index_base: usize,
pub(crate) transcript_activity_links: HashMap<usize, TranscriptActivityLink>,
pub(super) transcript_cache: RefCell<TranscriptRenderCache>,
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>,
#[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) 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) active_thinking_text: String,
#[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) thinking_transcript_identities: HashMap<ThinkingTranscriptIdentity, usize>,
pub(crate) context_usage: Option<ContextUsageState>,
pub(crate) selected: usize,
pub(crate) input: String,
pub(crate) focus_pane: TuiFocusPane,
pub(crate) last_standalone_ctrl: Option<Instant>,
pub(crate) ignore_next_standalone_ctrl_release: bool,
pub(crate) prompt_cursor_visible: bool,
pub(crate) prompt_cursor: usize,
pub(crate) prompt_target_column: Option<usize>,
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) 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) 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_request_id: Option<u64>,
pub(crate) modals: ModalState,
pub(crate) primary_agents: Vec<PrimaryAgentEntry>,
pub(crate) selected_primary_agent: Option<usize>,
}
impl MissionControlState {
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 prompt_offset(&self) -> usize {
self.scroll_offset(&self.scroll_views.prompt)
}
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(&self) {
self.set_scroll_offset(&self.scroll_views.prompt, 0);
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 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> {
(local_index < self.transcript.len()).then(|| self.transcript_index_base + local_index)
}
pub(crate) fn transcript_local_index(&self, absolute_index: usize) -> Option<usize> {
absolute_index
.checked_sub(self.transcript_index_base)
.filter(|index| *index < self.transcript.len())
}
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_projection();
}
fn invalidate_transcript_entry_cache(&self, entry_index: usize) {
let Some(absolute_index) = self.transcript_absolute_index(entry_index) else {
return;
};
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;
}
self.bump_transcript_visual_generation();
self.transcript_cache
.borrow_mut()
.evict_cards(entry_indices);
}
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>> {
if self.transcript_selection_active() {
return super::render::transcript::uncached_transcript_lines(self, width);
}
super::transcript_cards::cached_visual_lines_ref(self, width)
.iter()
.map(|line| line.line.clone())
.collect()
}
pub(crate) fn visible_transcript_lines(
&self,
width: u16,
visible_rows: u16,
) -> super::transcript_cards::VisibleTranscriptLines {
if !self.transcript_selection_active() {
if self.transcript_scroll_offset() == 0 {
return super::transcript_cards::visible_tail_lines(self, width, visible_rows);
}
return super::transcript_cards::visible_scrollback_lines(
self,
width,
visible_rows,
self.transcript_scroll_offset(),
);
}
super::render::transcript::selected_visible_transcript_lines(self, width, visible_rows)
}
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 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.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 (summary_lines, body_lines) = selected_id
.as_ref()
.and_then(|id| self.nodes.get(id))
.map(|node| {
let lines = activity::activity_detail_lines(node);
(lines.summary, lines.body)
})
.unwrap_or_else(|| {
(
vec![DisplayLine::plain(no_activity_selected_text())],
Vec::new(),
)
});
self.activity_render_cache.borrow_mut().selected_detail = Some(ActivityDetailCache {
id: selected_id,
summary_lines,
body_lines,
body_wrap_cache: HashMap::new(),
body_wrap_widths: VecDeque::new(),
});
}
pub(crate) fn start_selection(&mut self, pane: TuiPane, position: TextPosition) {
self.selection = Some(ActiveSelection::new(pane, position));
}
pub(crate) fn update_selection(&mut self, pane: TuiPane, position: TextPosition) -> bool {
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
}
pub(crate) fn finish_selection(
&mut self,
pane: TuiPane,
transcript_wrap_width: u16,
) -> Option<String> {
let selection = self.selection.take()?;
if selection.pane != pane {
return None;
}
let (start, end) = selection.byte_range()?;
match pane {
TuiPane::Prompt => self.input.get(start..end).map(ToOwned::to_owned),
TuiPane::Transcript => {
super::transcript_cards::visual_text_for_width(self, transcript_wrap_width.max(1))
.get(start..end)
.map(ToOwned::to_owned)
}
_ => None,
}
.filter(|text| !text.is_empty())
}
pub(crate) fn clear_selection(&mut self) -> bool {
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 transcript_activity_button_for_cell(
&self,
row: u16,
column: u16,
visible_rows: u16,
wrap_width: u16,
) -> Option<ActivityId> {
let render_scroll = self.transcript_render_scroll(visible_rows, wrap_width);
super::transcript_cards::activity_button_for_visual_cell(
self,
render_scroll.saturating_add(usize::from(row)),
usize::from(column),
wrap_width,
)
}
pub(crate) fn select_activity_id(&mut self, id: &ActivityId) -> bool {
if !self.nodes.contains_key(id) {
return false;
}
let previous_selection = self.selected_activity_id();
let mut parent = self.nodes.get(id).and_then(|node| node.parent_id.clone());
let mut expanded_changed = false;
while let Some(parent_id) = parent {
expanded_changed |= self.expanded.insert(parent_id.clone());
parent = self
.nodes
.get(&parent_id)
.and_then(|node| node.parent_id.clone());
}
if expanded_changed {
self.invalidate_activity_render_cache();
}
let Some(index) = self.visible_node_index(id) else {
return expanded_changed;
};
let visible_len = self.visible_node_count();
let selected_changed = self.selected != index;
self.selected = index;
self.activity_follow_tail = index >= visible_len.saturating_sub(1);
if selected_changed || previous_selection.as_ref() != Some(id) {
self.reset_detail();
}
expanded_changed || selected_changed
}
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;