adding codex resume <name>

This commit is contained in:
pap
2026-01-05 22:23:52 +00:00
parent c09cdc3ae2
commit 748bfa05dd
10 changed files with 338 additions and 97 deletions

View File

@@ -284,6 +284,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
let AppExitInfo {
token_usage,
conversation_id,
resume_selector,
..
} = exit_info;
@@ -296,8 +297,10 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
codex_core::protocol::FinalOutput::from(token_usage)
)];
if let Some(session_id) = conversation_id {
let resume_cmd = format!("codex resume {session_id}");
let selector = resume_selector.or_else(|| conversation_id.as_ref().map(ToString::to_string));
if let Some(selector) = selector {
let selector = format_resume_selector_arg(&selector);
let resume_cmd = format!("codex resume {selector}");
let command = if color_enabled {
resume_cmd.cyan().to_string()
} else {
@@ -309,6 +312,15 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
lines
}
fn format_resume_selector_arg(selector: &str) -> String {
if !selector.contains(char::is_whitespace) && !selector.contains('"') {
return selector.to_string();
}
let escaped = selector.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
/// Handle the app exit and print the results. Optionally run the update action.
fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
let update_action = exit_info.update_action;
@@ -823,11 +835,13 @@ mod tests {
total_tokens: 2,
..Default::default()
};
let resume_selector = conversation.map(ToString::to_string);
AppExitInfo {
token_usage,
conversation_id: conversation
.map(ConversationId::from_string)
.map(Result::unwrap),
resume_selector,
update_action: None,
}
}
@@ -837,6 +851,7 @@ mod tests {
let exit_info = AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
};
let lines = format_exit_messages(exit_info, false);
@@ -865,6 +880,32 @@ mod tests {
assert!(lines[1].contains("\u{1b}[36m"));
}
#[test]
fn format_exit_messages_prefers_resume_selector_when_present() {
let token_usage = TokenUsage {
output_tokens: 2,
total_tokens: 2,
..Default::default()
};
let exit_info = AppExitInfo {
token_usage,
conversation_id: Some(
ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(),
),
resume_selector: Some("pap".to_string()),
update_action: None,
};
let lines = format_exit_messages(exit_info, false);
assert_eq!(
lines,
vec![
"Token usage: total=2 input=0 output=2".to_string(),
"To continue this session, run codex resume pap".to_string(),
]
);
}
#[test]
fn resume_model_flag_applies_when_no_root_flags() {
let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref());

View File

@@ -87,11 +87,13 @@ pub use rollout::RolloutRecorder;
pub use rollout::SESSIONS_SUBDIR;
pub use rollout::SessionMeta;
pub use rollout::find_conversation_path_by_id_str;
pub use rollout::find_conversation_path_by_selector;
pub use rollout::list::ConversationItem;
pub use rollout::list::ConversationsPage;
pub use rollout::list::Cursor;
pub use rollout::list::parse_cursor;
pub use rollout::list::read_head_for_summary;
pub use rollout::read_rollout_session_title;
mod function_tool;
mod state;
mod tasks;

View File

@@ -158,88 +158,65 @@ async fn traverse_directories_for_paths(
};
let mut more_matches_available = false;
let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u16>().ok()).await?;
'outer: for (_year, year_path) in year_dirs.iter() {
let day_dirs = collect_day_dirs_desc(&root).await?;
'outer: for day_path in day_dirs {
if scanned_files >= MAX_SCAN_FILES {
break;
}
let month_dirs = collect_dirs_desc(year_path, |s| s.parse::<u8>().ok()).await?;
for (_month, month_path) in month_dirs.iter() {
let day_files = collect_rollout_files_desc(&day_path).await?;
for (ts, sid, path) in day_files {
scanned_files += 1;
if items.len() == page_size {
more_matches_available = true;
break 'outer;
}
if scanned_files >= MAX_SCAN_FILES {
break 'outer;
}
let day_dirs = collect_dirs_desc(month_path, |s| s.parse::<u8>().ok()).await?;
for (_day, day_path) in day_dirs.iter() {
if scanned_files >= MAX_SCAN_FILES {
break 'outer;
if !anchor_passed {
if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) {
anchor_passed = true;
} else {
continue;
}
let mut day_files = collect_files(day_path, |name_str, path| {
if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") {
return None;
}
}
parse_timestamp_uuid_from_filename(name_str)
.map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf()))
})
.await?;
// Stable ordering within the same second: (timestamp desc, uuid desc)
day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid)));
for (ts, sid, _name_str, path) in day_files.into_iter() {
scanned_files += 1;
if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size {
more_matches_available = true;
break 'outer;
}
if !anchor_passed {
if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) {
anchor_passed = true;
} else {
continue;
}
}
if items.len() == page_size {
more_matches_available = true;
break 'outer;
}
// Read head and detect message events; stop once meta + user are found.
let summary = read_head_summary(&path, HEAD_RECORD_LIMIT)
// Read head and detect message events; stop once meta + user are found.
let summary = read_head_summary(&path, HEAD_RECORD_LIMIT)
.await
.unwrap_or_default();
if !allowed_sources.is_empty()
&& !summary
.source
.is_some_and(|source| allowed_sources.iter().any(|s| s == &source))
{
continue;
}
if let Some(matcher) = provider_matcher
&& !matcher.matches(summary.model_provider.as_deref())
{
continue;
}
// Apply filters: must have session meta and at least one user message event
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
head,
created_at,
mut updated_at,
..
} = summary;
if updated_at.is_none() {
updated_at = file_modified_rfc3339(&path)
.await
.unwrap_or_default();
if !allowed_sources.is_empty()
&& !summary
.source
.is_some_and(|source| allowed_sources.iter().any(|s| s == &source))
{
continue;
}
if let Some(matcher) = provider_matcher
&& !matcher.matches(summary.model_provider.as_deref())
{
continue;
}
// Apply filters: must have session meta and at least one user message event
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
head,
created_at,
mut updated_at,
..
} = summary;
if updated_at.is_none() {
updated_at = file_modified_rfc3339(&path)
.await
.unwrap_or(None)
.or_else(|| created_at.clone());
}
items.push(ConversationItem {
path,
head,
created_at,
updated_at,
});
}
.unwrap_or(None)
.or_else(|| created_at.clone());
}
items.push(ConversationItem {
path,
head,
created_at,
updated_at,
});
}
}
}
@@ -350,6 +327,35 @@ fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uui
Some((ts, uuid))
}
async fn collect_day_dirs_desc(sessions_root: &Path) -> io::Result<Vec<PathBuf>> {
let mut day_dirs: Vec<PathBuf> = Vec::new();
let year_dirs = collect_dirs_desc(sessions_root, |s| s.parse::<u16>().ok()).await?;
for (_year, year_path) in year_dirs {
let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
for (_month, month_path) in month_dirs {
let days = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
for (_day, day_path) in days {
day_dirs.push(day_path);
}
}
}
Ok(day_dirs)
}
async fn collect_rollout_files_desc(
day_path: &Path,
) -> io::Result<Vec<(OffsetDateTime, Uuid, PathBuf)>> {
let mut day_files = collect_files(day_path, |name_str, path| {
if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") {
return None;
}
parse_timestamp_uuid_from_filename(name_str).map(|(ts, id)| (ts, id, path.to_path_buf()))
})
.await?;
day_files.sort_by_key(|(ts, sid, _path)| (Reverse(*ts), Reverse(*sid)));
Ok(day_files)
}
struct ProviderMatcher<'a> {
filters: &'a [String],
matches_default_provider: bool,
@@ -500,3 +506,81 @@ pub async fn find_conversation_path_by_id_str(
.next()
.map(|m| root.join(m.path)))
}
/// Locate a recorded conversation rollout file by either UUID (fast path) or by matching
/// `SessionMeta.title` in the first JSONL line of each rollout file.
///
/// Assumes the invariant that the first rollout line is always the `session_meta` record.
/// If multiple sessions share the same title, returns the newest matching rollout.
pub async fn find_conversation_path_by_selector(
codex_home: &Path,
selector: &str,
) -> io::Result<Option<PathBuf>> {
if let Some(path) = find_conversation_path_by_id_str(codex_home, selector).await? {
return Ok(Some(path));
}
let normalized = selector.trim().to_lowercase();
if normalized.is_empty() {
return Ok(None);
}
let mut root = codex_home.to_path_buf();
root.push(SESSIONS_SUBDIR);
if !root.exists() {
return Ok(None);
}
use tokio::io::AsyncBufReadExt;
let day_dirs = collect_day_dirs_desc(&root).await?;
for day_path in day_dirs {
let day_files = collect_rollout_files_desc(&day_path).await?;
for (_ts, _sid, path) in day_files {
let file = tokio::fs::File::open(&path).await?;
let mut lines = tokio::io::BufReader::new(file).lines();
let first_line = lines.next_line().await?;
let Some(first_line) = first_line else {
continue;
};
let rollout_line: RolloutLine = match serde_json::from_str(first_line.trim()) {
Ok(v) => v,
Err(_) => continue,
};
let RolloutItem::SessionMeta(meta_line) = rollout_line.item else {
continue;
};
let Some(title) = meta_line.meta.title.as_deref() else {
continue;
};
if title.trim().to_lowercase() == normalized {
return Ok(Some(path));
}
}
}
Ok(None)
}
/// Read the `SessionMeta.title` from a rollout file.
///
/// Rollout files are expected to begin with a `session_meta` line.
pub async fn read_rollout_session_title(path: &Path) -> io::Result<Option<String>> {
use tokio::io::AsyncBufReadExt;
let file = tokio::fs::File::open(path).await?;
let mut lines = tokio::io::BufReader::new(file).lines();
let first_line = lines.next_line().await?;
let Some(first_line) = first_line else {
return Ok(None);
};
let rollout_line: RolloutLine =
serde_json::from_str(first_line.trim()).map_err(|e| io::Error::other(e))?;
let RolloutItem::SessionMeta(meta_line) = rollout_line.item else {
return Ok(None);
};
Ok(meta_line.meta.title)
}

View File

@@ -15,6 +15,8 @@ pub mod recorder;
pub use codex_protocol::protocol::SessionMeta;
pub(crate) use error::map_session_init_error;
pub use list::find_conversation_path_by_id_str;
pub use list::find_conversation_path_by_selector;
pub use list::read_rollout_session_title;
pub use recorder::RolloutRecorder;
pub use recorder::RolloutRecorderParams;

View File

@@ -4,6 +4,7 @@ use std::path::Path;
use std::path::PathBuf;
use codex_core::find_conversation_path_by_id_str;
use codex_core::find_conversation_path_by_selector;
use tempfile::TempDir;
use uuid::Uuid;
@@ -38,6 +39,42 @@ fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf {
file
}
fn write_minimal_rollout_with_id_and_title(
codex_home: &Path,
id: Uuid,
timestamp_fragment: &str,
title: &str,
) -> PathBuf {
let sessions = codex_home.join("sessions/2024/01/01");
std::fs::create_dir_all(&sessions).unwrap();
let file = sessions.join(format!(
"rollout-2024-01-01T{timestamp_fragment}-{id}.jsonl"
));
let mut f = std::fs::File::create(&file).unwrap();
writeln!(
f,
"{}",
serde_json::json!({
"timestamp": "2024-01-01T00:00:00.000Z",
"type": "session_meta",
"payload": {
"id": id,
"timestamp": "2024-01-01T00:00:00Z",
"cwd": ".",
"title": title,
"originator": "test",
"cli_version": "test",
"instructions": null,
"model_provider": "test-provider"
}
})
)
.unwrap();
file
}
#[tokio::test]
async fn find_locates_rollout_file_by_id() {
let home = TempDir::new().unwrap();
@@ -67,6 +104,34 @@ async fn find_handles_gitignore_covering_codex_home_directory() {
assert_eq!(found, Some(expected));
}
#[tokio::test]
async fn resolve_selector_finds_rollout_file_by_title() {
let home = TempDir::new().unwrap();
let id = Uuid::new_v4();
let expected = write_minimal_rollout_with_id_and_title(home.path(), id, "00-00-00", "My Title");
let resolved = find_conversation_path_by_selector(home.path(), "my title")
.await
.unwrap();
assert_eq!(resolved, Some(expected));
}
#[tokio::test]
async fn resolve_selector_picks_newest_when_titles_duplicate() {
let home = TempDir::new().unwrap();
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
let _older = write_minimal_rollout_with_id_and_title(home.path(), id1, "00-00-00", "Dup");
let newer = write_minimal_rollout_with_id_and_title(home.path(), id2, "00-00-01", "Dup");
let resolved = find_conversation_path_by_selector(home.path(), "dup")
.await
.unwrap();
assert_eq!(resolved, Some(newer));
}
#[tokio::test]
async fn find_ignores_granular_gitignore_rules() {
let home = TempDir::new().unwrap();

View File

@@ -55,7 +55,7 @@ use crate::cli::Command as ExecCommand;
use crate::event_processor::CodexStatus;
use crate::event_processor::EventProcessor;
use codex_core::default_client::set_default_originator;
use codex_core::find_conversation_path_by_id_str;
use codex_core::find_conversation_path_by_selector;
enum InitialOperation {
UserTurn {
@@ -501,8 +501,7 @@ async fn resolve_resume_path(
}
}
} else if let Some(id_str) = args.session_id.as_deref() {
let path = find_conversation_path_by_id_str(&config.codex_home, id_str).await?;
Ok(path)
Ok(find_conversation_path_by_selector(&config.codex_home, id_str).await?)
} else {
Ok(None)
}

View File

@@ -71,20 +71,21 @@ const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."
pub struct AppExitInfo {
pub token_usage: TokenUsage,
pub conversation_id: Option<ConversationId>,
/// Preferred selector for `codex resume`: title when set, otherwise UUID.
pub resume_selector: Option<String>,
pub update_action: Option<UpdateAction>,
}
fn session_summary(
token_usage: TokenUsage,
conversation_id: Option<ConversationId>,
resume_selector: Option<&str>,
) -> Option<SessionSummary> {
if token_usage.is_zero() {
return None;
}
let usage_line = FinalOutput::from(token_usage).to_string();
let resume_command =
conversation_id.map(|conversation_id| format!("codex resume {conversation_id}"));
let resume_command = resume_selector.map(|selector| format!("codex resume {selector}"));
Some(SessionSummary {
usage_line,
resume_command,
@@ -276,6 +277,7 @@ async fn handle_model_migration_prompt_if_needed(
return Some(AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
});
}
@@ -499,9 +501,23 @@ impl App {
}
} {}
tui.terminal.clear()?;
let conversation_id = app.chat_widget.conversation_id();
let mut resume_selector = conversation_id.as_ref().map(ToString::to_string);
if let Some(conversation_id) = &conversation_id
&& let Ok(Some(path)) = codex_core::find_conversation_path_by_id_str(
&app.config.codex_home,
&conversation_id.to_string(),
)
.await
&& let Ok(Some(title)) = codex_core::read_rollout_session_title(&path).await
&& !title.trim().is_empty()
{
resume_selector = Some(title);
}
Ok(AppExitInfo {
token_usage: app.token_usage(),
conversation_id: app.chat_widget.conversation_id(),
conversation_id,
resume_selector,
update_action: app.pending_update_action,
})
}
@@ -562,10 +578,13 @@ impl App {
.await;
match event {
AppEvent::NewSession => {
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.conversation_id(),
);
let resume_selector = self
.chat_widget
.conversation_id()
.as_ref()
.map(ToString::to_string);
let summary =
session_summary(self.chat_widget.token_usage(), resume_selector.as_deref());
self.shutdown_current_conversation().await;
let init = crate::chatwidget::ChatWidgetInit {
config: self.config.clone(),
@@ -602,9 +621,14 @@ impl App {
.await?
{
ResumeSelection::Resume(path) => {
let resume_selector = self
.chat_widget
.conversation_id()
.as_ref()
.map(ToString::to_string);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.conversation_id(),
resume_selector.as_deref(),
);
match self
.server
@@ -1643,10 +1667,8 @@ mod tests {
total_tokens: 12,
..Default::default()
};
let conversation =
ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let summary = session_summary(usage, Some(conversation)).expect("summary");
let summary =
session_summary(usage, Some("123e4567-e89b-12d3-a456-426614174000")).expect("summary");
assert_eq!(
summary.usage_line,
"Token usage: total=12 input=10 output=2"

View File

@@ -19,7 +19,7 @@ use codex_core::config::ConfigOverrides;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_overrides;
use codex_core::config::resolve_oss_provider;
use codex_core::find_conversation_path_by_id_str;
use codex_core::find_conversation_path_by_selector;
use codex_core::get_platform_sandbox;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::SandboxMode;
@@ -371,6 +371,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: Some(action),
});
}
@@ -410,6 +411,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
});
}
@@ -429,7 +431,7 @@ async fn run_ratatui_app(
// Determine resume behavior: explicit id, then resume last, then picker.
let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() {
match find_conversation_path_by_id_str(&config.codex_home, id_str).await? {
match find_conversation_path_by_selector(&config.codex_home, id_str).await? {
Some(path) => resume_picker::ResumeSelection::Resume(path),
None => {
error!("Error finding conversation path: {id_str}");
@@ -438,13 +440,14 @@ async fn run_ratatui_app(
let _ = tui.terminal.clear();
if let Err(err) = writeln!(
std::io::stdout(),
"No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions."
"No saved session found with ID or title {id_str}. Run `codex resume` without an argument to choose from existing sessions."
) {
error!("Failed to write resume error message: {err}");
}
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
});
}
@@ -483,6 +486,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
});
}

View File

@@ -87,6 +87,8 @@ use crate::history_cell::UpdateAvailableHistoryCell;
pub struct AppExitInfo {
pub token_usage: TokenUsage,
pub conversation_id: Option<ConversationId>,
/// Preferred selector for `codex resume`: title when set, otherwise UUID.
pub resume_selector: Option<String>,
pub update_action: Option<UpdateAction>,
/// ANSI-styled transcript lines to print after the TUI exits.
///
@@ -101,6 +103,7 @@ impl From<AppExitInfo> for codex_tui::AppExitInfo {
codex_tui::AppExitInfo {
token_usage: info.token_usage,
conversation_id: info.conversation_id,
resume_selector: info.resume_selector,
update_action: info.update_action.map(Into::into),
}
}
@@ -304,6 +307,7 @@ async fn handle_model_migration_prompt_if_needed(
return Some(AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
session_lines: Vec::new(),
});
@@ -581,9 +585,23 @@ impl App {
};
tui.terminal.clear()?;
let conversation_id = app.chat_widget.conversation_id();
let mut resume_selector = conversation_id.as_ref().map(ToString::to_string);
if let Some(conversation_id) = &conversation_id
&& let Ok(Some(path)) = codex_core::find_conversation_path_by_id_str(
&app.config.codex_home,
&conversation_id.to_string(),
)
.await
&& let Ok(Some(title)) = codex_core::read_rollout_session_title(&path).await
&& !title.trim().is_empty()
{
resume_selector = Some(title);
}
Ok(AppExitInfo {
token_usage: app.token_usage(),
conversation_id: app.chat_widget.conversation_id(),
conversation_id,
resume_selector,
update_action: app.pending_update_action,
session_lines,
})

View File

@@ -19,7 +19,7 @@ use codex_core::config::ConfigOverrides;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_overrides;
use codex_core::config::resolve_oss_provider;
use codex_core::find_conversation_path_by_id_str;
use codex_core::find_conversation_path_by_selector;
use codex_core::get_platform_sandbox;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::SandboxMode;
@@ -386,6 +386,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: Some(action),
session_lines: Vec::new(),
});
@@ -426,6 +427,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
session_lines: Vec::new(),
});
@@ -446,7 +448,7 @@ async fn run_ratatui_app(
// Determine resume behavior: explicit id, then resume last, then picker.
let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() {
match find_conversation_path_by_id_str(&config.codex_home, id_str).await? {
match find_conversation_path_by_selector(&config.codex_home, id_str).await? {
Some(path) => resume_picker::ResumeSelection::Resume(path),
None => {
error!("Error finding conversation path: {id_str}");
@@ -455,13 +457,14 @@ async fn run_ratatui_app(
let _ = tui.terminal.clear();
if let Err(err) = writeln!(
std::io::stdout(),
"No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions."
"No saved session found with ID or title {id_str}. Run `codex resume` without an argument to choose from existing sessions."
) {
error!("Failed to write resume error message: {err}");
}
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
session_lines: Vec::new(),
});
@@ -501,6 +504,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
session_lines: Vec::new(),
});