feat(tui): redesign session picker (#20065)

## Why

The resume/fork picker is becoming the main way users recover previous
work, but the old fixed table made sessions hard to scan once thread
names, branches, working directories, and timestamps all mattered. This
redesign makes the picker denser by default, easier to search, and safer
to inspect before resuming or forking.

<table>
<tr>
<td>
<img width="1660" height="1103" alt="CleanShot 2026-05-03 at 12 34 10"
src="https://github.com/user-attachments/assets/313ede1d-1da4-4863-acd2-56b3e27e9703"
/>
</td>
<td>
<img width="1662" height="1100" alt="CleanShot 2026-05-03 at 12 34 15"
src="https://github.com/user-attachments/assets/cfde7d5c-bab0-4994-a807-254e53f344ea"
/>
</td>
</tr>
<tr>
<td>
<img width="1664" height="1107" alt="CleanShot 2026-05-03 at 12 39 22"
src="https://github.com/user-attachments/assets/e1ee58ca-4dc5-4a35-ae0f-47562da3974c"
/>
</td>
<td>
<img width="1662" height="1100" alt="CleanShot 2026-05-03 at 12 35 09"
src="https://github.com/user-attachments/assets/9c888072-eedf-4f45-985c-0c14df28bcc7"
/>
</td>
</tr>
</table>

## What Changed

- Replaces the old session table with responsive session rows that
prioritize the session name or preview, then show timestamp, cwd, and
branch metadata.
- Makes dense view the default while keeping comfortable view available
through `Ctrl+O`.
- Persists the picker view preference in `[tui].session_picker_view`,
including active profile-scoped config.
- Adds sort/filter controls for updated time, created time, cwd, and all
sessions.
- Expands search matching across session name, preview, thread id,
branch, and cwd.
- Makes `Esc` safer in search mode: it clears an active query before
starting a new session.
- Adds lazy transcript inspection:
  - `Space` expands recent transcript context inline.
  - `Ctrl+T` opens a transcript overlay.
  - raw reasoning visibility follows `show_raw_agent_reasoning`.
- Keeps remote cwd filtering server-side for remote app-server sessions
so local path normalization does not incorrectly hide remote results.
- Updates snapshots and config schema for the new picker states and
config option.

## How to Test

1. Start Codex in a repo with several saved sessions.
2. Press `Ctrl+R` / resume picker entry point.
3. Confirm the picker opens in dense mode and shows session name or
preview, timestamp, cwd, and branch metadata.
4. Press `Ctrl+O` and confirm it switches between dense and comfortable
views.
5. Restart Codex and confirm the selected view persists.
6. Type a query that matches a branch, cwd, thread id, or session name;
confirm matching sessions appear.
7. Press `Esc` while the query is non-empty and confirm it clears search
instead of starting a new session.
8. Select a session and press `Space`; confirm recent transcript context
expands inline.
9. Press `Ctrl+T`; confirm the transcript overlay opens and respects
raw-reasoning visibility settings.

Targeted tests:
- `cargo test -p codex-tui resume_picker --no-fail-fast`
- `cargo test -p codex-core
runtime_config_resolves_session_picker_view_default_and_override`
- `cargo test -p codex-core profile_tui_rejects_unsupported_settings`
- `cargo check -p codex-thread-manager-sample`
- `cargo insta pending-snapshots`
This commit is contained in:
Felipe Coury
2026-05-05 17:32:54 -03:00
committed by GitHub
Unverified
parent 52fbbe7cdd
commit 3b2ebb368e
27 changed files with 5122 additions and 575 deletions
+14
View File
@@ -7,6 +7,7 @@ use crate::config_toml::ToolsToml;
use crate::types::AnalyticsConfigToml;
use crate::types::ApprovalsReviewer;
use crate::types::Personality;
use crate::types::SessionPickerViewMode;
use crate::types::WindowsToml;
use codex_features::FeaturesToml;
use codex_protocol::config_types::ReasoningSummary;
@@ -63,6 +64,9 @@ pub struct ConfigProfile {
pub tools: Option<ToolsToml>,
pub web_search: Option<WebSearchMode>,
pub analytics: Option<AnalyticsConfigToml>,
/// TUI settings scoped to this profile.
#[serde(default)]
pub tui: Option<ProfileTui>,
#[serde(default)]
pub windows: Option<WindowsToml>,
/// Optional feature toggles scoped to this profile.
@@ -73,6 +77,16 @@ pub struct ConfigProfile {
pub oss_provider: Option<String>,
}
/// TUI settings supported inside a named profile.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct ProfileTui {
/// Preferred layout for resume/fork session picker results.
#[serde(default)]
pub session_picker_view: Option<SessionPickerViewMode>,
}
impl From<ConfigProfile> for codex_app_server_protocol::Profile {
fn from(config_profile: ConfigProfile) -> Self {
Self {
+28
View File
@@ -57,6 +57,30 @@ const fn default_enabled() -> bool {
true
}
/// Preferred layout for the resume/fork session picker.
#[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum SessionPickerViewMode {
Comfortable,
#[default]
Dense,
}
impl SessionPickerViewMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Comfortable => "comfortable",
Self::Dense => "dense",
}
}
}
impl fmt::Display for SessionPickerViewMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Determine where Codex should store CLI auth credentials.
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
@@ -661,6 +685,10 @@ pub struct Tui {
#[serde(default)]
pub theme: Option<String>,
/// Preferred layout for resume/fork session picker results.
#[serde(default)]
pub session_picker_view: Option<SessionPickerViewMode>,
/// Keybinding overrides for the TUI.
///
/// This supports rebinding selected actions globally and by context.
+1
View File
@@ -18,6 +18,7 @@ pub use codex_config::types::ModelAvailabilityNuxConfig;
pub use codex_config::types::Notice;
pub use codex_config::types::OAuthCredentialsStoreMode;
pub use codex_config::types::OtelConfig;
pub use codex_config::types::SessionPickerViewMode;
pub use codex_config::types::ToolSuggestConfig;
pub use codex_config::types::TuiKeymap;
pub use codex_config::types::TuiNotificationSettings;
+42
View File
@@ -674,6 +674,15 @@
"tools_view_image": {
"type": "boolean"
},
"tui": {
"allOf": [
{
"$ref": "#/definitions/ProfileTui"
}
],
"default": null,
"description": "TUI settings scoped to this profile."
},
"web_search": {
"$ref": "#/definitions/WebSearchMode"
},
@@ -1876,6 +1885,22 @@
},
"type": "object"
},
"ProfileTui": {
"additionalProperties": false,
"description": "TUI settings supported inside a named profile.",
"properties": {
"session_picker_view": {
"allOf": [
{
"$ref": "#/definitions/SessionPickerViewMode"
}
],
"default": null,
"description": "Preferred layout for resume/fork session picker results."
}
},
"type": "object"
},
"ProjectConfig": {
"additionalProperties": false,
"properties": {
@@ -2163,6 +2188,14 @@
],
"type": "string"
},
"SessionPickerViewMode": {
"description": "Preferred layout for the resume/fork session picker.",
"enum": [
"comfortable",
"dense"
],
"type": "string"
},
"ShellEnvironmentPolicyInherit": {
"oneOf": [
{
@@ -2567,6 +2600,15 @@
"description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.",
"type": "boolean"
},
"session_picker_view": {
"allOf": [
{
"$ref": "#/definitions/SessionPickerViewMode"
}
],
"default": null,
"description": "Preferred layout for resume/fork session picker results."
},
"show_tooltips": {
"default": true,
"description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.",
+104
View File
@@ -44,6 +44,7 @@ use codex_config::types::NotificationCondition;
use codex_config::types::NotificationMethod;
use codex_config::types::Notifications;
use codex_config::types::SandboxWorkspaceWrite;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::SkillsConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverableType;
@@ -556,6 +557,7 @@ fn config_toml_deserializes_model_availability_nux() {
status_line_use_colors: true,
terminal_title: None,
theme: None,
session_picker_view: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig {
shown_count: HashMap::from([
@@ -2156,6 +2158,31 @@ fn tui_theme_defaults_to_none() {
assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None);
}
#[test]
fn tui_session_picker_view_deserializes_from_toml() {
let cfg = r#"
[tui]
session_picker_view = "dense"
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().and_then(|t| t.session_picker_view),
Some(SessionPickerViewMode::Dense),
);
}
#[test]
fn tui_session_picker_view_defaults_to_none() {
let cfg = r#"
[tui]
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().and_then(|t| t.session_picker_view),
None,
);
}
#[test]
fn tui_config_missing_notifications_field_defaults_to_enabled() {
let cfg = r#"
@@ -2179,6 +2206,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
status_line_use_colors: true,
terminal_title: None,
theme: None,
session_picker_view: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow_max_rows: None,
@@ -2244,6 +2272,78 @@ async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides()
);
}
#[test]
fn profile_tui_rejects_unsupported_settings() {
let err = toml::from_str::<ConfigToml>(
r#"profile = "work"
[profiles.work.tui]
theme = "dark"
"#,
)
.expect_err("profile TUI config should only accept supported fields");
assert!(err.to_string().contains("unknown field"));
assert!(err.to_string().contains("theme"));
}
#[tokio::test]
async fn runtime_config_resolves_session_picker_view_default_and_override() {
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load default config");
assert_eq!(cfg.tui_session_picker_view, SessionPickerViewMode::Dense);
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
tui: Some(Tui {
session_picker_view: Some(SessionPickerViewMode::Comfortable),
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load root override config");
assert_eq!(
cfg.tui_session_picker_view,
SessionPickerViewMode::Comfortable
);
let cfg_toml = toml::from_str::<ConfigToml>(
r#"profile = "work"
[tui]
session_picker_view = "dense"
[profiles.work.tui]
session_picker_view = "comfortable"
"#,
)
.expect("parse profile scoped tui config");
let cfg = Config::load_from_base_config_with_overrides(
cfg_toml,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load profile override config");
assert_eq!(
cfg.tui_session_picker_view,
SessionPickerViewMode::Comfortable
);
}
#[tokio::test]
async fn test_sandbox_config_parsing() {
let sandbox_full_access = r#"
@@ -6511,6 +6611,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
},
o3_profile_config
@@ -6714,6 +6815,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
@@ -6871,6 +6973,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
@@ -7013,6 +7116,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
+28
View File
@@ -3,6 +3,7 @@ use crate::path_utils::write_atomically;
use anyhow::Context;
use codex_config::CONFIG_TOML_FILE;
use codex_config::types::McpServerConfig;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::ToolSuggestDisabledTool;
use codex_features::FEATURES;
use codex_protocol::config_types::Personality;
@@ -91,6 +92,14 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
}
}
/// Produces a config edit that sets `[tui].session_picker_view = "<mode>"`.
pub fn session_picker_view_edit(mode: SessionPickerViewMode) -> ConfigEdit {
ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "session_picker_view".to_string()],
value: value(mode.to_string()),
}
}
/// Produces a config edit that sets `[tui].status_line` to an explicit ordered list.
///
/// The array is written even when it is empty so "hide the status line" stays
@@ -1316,6 +1325,25 @@ impl ConfigEditsBuilder {
self
}
pub fn set_session_picker_view(mut self, mode: SessionPickerViewMode) -> Self {
let segments = if let Some(profile) = self.profile.as_ref() {
vec![
"profiles".to_string(),
profile.clone(),
"tui".to_string(),
"session_picker_view".to_string(),
]
} else {
vec!["tui".to_string(), "session_picker_view".to_string()]
};
self.edits.push(ConfigEdit::SetPath {
segments,
value: value(mode.to_string()),
});
self
}
pub fn with_edits<I>(mut self, edits: I) -> Self
where
I: IntoIterator<Item = ConfigEdit>,
+36
View File
@@ -2,6 +2,7 @@ use super::*;
use codex_config::types::AppToolApproval;
use codex_config::types::McpServerToolConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::SessionPickerViewMode;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
#[cfg(unix)]
@@ -48,6 +49,41 @@ fn builder_with_edits_applies_custom_paths() {
assert_eq!(contents, "enabled = true\n");
}
#[test]
fn session_picker_view_edit_writes_root_tui_setting() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.with_edits([session_picker_view_edit(SessionPickerViewMode::Dense)])
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[tui]
session_picker_view = "dense"
"#;
assert_eq!(contents, expected);
}
#[test]
fn session_picker_view_builder_respects_active_profile() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.with_profile(Some("work"))
.set_session_picker_view(SessionPickerViewMode::Dense)
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[profiles.work.tui]
session_picker_view = "dense"
"#;
assert_eq!(contents, expected);
}
#[test]
fn keymap_binding_edit_writes_root_action_binding() {
let tmp = tempdir().expect("tmpdir");
+10
View File
@@ -49,6 +49,7 @@ use codex_config::types::OAuthCredentialsStoreMode;
use codex_config::types::OtelConfig;
use codex_config::types::OtelConfigToml;
use codex_config::types::OtelExporterKind;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::ToolSuggestConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverable;
@@ -547,6 +548,9 @@ pub struct Config {
/// Syntax highlighting theme override (kebab-case name).
pub tui_theme: Option<String>,
/// Preferred layout for resume/fork session picker results.
pub tui_session_picker_view: SessionPickerViewMode,
/// Terminal resize-reflow tuning knobs.
pub terminal_resize_reflow: TerminalResizeReflowConfig,
@@ -3168,6 +3172,12 @@ impl Config {
.unwrap_or(true),
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
tui_session_picker_view: config_profile
.tui
.as_ref()
.and_then(|t| t.session_picker_view)
.or_else(|| cfg.tui.as_ref().and_then(|t| t.session_picker_view))
.unwrap_or_default(),
terminal_resize_reflow,
tui_keymap: cfg
.tui
@@ -40,6 +40,7 @@ use codex_core_api::Permissions;
use codex_core_api::ProjectConfig;
use codex_core_api::RealtimeAudioConfig;
use codex_core_api::RealtimeConfig;
use codex_core_api::SessionPickerViewMode;
use codex_core_api::SessionSource;
use codex_core_api::ShellEnvironmentPolicy;
use codex_core_api::TerminalResizeReflowConfig;
@@ -200,6 +201,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
tui_raw_output_mode: false,
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
tui_keymap: TuiKeymap::default(),
tui_session_picker_view: SessionPickerViewMode::Dense,
tui_vim_mode_default: false,
cwd,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
+8 -4
View File
@@ -77,7 +77,7 @@ impl App {
return Ok(AppRunControl::Continue);
}
};
match crate::resume_picker::run_resume_picker_with_app_server(
match crate::resume_picker::run_resume_picker_from_existing_session_with_app_server(
tui,
&self.config,
/*show_all*/ false,
@@ -97,9 +97,13 @@ impl App {
}
}
}
SessionSelection::Exit
| SessionSelection::StartFresh
| SessionSelection::Fork(_) => {}
SessionSelection::Exit | SessionSelection::StartFresh => {
self.refresh_in_memory_config_from_disk_best_effort(
"closing the session picker",
)
.await;
}
SessionSelection::Fork(_) => {}
}
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
+13
View File
@@ -1430,6 +1430,11 @@ async fn run_ratatui_app(
None => None,
};
let picker_cancelled_without_selection = matches!(
session_selection,
resume_picker::SessionSelection::StartFresh
) && (cli.resume_picker || cli.fork_picker);
let mut config = match &session_selection {
resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => {
load_config_or_exit_with_fallback_cwd(
@@ -1440,6 +1445,14 @@ async fn run_ratatui_app(
)
.await
}
resume_picker::SessionSelection::StartFresh if picker_cancelled_without_selection => {
load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
cloud_requirements.clone(),
)
.await
}
_ => config,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,214 @@
use std::sync::Arc;
use crate::app_server_session::AppServerSession;
use crate::history_cell::AgentMarkdownCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::ReasoningSummaryCell;
use crate::history_cell::UserHistoryCell;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadItem;
use codex_protocol::ThreadId;
use codex_protocol::items::UserMessageItem;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
pub(crate) type TranscriptCells = Vec<Arc<dyn HistoryCell>>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RawReasoningVisibility {
Hidden,
Visible,
}
pub(crate) async fn load_session_transcript(
app_server: &mut AppServerSession,
thread_id: ThreadId,
raw_reasoning_visibility: RawReasoningVisibility,
) -> std::io::Result<TranscriptCells> {
let thread = app_server
.thread_read(thread_id, /*include_turns*/ true)
.await
.map_err(std::io::Error::other)?;
Ok(thread_to_transcript_cells(
&thread,
raw_reasoning_visibility,
))
}
pub(crate) fn thread_to_transcript_cells(
thread: &Thread,
raw_reasoning_visibility: RawReasoningVisibility,
) -> TranscriptCells {
let cwd = thread.cwd.as_path();
let mut cells: TranscriptCells = Vec::new();
for item in thread.turns.iter().flat_map(|turn| turn.items.iter()) {
match item {
ThreadItem::UserMessage { id, content } => {
let item = UserMessageItem {
id: id.clone(),
content: content
.iter()
.cloned()
.map(codex_app_server_protocol::UserInput::into_core)
.collect(),
};
cells.push(Arc::new(UserHistoryCell {
message: item.message(),
text_elements: item.text_elements(),
local_image_paths: item.local_image_paths(),
remote_image_urls: item.image_urls(),
}));
}
ThreadItem::AgentMessage { text, .. } => {
if !text.trim().is_empty() {
cells.push(Arc::new(AgentMarkdownCell::new(text.clone(), cwd)));
}
}
ThreadItem::Plan { text, .. } => {
if !text.trim().is_empty() {
cells.push(Arc::new(crate::history_cell::new_proposed_plan(
text.clone(),
cwd,
)));
}
}
ThreadItem::Reasoning {
summary, content, ..
} => {
let text = if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible)
&& !content.is_empty()
{
content.join("\n\n")
} else {
summary.join("\n\n")
};
if !text.trim().is_empty() {
cells.push(Arc::new(ReasoningSummaryCell::new(
"Reasoning".to_string(),
text,
cwd,
/*transcript_only*/ false,
)));
}
}
other => {
if let Some(cell) = fallback_transcript_cell(other) {
cells.push(Arc::new(cell));
}
}
}
}
if cells.is_empty() {
cells.push(Arc::new(PlainHistoryCell::new(vec![
"No transcript content available".italic().dim().into(),
])));
}
cells
}
fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
let lines = match item {
ThreadItem::HookPrompt { fragments, .. } => fragments
.iter()
.map(|fragment| {
vec![
"hook prompt: ".dim(),
fragment.text.trim().to_string().into(),
]
.into()
})
.collect::<Vec<_>>(),
ThreadItem::CommandExecution {
command,
status,
aggregated_output,
exit_code,
..
} => {
let mut lines: Vec<Line<'static>> =
vec![vec!["$ ".dim(), command.clone().into()].into()];
lines.push(
format!(
"status: {status:?}{}",
exit_code
.map(|code| format!(" · exit {code}"))
.unwrap_or_default()
)
.dim()
.into(),
);
if let Some(output) = aggregated_output.as_deref()
&& !output.trim().is_empty()
{
lines.extend(
output
.lines()
.map(|line| vec![" ".dim(), line.trim_end().to_string().dim()].into()),
);
}
lines
}
ThreadItem::FileChange {
changes, status, ..
} => vec![
format!("file changes: {status:?} · {} changes", changes.len())
.dim()
.into(),
],
ThreadItem::McpToolCall {
server,
tool,
status,
..
} => vec![
format!("mcp tool: {server}/{tool} · {status:?}")
.dim()
.into(),
],
ThreadItem::DynamicToolCall {
namespace,
tool,
status,
..
} => {
let name = namespace
.as_ref()
.map(|namespace| format!("{namespace}/{tool}"))
.unwrap_or_else(|| tool.clone());
vec![format!("tool: {name} · {status:?}").dim().into()]
}
ThreadItem::CollabAgentToolCall { tool, status, .. } => {
vec![format!("agent tool: {tool:?} · {status:?}").dim().into()]
}
ThreadItem::WebSearch { query, .. } => {
vec![vec!["web search: ".dim(), query.clone().into()].into()]
}
ThreadItem::ImageView { path, .. } => {
vec![format!("image: {}", path.as_path().display()).dim().into()]
}
ThreadItem::ImageGeneration {
status, saved_path, ..
} => {
let saved = saved_path
.as_ref()
.map(|path| format!(" · {}", path.as_path().display()))
.unwrap_or_default();
vec![format!("image generation: {status}{saved}").dim().into()]
}
ThreadItem::EnteredReviewMode { review, .. } => {
vec![vec!["review started: ".dim(), review.clone().into()].into()]
}
ThreadItem::ExitedReviewMode { review, .. } => {
vec![vec!["review finished: ".dim(), review.clone().into()].into()]
}
ThreadItem::ContextCompaction { .. } => {
vec!["context compacted".dim().into()]
}
ThreadItem::UserMessage { .. }
| ThreadItem::AgentMessage { .. }
| ThreadItem::Plan { .. }
| ThreadItem::Reasoning { .. } => return None,
};
(!lines.is_empty()).then(|| PlainHistoryCell::new(lines))
}
@@ -0,0 +1,5 @@
---
source: tui/src/resume_picker.rs
expression: "render_dense_row_snapshot(true, None, 120,)"
---
15m ago Propose session picker redesign with enough title text to exercise truncation
@@ -0,0 +1,5 @@
---
source: tui/src/resume_picker.rs
expression: "render_dense_row_snapshot(true, None, 100,)"
---
15m ago Propose session picker redesign with enough title text to exercise truncation
@@ -0,0 +1,5 @@
---
source: tui/src/resume_picker.rs
expression: "render_dense_row_snapshot(true, None, 48,)"
---
15m ago Propose session picker redesig...
@@ -0,0 +1,5 @@
---
source: tui/src/resume_picker.rs
expression: "render_dense_row_snapshot(false,\nSome(PathBuf::from(\"/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs\")),\n100,)"
---
15m ago Propose session picker redesign with enough title text to exercise truncation
@@ -0,0 +1,5 @@
---
source: tui/src/resume_picker.rs
expression: "render_dense_row_snapshot(true, None, 48,)"
---
15m ago Propose session picker redesig...
@@ -0,0 +1,6 @@
---
source: tui/src/resume_picker.rs
expression: terminal.backend().to_string()
---
15m ago First dense row
15m ago Second dense row
@@ -0,0 +1,14 @@
---
source: tui/src/resume_picker.rs
expression: rendered
---
⌄ Investigate picker expansion
│ Session: 019dabc1-0ef5-7431-b81c-03037f51f62c
│ Created: 1 hour ago · 2026-04-28 16:30:00
│ Updated: 15 minutes ago · 2026-04-28 17:45:00
│ Directory: /tmp/codex
│ Branch:  fcoury/session-picker
│ Conversation:
│ Show me the recent transcript
└ Here are the last few lines.
@@ -0,0 +1,7 @@
---
source: tui/src/resume_picker.rs
expression: "footer_snapshot(&state, 96, 20)"
---
───────────────────────────────────────────────────────────────────────────────── 0 / 0 · 100% ─
enter resume esc clear ctrl+c quit tab focus ←/→ option
ctrl+o comfy ctrl+t preview ctrl+e exp ↑/↓ browse
@@ -0,0 +1,7 @@
---
source: tui/src/resume_picker.rs
expression: "footer_snapshot(&state, 220, 20)"
---
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 0 / 0 · 100% ─
enter resume esc start new ctrl+c quit tab focus sort/filter ←/→ change option
ctrl+o dense view ctrl+t transcript ctrl+e expand ↑/↓ browse
@@ -0,0 +1,10 @@
---
source: tui/src/resume_picker.rs
expression: terminal.backend().to_string()
---
↑ more
item-2
10m ago ⌁ no cwd  no branch
item-3
↓ more
@@ -0,0 +1,7 @@
---
source: tui/src/resume_picker.rs
expression: terminal.backend().to_string()
---
Investigate picker expansion
15m ago ⌁ /tmp/codex
 fcoury/session-picker
@@ -0,0 +1,5 @@
---
source: tui/src/resume_picker.rs
expression: terminal.backend().to_string()
---
Type to search Filter: [Cwd] All Sort: [Updated] Created
@@ -2,7 +2,11 @@
source: tui/src/resume_picker.rs
expression: snapshot
---
Created Updated Branch CWD Conversation
16 minutes ago 42 seconds ago - - Fix resume picker timestamps
> 1 hour ago 35 minutes ago - - Investigate lazy pagination cap
2 hours ago 2 hours ago - - Explain the codebase
Fix resume picker timestamps
42s ago ⌁ no cwd  no branch
Investigate lazy pagination cap
35m ago ⌁ no cwd  no branch
Explain the codebase
2h ago ⌁ no cwd  no branch
@@ -0,0 +1,9 @@
---
source: tui/src/resume_picker.rs
expression: snapshot
---
Find pending threads and emails
- ⌁ no cwd  no branch
Plan raw scrollback mod Loading transcript…
- ⌁ no cwd branch