use super::history;
use crate::{
commands::BuiltInCommand,
sessions::{
Session, SessionEvent, SessionEventKind, SessionManager, parse_prune_sessions_days,
resolve_session_scope_path,
},
shell::{ShellState, handle_builtin},
tui::{
PADDED_INLINE_SEPARATOR, TuiEvent,
events::delivery::{EventDelivery, tui_event_delivery},
state,
},
};
use crossbeam_channel::Receiver;
use std::{
collections::VecDeque,
fs,
io::{BufRead, BufReader, Read, Seek, SeekFrom},
path::{Path, PathBuf},
};
pub(crate) fn preserve_critical_tui_events(
receiver: &Receiver<TuiEvent>,
pending: &mut VecDeque<TuiEvent>,
) {
if !pending.is_empty() {
return;
}
let snapshot_len = receiver.len().min(1024);
for _ in 0..snapshot_len {
let Ok(event) = receiver.try_recv() else {
break;
};
if tui_event_delivery(&event) == EventDelivery::Critical {
pending.push_back(event);
}
}
}
pub(crate) fn session_picker_rows(
manager: &SessionManager,
active_session_id: Option<&str>,
current_cwd: &Path,
) -> anyhow::Result<Vec<state::SessionPickerRow>> {
let current_session_path = resolve_session_scope_path(current_cwd);
let mut sessions = manager.list_metadata_summaries()?;
sessions.reverse();
Ok(sessions
.into_iter()
.filter(|summary| summary.session_path.as_deref() == Some(current_session_path.as_path()))
.map(|summary| {
let id = summary.session.id().to_string();
state::SessionPickerRow {
label: session_title_label(summary.session.id(), summary.latest_title.as_deref()),
is_current: active_session_id == Some(id.as_str()),
preview: None,
id,
}
})
.collect())
}
pub(crate) const SESSION_PREVIEW_MESSAGE_MAX_CHARS: usize = 2_000;
pub(crate) const SESSION_PREVIEW_MAX_FORWARD_EVENTS: usize = 256;
pub(crate) const SESSION_PREVIEW_MAX_RECENT_EVENTS: usize = 256;
pub(crate) const SESSION_PREVIEW_MAX_SCAN_BYTES: u64 = 256 * 1024;
pub(crate) fn load_session_preview(session: &Session) -> state::SessionPreviewState {
match load_session_preview_inner(session) {
Ok(preview) => preview,
Err(error) => state::SessionPreviewState::Error(compact_preview_error(&error.to_string())),
}
}
fn load_session_preview_inner(session: &Session) -> anyhow::Result<state::SessionPreviewState> {
if !session.path().exists() {
return Ok(state::SessionPreviewState::Empty);
}
let first = scan_first_preview_message(session)?;
let recent = scan_recent_preview_message(session)?;
let limited = first.limited || recent.limited;
match (first.message, recent.message) {
(Some(first), Some(recent)) if first == recent => {
if limited {
Ok(state::SessionPreviewState::Partial(
state::SessionPreviewPartial {
first: Some(first),
most_recent: None,
reason: "scan bounds reached",
},
))
} else {
Ok(state::SessionPreviewState::Ready(state::SessionPreview {
first,
most_recent: None,
}))
}
}
(Some(first), Some(recent)) => {
if limited {
Ok(state::SessionPreviewState::Partial(
state::SessionPreviewPartial {
first: Some(first),
most_recent: Some(recent),
reason: "scan bounds reached",
},
))
} else {
Ok(state::SessionPreviewState::Ready(state::SessionPreview {
first,
most_recent: Some(recent),
}))
}
}
(Some(first), None) => Ok(if limited {
state::SessionPreviewState::Partial(state::SessionPreviewPartial {
first: Some(first),
most_recent: None,
reason: "scan bounds reached",
})
} else {
state::SessionPreviewState::Ready(state::SessionPreview {
first,
most_recent: None,
})
}),
(None, Some(recent)) => Ok(state::SessionPreviewState::Partial(
state::SessionPreviewPartial {
first: None,
most_recent: Some(recent),
reason: "scan bounds reached before first message",
},
)),
(None, None) => Ok(if limited {
state::SessionPreviewState::Partial(state::SessionPreviewPartial {
first: None,
most_recent: None,
reason: "scan bounds reached",
})
} else {
state::SessionPreviewState::Empty
}),
}
}
#[derive(Debug)]
struct PreviewScanResult {
message: Option<state::PreviewMessage>,
limited: bool,
}
fn scan_first_preview_message(session: &Session) -> anyhow::Result<PreviewScanResult> {
let file = fs::File::open(session.path())?;
let mut bytes_read = 0u64;
let mut events_read = 0usize;
for (index, line) in BufReader::new(file).lines().enumerate() {
if events_read >= SESSION_PREVIEW_MAX_FORWARD_EVENTS {
return Ok(PreviewScanResult {
message: None,
limited: true,
});
}
let line = line?;
let line_bytes = line.len() as u64 + 1;
if bytes_read.saturating_add(line_bytes) > SESSION_PREVIEW_MAX_SCAN_BYTES {
return Ok(PreviewScanResult {
message: None,
limited: true,
});
}
bytes_read = bytes_read.saturating_add(line_bytes);
if line.trim().is_empty() {
continue;
}
events_read = events_read.saturating_add(1);
let event = parse_preview_event(&line, index + 1)?;
if let Some(message) = preview_message_from_event(&event)
&& message.role == "user"
{
return Ok(PreviewScanResult {
message: Some(message),
limited: false,
});
}
}
Ok(PreviewScanResult {
message: None,
limited: false,
})
}
fn scan_recent_preview_message(session: &Session) -> anyhow::Result<PreviewScanResult> {
let mut file = fs::File::open(session.path())?;
let len = file.metadata()?.len();
let start = len.saturating_sub(SESSION_PREVIEW_MAX_SCAN_BYTES);
file.seek(SeekFrom::Start(start))?;
let mut buffer = Vec::new();
file.take(SESSION_PREVIEW_MAX_SCAN_BYTES)
.read_to_end(&mut buffer)?;
if start > 0 {
if let Some(newline) = buffer.iter().position(|byte| *byte == b'\n') {
buffer.drain(..=newline);
} else {
buffer.clear();
}
}
let text = String::from_utf8_lossy(&buffer);
let lines = text.lines().collect::<Vec<_>>();
let mut assistant_chunks = Vec::new();
let mut events_read = 0usize;
for (reverse_index, line) in lines.iter().rev().enumerate() {
if line.trim().is_empty() {
continue;
}
if events_read >= SESSION_PREVIEW_MAX_RECENT_EVENTS {
return Ok(PreviewScanResult {
message: assistant_chunks_preview(&assistant_chunks),
limited: true,
});
}
events_read = events_read.saturating_add(1);
let event = parse_preview_event(line, lines.len().saturating_sub(reverse_index))?;
if let Some(message) = preview_message_from_event(&event) {
if message.role == "assistant" && event.kind() != Some(SessionEventKind::AssistantChunk)
{
return Ok(PreviewScanResult {
message: Some(message),
limited: start > 0,
});
}
if message.role == "assistant" {
assistant_chunks.push(message.text);
continue;
}
}
if event.kind() == Some(SessionEventKind::UserInput) {
return Ok(PreviewScanResult {
message: assistant_chunks_preview(&assistant_chunks),
limited: start > 0,
});
}
}
if let Some(message) = assistant_chunks_preview(&assistant_chunks) {
return Ok(PreviewScanResult {
message: Some(message),
limited: start > 0,
});
}
Ok(PreviewScanResult {
message: None,
limited: start > 0,
})
}
fn assistant_chunks_preview(chunks: &[String]) -> Option<state::PreviewMessage> {
if chunks.is_empty() {
return None;
}
let text = chunks.iter().rev().map(String::as_str).collect::<String>();
Some(state::PreviewMessage {
role: "assistant",
text: bounded_preview_text(&text),
})
}
fn parse_preview_event(line: &str, line_number: usize) -> anyhow::Result<SessionEvent> {
serde_json::from_str::<SessionEvent>(line)
.map_err(|error| anyhow::anyhow!("malformed session event at line {line_number}: {error}"))
}
fn preview_message_from_event(event: &SessionEvent) -> Option<state::PreviewMessage> {
let kind = event.kind()?;
let role = match kind {
SessionEventKind::UserInput => "user",
SessionEventKind::AssistantOutput | SessionEventKind::AssistantChunk => "assistant",
SessionEventKind::TurnStatus => "assistant",
_ => return None,
};
let text = match kind {
SessionEventKind::UserInput
| SessionEventKind::AssistantOutput
| SessionEventKind::AssistantChunk => event.payload.get("text")?.as_str()?.to_string(),
SessionEventKind::TurnStatus => event.turn_status_payload()?.assistant_text?,
_ => return None,
};
let text = if kind == SessionEventKind::AssistantChunk {
if text.trim().is_empty() {
return None;
}
text
} else {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
trimmed.to_string()
};
Some(state::PreviewMessage {
role,
text: if kind == SessionEventKind::AssistantChunk {
text
} else {
bounded_preview_text(&text)
},
})
}
fn bounded_preview_text(text: &str) -> String {
let sanitized = crate::tui::transcript::sanitize_preview(text);
let mut chars = sanitized.chars();
let mut bounded = chars
.by_ref()
.take(SESSION_PREVIEW_MESSAGE_MAX_CHARS)
.collect::<String>();
if chars.next().is_some() {
bounded.push('…');
}
bounded
}
fn compact_preview_error(error: &str) -> String {
crate::tui::transcript::sanitize_preview(error)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.take(1)
.collect::<Vec<_>>()
.join(PADDED_INLINE_SEPARATOR)
}
pub(crate) const SESSION_SWITCH_MAX_EVENTS: usize = 20_000;
pub(crate) const SESSION_SWITCH_MAX_BYTES: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone)]
pub(crate) struct SessionHydrationSnapshot {
pub(crate) session_usage: crate::tui::session_usage::SessionUsageLedger,
pub(crate) session_active_time: crate::sessions::active_time::SessionActiveTime,
pub(crate) last_known_session_cache_percent: Option<u64>,
pub(crate) transcript: state::TranscriptEntries,
pub(crate) session_files: state::session_files::SessionFiles,
pub(crate) thinking_transcript_identities:
std::collections::HashMap<state::ThinkingTranscriptIdentity, usize>,
pub(crate) nodes:
std::collections::HashMap<crate::output::ActivityId, crate::tui::activity::ActivityNode>,
pub(crate) roots: Vec<crate::output::ActivityId>,
pub(crate) children:
std::collections::HashMap<crate::output::ActivityId, Vec<crate::output::ActivityId>>,
pub(crate) expanded: std::collections::HashSet<crate::output::ActivityId>,
pub(crate) active_tool_transcripts:
std::collections::HashMap<crate::output::ActivityId, state::ActiveToolTranscript>,
pub(crate) selected: usize,
pub(crate) rewind_prompt: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionSwitchLoad {
pub(crate) session: Session,
pub(crate) latest_title: Option<String>,
pub(crate) snapshot: SessionHydrationSnapshot,
pub(crate) diagnostics: Vec<String>,
pub(crate) status: String,
pub(crate) preferences: Option<crate::sessions::preferences::RestoredSessionPreferences>,
}
pub(crate) fn hydrate_session_history_snapshot(
events: &[SessionEvent],
injected_content_settings: &crate::config::InjectedContentSettings,
) -> SessionHydrationSnapshot {
let mut ui_state = state::MissionControlState::default();
history::hydrate_session_history(&mut ui_state, events, injected_content_settings);
SessionHydrationSnapshot {
rewind_prompt: events
.iter()
.rev()
.take_while(|event| event.kind() != Some(SessionEventKind::UserInput))
.filter(|event| event.kind() == Some(SessionEventKind::Diagnostic))
.find_map(|event| {
event
.payload
.get("rewind_prompt")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
}),
session_usage: ui_state.session_usage,
session_active_time: ui_state.session_active_time,
last_known_session_cache_percent: ui_state.last_known_session_cache_percent,
transcript: ui_state.transcript,
session_files: ui_state.session_files,
thinking_transcript_identities: ui_state.thinking_transcript_identities,
nodes: ui_state.nodes,
roots: ui_state.roots,
children: ui_state.children,
expanded: ui_state.expanded,
active_tool_transcripts: ui_state.active_tool_transcripts,
selected: ui_state.selected,
}
}
pub(crate) fn apply_session_hydration_snapshot(
ui_state: &mut state::MissionControlState,
snapshot: SessionHydrationSnapshot,
) {
if ui_state.prompt_is_empty()
&& let Some(prompt) = &snapshot.rewind_prompt
{
ui_state.set_prompt_text(prompt, prompt.len());
}
ui_state.activity_motion = Default::default();
ui_state.session_usage = snapshot.session_usage;
ui_state.session_active_time = snapshot.session_active_time;
ui_state.last_known_session_cache_percent = snapshot.last_known_session_cache_percent;
ui_state.transcript = snapshot.transcript;
ui_state.session_files = snapshot.session_files;
ui_state.thinking_transcript_identities = snapshot.thinking_transcript_identities;
ui_state.nodes = snapshot.nodes;
ui_state.roots = snapshot.roots;
ui_state.children = snapshot.children;
ui_state.expanded = snapshot.expanded;
ui_state.active_tool_transcripts = snapshot.active_tool_transcripts;
ui_state.selected = snapshot.selected;
ui_state.invalidate_transcript_cache();
ui_state.invalidate_activity_render_cache();
}
pub(crate) fn start_session_switch_load(
manager: &SessionManager,
config: Option<&crate::config::EffectiveConfig>,
session_id: &str,
) -> Result<SessionSwitchLoad, String> {
load_session_switch(manager, config, session_id).map_err(|error| error.to_string())
}
fn load_session_switch(
manager: &SessionManager,
config: Option<&crate::config::EffectiveConfig>,
session_id: &str,
) -> anyhow::Result<SessionSwitchLoad> {
let sessions = manager.list_metadata_summaries()?;
let target_summary = sessions
.iter()
.find(|summary| summary.session.id() == session_id && summary.session.path().exists());
let Some(target_summary) = target_summary else {
anyhow::bail!("session no longer exists: {}", short_session_id(session_id));
};
let latest_title = target_summary.latest_title.clone();
let session = manager
.open_existing(session_id)?
.admit_standalone_writer()?;
load_admitted_session_switch(session, config, latest_title)
}
pub(crate) fn load_admitted_session_switch(
session: Session,
config: Option<&crate::config::EffectiveConfig>,
latest_title: Option<String>,
) -> anyhow::Result<SessionSwitchLoad> {
let session = session.admit_standalone_writer()?;
let status = format!("switched session: {}", short_session_id(session.id()));
let tolerant = session
.read_events_tolerant_bounded(SESSION_SWITCH_MAX_EVENTS, SESSION_SWITCH_MAX_BYTES)?;
let latest_title = latest_title.or_else(|| {
tolerant
.events
.iter()
.rev()
.filter(|event| event.kind() == Some(SessionEventKind::SessionTitle))
.find_map(|event| {
event
.payload
.get("title")
.and_then(serde_json::Value::as_str)
.and_then(crate::sessions::sanitize_session_title)
})
});
let injected_content_settings = config
.and_then(|config| crate::config::read_settings(&config.paths).ok())
.map(|settings| settings.hooks.injected_content)
.unwrap_or_default();
let snapshot = hydrate_session_history_snapshot(&tolerant.events, &injected_content_settings);
let mut preferences = None;
for event in &tolerant.events {
if event.session_id == session.id() {
crate::sessions::preferences::observe(&mut preferences, event);
}
}
let preferences = preferences
.zip(config)
.map(|(saved, config)| crate::sessions::preferences::restore(config, saved));
Ok(SessionSwitchLoad {
session,
latest_title,
snapshot,
preferences,
diagnostics: tolerant
.diagnostics
.into_iter()
.map(|diagnostic| diagnostic.message)
.collect(),
status,
})
}
pub(crate) fn execute_new_session_command(
shell_state: &mut ShellState,
ui_state: &mut state::MissionControlState,
) -> anyhow::Result<String> {
let status = handle_builtin(BuiltInCommand::New, None, shell_state)?;
ui_state.reset_for_new_session();
apply_footer_context(
ui_state,
shell_state.current_session.as_ref(),
None,
&shell_state.cwd,
);
Ok(status)
}
pub(crate) fn execute_prune_sessions_command(
manager: &SessionManager,
active_session_id: Option<&str>,
arg: Option<&str>,
) -> anyhow::Result<String> {
let retention_days = parse_prune_sessions_days(arg).map_err(|usage| anyhow::anyhow!(usage))?;
let report = manager.prune_sessions(retention_days, active_session_id)?;
Ok(report.summary())
}
pub(crate) fn apply_footer_context(
ui_state: &mut state::MissionControlState,
session: Option<&Session>,
latest_title: Option<&str>,
cwd: &Path,
) {
ui_state.footer_session_id = session.map(|session| session.id().to_string());
ui_state.footer_session = session
.map(|session| session_title_label(session.id(), latest_title))
.unwrap_or_else(|| "<disabled>".to_string());
ui_state.footer_cwd = cwd.display().to_string();
}
fn session_title_label(session_id: &str, latest_title: Option<&str>) -> String {
match latest_title {
Some(title) => format!("{} [{}]", title, short_session_id(session_id)),
None => short_session_id(session_id),
}
}
pub(crate) 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}")
}
pub(crate) fn current_git_branch(cwd: &Path) -> Option<String> {
let git_dir = find_git_dir(cwd)?;
let head = fs::read_to_string(git_dir.join("HEAD")).ok()?;
let head = head.trim();
if let Some(branch) = head.strip_prefix("ref: refs/heads/") {
return Some(branch.to_string());
}
None
}
fn find_git_dir(cwd: &Path) -> Option<PathBuf> {
for dir in cwd.ancestors() {
let dot_git = dir.join(".git");
if dot_git.is_dir() {
return Some(dot_git);
}
if dot_git.is_file() {
let contents = fs::read_to_string(&dot_git).ok()?;
if let Some(path) = contents.trim().strip_prefix("gitdir: ") {
let git_dir = PathBuf::from(path);
return Some(if git_dir.is_absolute() {
git_dir
} else {
dir.join(git_dir)
});
}
}
}
None
}