diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index f6f63191b5..fab78a128c 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -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, pub web_search: Option, pub analytics: Option, + /// TUI settings scoped to this profile. + #[serde(default)] + pub tui: Option, #[serde(default)] pub windows: Option, /// Optional feature toggles scoped to this profile. @@ -73,6 +77,16 @@ pub struct ConfigProfile { pub oss_provider: Option, } +/// 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, +} + impl From for codex_app_server_protocol::Profile { fn from(config_profile: ConfigProfile) -> Self { Self { diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 62ba242148..6cb9abc507 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -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, + /// Preferred layout for resume/fork session picker results. + #[serde(default)] + pub session_picker_view: Option, + /// Keybinding overrides for the TUI. /// /// This supports rebinding selected actions globally and by context. diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index aa68656d1b..e884cfae9b 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -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; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index fc8d3b2745..c40b7654ab 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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`.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index dfd0fc36d5..c35a3767cb 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -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::(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::(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::( + 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::( + 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(), }; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 8d4128900d..1362103364 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -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 = ""`. +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(mut self, edits: I) -> Self where I: IntoIterator, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 376632a93a..45af723b8f 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -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"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7dcc625c0d..cfe2100d49 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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, + /// 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 diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index f350bce2cd..9d84ece674 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -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, 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, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 068084839b..37fcdb4251 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 55c190a66d..5622c59f65 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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, }; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index dc148f0059..56dad84282 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1,24 +1,39 @@ +use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +mod transcript; + use crate::app_server_session::AppServerSession; -use crate::diff_render::display_path_for; -use crate::key_hint; +use crate::color::blend; +use crate::color::is_light; +use crate::keymap::PagerKeymap; +use crate::keymap::RuntimeKeymap; use crate::legacy_core::config::Config; +use crate::legacy_core::config::edit::ConfigEditsBuilder; +use crate::markdown::append_markdown; +use crate::pager_overlay::Overlay; use crate::session_resume::resolve_session_thread_id; +use crate::status::format_directory_display; +use crate::terminal_palette::best_color; +use crate::terminal_palette::default_bg; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_lines; use chrono::DateTime; use chrono::Utc; use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; +use codex_config::types::SessionPickerViewMode; use codex_protocol::ThreadId; use codex_utils_path as path_utils; use color_eyre::eyre::Result; @@ -29,17 +44,37 @@ use crossterm::event::KeyModifiers; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Styled as _; use ratatui::style::Stylize as _; use ratatui::text::Line; use ratatui::text::Span; +use ratatui::widgets::Clear; +use ratatui::widgets::Widget; use tokio::sync::mpsc; use tokio_stream::StreamExt; use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::warn; +use transcript::RawReasoningVisibility; +use transcript::TranscriptCells; +use transcript::load_session_transcript; use unicode_width::UnicodeWidthStr; const PAGE_SIZE: usize = 25; const LOAD_NEAR_THRESHOLD: usize = 5; +const SESSION_META_INDENT_WIDTH: usize = 2; +const SESSION_META_DATE_WIDTH: usize = 12; +const SESSION_META_FIELD_GAP_WIDTH: usize = 2; +const SESSION_META_MIN_CWD_WIDTH: usize = 30; +const SESSION_META_MAX_CWD_WIDTH: usize = 72; +const SESSION_META_BRANCH_ICON: &str = ""; +const SESSION_META_CWD_ICON: &str = "⌁"; +const FOOTER_COMPACT_BREAKPOINT: u16 = 120; +const FOOTER_HINT_LEFT_PADDING: usize = 1; +const FOOTER_HINT_GAP: usize = 3; +const PICKER_CHROME_HEIGHT: u16 = 8; +const PICKER_LIST_HORIZONTAL_INSET: u16 = 4; #[derive(Debug, Clone)] pub struct SessionTarget { @@ -70,6 +105,12 @@ pub enum SessionPickerAction { Fork, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionPickerLaunchContext { + Startup, + ExistingSession, +} + impl SessionPickerAction { fn title(self) -> &'static str { match self { @@ -99,24 +140,117 @@ struct PageLoadRequest { cursor: Option, request_token: usize, search_token: Option, + cwd_filter: Option, provider_filter: ProviderFilter, sort_key: ThreadSortKey, } +enum PickerLoadRequest { + Page(PageLoadRequest), + Preview { thread_id: ThreadId }, + Transcript { thread_id: ThreadId }, +} + #[derive(Clone)] enum ProviderFilter { Any, MatchDefault(String), } -type PageLoader = Arc; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionFilterMode { + Cwd, + All, +} +impl SessionFilterMode { + fn from_show_all(show_all: bool, filter_cwd: Option<&Path>) -> Self { + if show_all || filter_cwd.is_none() { + Self::All + } else { + Self::Cwd + } + } + + fn toggle(self, filter_cwd: Option<&Path>) -> Self { + match self { + Self::Cwd => Self::All, + Self::All if filter_cwd.is_some() => Self::Cwd, + Self::All => Self::All, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ToolbarControl { + Filter, + Sort, +} + +impl ToolbarControl { + fn previous(self) -> Self { + match self { + Self::Filter => Self::Sort, + Self::Sort => Self::Filter, + } + } + + fn next(self) -> Self { + match self { + Self::Filter => Self::Sort, + Self::Sort => Self::Filter, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionListDensity { + Comfortable, + Dense, +} + +impl SessionListDensity { + fn toggle(self) -> Self { + match self { + Self::Comfortable => Self::Dense, + Self::Dense => Self::Comfortable, + } + } +} + +impl From for SessionListDensity { + fn from(mode: SessionPickerViewMode) -> Self { + match mode { + SessionPickerViewMode::Comfortable => Self::Comfortable, + SessionPickerViewMode::Dense => Self::Dense, + } + } +} + +impl From for SessionPickerViewMode { + fn from(density: SessionListDensity) -> Self { + match density { + SessionListDensity::Comfortable => Self::Comfortable, + SessionListDensity::Dense => Self::Dense, + } + } +} + +type PickerLoader = Arc; enum BackgroundEvent { - PageLoaded { + Page { request_token: usize, search_token: Option, page: std::io::Result, }, + Preview { + thread_id: ThreadId, + preview: std::io::Result>, + }, + Transcript { + thread_id: ThreadId, + transcript: std::io::Result, + }, } #[derive(Clone)] @@ -131,12 +265,31 @@ struct PickerPage { reached_scan_cap: bool, } -/// Interactive session picker that lists app-server threads with simple search -/// and pagination. +#[derive(Clone)] +struct SessionPickerViewPersistence { + codex_home: PathBuf, + active_profile: Option, +} + +struct SessionPickerRunOptions { + show_all: bool, + filter_cwd: Option, + local_filter_cwd: Option, + action: SessionPickerAction, + launch_context: SessionPickerLaunchContext, + provider_filter: ProviderFilter, + initial_density: SessionListDensity, + view_persistence: Option, + pager_keymap: PagerKeymap, +} + +/// Interactive session picker that lists app-server threads with simple search, +/// lazy transcript previews, and pagination. /// -/// The picker displays sessions in a table with timestamp columns (created/updated), -/// git branch, working directory, and conversation preview. Users can toggle -/// between sorting by creation time and last-updated time using the Tab key. +/// Sessions render as compact multi-line records with stable metadata first and +/// the conversation preview last. Users can focus Sort/Filter toolbar controls +/// with Tab, change the focused control with the arrow keys, and expand the +/// selected session with Ctrl+E to load recent transcript context on demand. /// /// Sessions are loaded on-demand via cursor-based pagination. The backend /// `thread/list` API returns pages ordered by the selected sort key, and the @@ -152,22 +305,78 @@ pub async fn run_resume_picker_with_app_server( show_all: bool, include_non_interactive: bool, app_server: AppServerSession, +) -> Result { + run_resume_picker_with_launch_context( + tui, + config, + show_all, + include_non_interactive, + app_server, + SessionPickerLaunchContext::Startup, + ) + .await +} + +pub async fn run_resume_picker_from_existing_session_with_app_server( + tui: &mut Tui, + config: &Config, + show_all: bool, + include_non_interactive: bool, + app_server: AppServerSession, +) -> Result { + run_resume_picker_with_launch_context( + tui, + config, + show_all, + include_non_interactive, + app_server, + SessionPickerLaunchContext::ExistingSession, + ) + .await +} + +async fn run_resume_picker_with_launch_context( + tui: &mut Tui, + config: &Config, + show_all: bool, + include_non_interactive: bool, + app_server: AppServerSession, + launch_context: SessionPickerLaunchContext, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let is_remote = app_server.is_remote(); let cwd_filter = picker_cwd_filter( config.cwd.as_path(), - show_all, + /*show_all*/ false, is_remote, app_server.remote_cwd_override(), ); + let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, is_remote); + let provider_filter = picker_provider_filter(config, is_remote); + let pager_keymap = picker_pager_keymap(config)?; + let options = SessionPickerRunOptions { + show_all, + filter_cwd: cwd_filter, + local_filter_cwd, + action: SessionPickerAction::Resume, + launch_context, + provider_filter, + initial_density: SessionListDensity::from(config.tui_session_picker_view), + view_persistence: Some(SessionPickerViewPersistence { + codex_home: config.codex_home.to_path_buf(), + active_profile: config.active_profile.clone(), + }), + pager_keymap, + }; run_session_picker_with_loader( tui, - config, - show_all, - SessionPickerAction::Resume, - is_remote, - spawn_app_server_page_loader(app_server, cwd_filter, include_non_interactive, bg_tx), + options, + spawn_app_server_page_loader( + app_server, + include_non_interactive, + raw_reasoning_visibility(config), + bg_tx, + ), bg_rx, ) .await @@ -183,18 +392,35 @@ pub async fn run_fork_picker_with_app_server( let is_remote = app_server.is_remote(); let cwd_filter = picker_cwd_filter( config.cwd.as_path(), - show_all, + /*show_all*/ false, is_remote, app_server.remote_cwd_override(), ); + let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, is_remote); + let provider_filter = picker_provider_filter(config, is_remote); + let pager_keymap = picker_pager_keymap(config)?; + let options = SessionPickerRunOptions { + show_all, + filter_cwd: cwd_filter, + local_filter_cwd, + action: SessionPickerAction::Fork, + launch_context: SessionPickerLaunchContext::Startup, + provider_filter, + initial_density: SessionListDensity::from(config.tui_session_picker_view), + view_persistence: Some(SessionPickerViewPersistence { + codex_home: config.codex_home.to_path_buf(), + active_profile: config.active_profile.clone(), + }), + pager_keymap, + }; run_session_picker_with_loader( tui, - config, - show_all, - SessionPickerAction::Fork, - is_remote, + options, spawn_app_server_page_loader( - app_server, cwd_filter, /*include_non_interactive*/ false, bg_tx, + app_server, + /*include_non_interactive*/ false, + raw_reasoning_visibility(config), + bg_tx, ), bg_rx, ) @@ -203,32 +429,24 @@ pub async fn run_fork_picker_with_app_server( async fn run_session_picker_with_loader( tui: &mut Tui, - config: &Config, - show_all: bool, - action: SessionPickerAction, - is_remote: bool, - page_loader: PageLoader, + options: SessionPickerRunOptions, + picker_loader: PickerLoader, bg_rx: mpsc::UnboundedReceiver, ) -> Result { let alt = AltScreenGuard::enter(tui); - let provider_filter = if is_remote { - ProviderFilter::Any - } else { - ProviderFilter::MatchDefault(config.model_provider_id.to_string()) - }; - // Remote sessions live in the server's filesystem namespace, so the client - // process cwd is not a meaningful row filter. Local cwd filtering and explicit - // remote --cd filtering are handled server-side in thread/list. - let filter_cwd = None; - let mut state = PickerState::new( alt.tui.frame_requester(), - page_loader, - provider_filter, - show_all, - filter_cwd, - action, + picker_loader, + options.provider_filter, + options.show_all, + options.filter_cwd, + options.action, ); + state.local_filter_cwd = options.local_filter_cwd; + state.density = options.initial_density; + state.view_persistence = options.view_persistence; + state.pager_keymap = options.pager_keymap; + state.launch_context = options.launch_context; state.start_initial_load(); state.request_frame(); @@ -238,6 +456,10 @@ async fn run_session_picker_with_loader( loop { tokio::select! { Some(ev) = tui_events.next() => { + if state.overlay.is_some() { + state.handle_overlay_event(alt.tui, ev)?; + continue; + } match ev { TuiEvent::Key(key) => { if matches!(key.kind, KeyEventKind::Release) { @@ -249,11 +471,15 @@ async fn run_session_picker_with_loader( } TuiEvent::Draw | TuiEvent::Resize => { if let Ok(size) = alt.tui.terminal.size() { - let list_height = size.height.saturating_sub(4) as usize; - state.update_view_rows(list_height); + let list_height = + size.height.saturating_sub(PICKER_CHROME_HEIGHT) as usize; + state.update_viewport(list_height, list_viewport_width(size.width)); state.ensure_minimum_rows_for_view(list_height); } draw_picker(alt.tui, &state)?; + if state.note_transcript_loading_frame_drawn() { + state.open_pending_transcript_if_ready(); + } } _ => {} } @@ -269,6 +495,32 @@ async fn run_session_picker_with_loader( Ok(SessionSelection::StartFresh) } +fn raw_reasoning_visibility(config: &Config) -> RawReasoningVisibility { + if config.show_raw_agent_reasoning { + RawReasoningVisibility::Visible + } else { + RawReasoningVisibility::Hidden + } +} + +fn local_picker_cwd_filter(cwd_filter: &Option, is_remote: bool) -> Option { + if is_remote { None } else { cwd_filter.clone() } +} + +fn picker_provider_filter(config: &Config, is_remote: bool) -> ProviderFilter { + if is_remote { + ProviderFilter::Any + } else { + ProviderFilter::MatchDefault(config.model_provider_id.to_string()) + } +} + +fn picker_pager_keymap(config: &Config) -> Result { + RuntimeKeymap::from_config(&config.tui_keymap) + .map(|keymap| keymap.pager) + .map_err(|err| color_eyre::eyre::eyre!("invalid keymap configuration: {err}")) +} + fn picker_cwd_filter( config_cwd: &Path, show_all: bool, @@ -286,37 +538,57 @@ fn picker_cwd_filter( fn spawn_app_server_page_loader( app_server: AppServerSession, - cwd_filter: Option, include_non_interactive: bool, + raw_reasoning_visibility: RawReasoningVisibility, bg_tx: mpsc::UnboundedSender, -) -> PageLoader { - let (request_tx, mut request_rx) = mpsc::unbounded_channel::(); +) -> PickerLoader { + let (request_tx, mut request_rx) = mpsc::unbounded_channel::(); tokio::spawn(async move { let mut app_server = app_server; while let Some(request) = request_rx.recv().await { - let cursor = request.cursor.map(|PageCursor::AppServer(cursor)| cursor); - let page = load_app_server_page( - &mut app_server, - cursor, - cwd_filter.as_deref(), - request.provider_filter, - request.sort_key, - include_non_interactive, - ) - .await; - let _ = bg_tx.send(BackgroundEvent::PageLoaded { - request_token: request.request_token, - search_token: request.search_token, - page, - }); + match request { + PickerLoadRequest::Page(request) => { + let cursor = request.cursor.map(|PageCursor::AppServer(cursor)| cursor); + let page = load_app_server_page( + &mut app_server, + cursor, + request.cwd_filter.as_deref(), + request.provider_filter, + request.sort_key, + include_non_interactive, + ) + .await; + let _ = bg_tx.send(BackgroundEvent::Page { + request_token: request.request_token, + search_token: request.search_token, + page, + }); + } + PickerLoadRequest::Preview { thread_id } => { + let preview = load_transcript_preview(&mut app_server, thread_id).await; + let _ = bg_tx.send(BackgroundEvent::Preview { thread_id, preview }); + } + PickerLoadRequest::Transcript { thread_id } => { + let transcript = load_session_transcript( + &mut app_server, + thread_id, + raw_reasoning_visibility, + ) + .await; + let _ = bg_tx.send(BackgroundEvent::Transcript { + thread_id, + transcript, + }); + } + } } if let Err(err) = app_server.shutdown().await { warn!(%err, "Failed to shut down app-server picker session"); } }); - Arc::new(move |request: PageLoadRequest| { + Arc::new(move |request: PickerLoadRequest| { let _ = request_tx.send(request); }) } @@ -329,9 +601,6 @@ fn sort_key_label(sort_key: ThreadSortKey) -> &'static str { } } -const CREATED_COLUMN_LABEL: &str = "Created"; -const UPDATED_COLUMN_LABEL: &str = "Updated"; - /// RAII guard that ensures we leave the alt-screen on scope exit. struct AltScreenGuard<'a> { tui: &'a mut Tui, @@ -359,18 +628,33 @@ struct PickerState { seen_rows: HashSet, selected: usize, scroll_top: usize, + pending_page_down_target: Option, + frozen_footer_percent: Option, query: String, search_state: SearchState, next_request_token: usize, next_search_token: usize, - page_loader: PageLoader, + picker_loader: PickerLoader, view_rows: Option, + view_width: Option, provider_filter: ProviderFilter, - show_all: bool, + filter_mode: SessionFilterMode, filter_cwd: Option, + local_filter_cwd: Option, + toolbar_focus: ToolbarControl, + density: SessionListDensity, + launch_context: SessionPickerLaunchContext, + view_persistence: Option, action: SessionPickerAction, sort_key: ThreadSortKey, inline_error: Option, + expanded_thread_id: Option, + transcript_previews: HashMap, + transcript_cells: HashMap, + pending_transcript_open: Option, + transcript_loading_frame_shown: bool, + overlay: Option, + pager_keymap: PagerKeymap, } struct PaginationState { @@ -398,6 +682,31 @@ enum SearchState { Active { token: usize }, } +#[derive(Clone)] +enum TranscriptPreviewState { + Loading, + Loaded(Vec), + Failed, +} + +enum SessionTranscriptState { + Loading, + Loaded(TranscriptCells), + Failed, +} + +#[derive(Clone)] +struct TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker, + text: String, +} + +#[derive(Clone, Copy)] +enum TranscriptPreviewSpeaker { + User, + Assistant, +} + enum LoadTrigger { Scroll, Search { token: usize }, @@ -441,6 +750,57 @@ async fn load_app_server_page( }) } +async fn load_transcript_preview( + app_server: &mut AppServerSession, + thread_id: ThreadId, +) -> std::io::Result> { + const MAX_PREVIEW_LINES: usize = 6; + + let thread = app_server + .thread_read(thread_id, /*include_turns*/ true) + .await + .map_err(std::io::Error::other)?; + let mut lines = thread + .turns + .iter() + .flat_map(|turn| turn.items.iter()) + .filter_map(|item| match item { + ThreadItem::UserMessage { content, .. } => Some(TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::User, + text: content + .iter() + .filter_map(|input| match input { + codex_app_server_protocol::UserInput::Text { text, .. } => { + Some(text.as_str()) + } + _ => None, + }) + .collect::>() + .join(" "), + }), + ThreadItem::AgentMessage { text, .. } => Some(TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::Assistant, + text: text.clone(), + }), + _ => None, + }) + .flat_map(|line| { + line.text + .lines() + .filter(|text| !text.trim().is_empty()) + .map(move |text| TranscriptPreviewLine { + speaker: line.speaker, + text: text.trim().to_string(), + }) + .collect::>() + }) + .collect::>(); + if lines.len() > MAX_PREVIEW_LINES { + lines.drain(..lines.len() - MAX_PREVIEW_LINES); + } + Ok(lines) +} + impl SearchState { fn active_token(&self) -> Option { match self { @@ -493,6 +853,26 @@ impl Row { { return true; } + if self + .thread_id + .is_some_and(|thread_id| thread_id.to_string().to_lowercase().contains(query)) + { + return true; + } + if self + .git_branch + .as_ref() + .is_some_and(|branch| branch.to_lowercase().contains(query)) + { + return true; + } + if self + .cwd + .as_ref() + .is_some_and(|cwd| cwd.to_string_lossy().to_lowercase().contains(query)) + { + return true; + } false } } @@ -500,7 +880,7 @@ impl Row { impl PickerState { fn new( requester: FrameRequester, - page_loader: PageLoader, + picker_loader: PickerLoader, provider_filter: ProviderFilter, show_all: bool, filter_cwd: Option, @@ -520,18 +900,33 @@ impl PickerState { seen_rows: HashSet::new(), selected: 0, scroll_top: 0, + pending_page_down_target: None, + frozen_footer_percent: None, query: String::new(), search_state: SearchState::Idle, next_request_token: 0, next_search_token: 0, - page_loader, + picker_loader, view_rows: None, + view_width: None, provider_filter, - show_all, + filter_mode: SessionFilterMode::from_show_all(show_all, filter_cwd.as_deref()), + local_filter_cwd: filter_cwd.clone(), filter_cwd, + toolbar_focus: ToolbarControl::Filter, + density: SessionListDensity::Comfortable, + launch_context: SessionPickerLaunchContext::Startup, + view_persistence: None, action, sort_key: ThreadSortKey::UpdatedAt, inline_error: None, + expanded_thread_id: None, + transcript_previews: HashMap::new(), + transcript_cells: HashMap::new(), + pending_transcript_open: None, + transcript_loading_frame_shown: false, + overlay: None, + pager_keymap: RuntimeKeymap::defaults().pager, } } @@ -539,12 +934,111 @@ impl PickerState { self.requester.schedule_frame(); } + fn is_transcript_loading(&self) -> bool { + self.pending_transcript_open.is_some() + } + + fn note_transcript_loading_frame_drawn(&mut self) -> bool { + if self.pending_transcript_open.is_some() { + self.transcript_loading_frame_shown = true; + true + } else { + false + } + } + + fn open_pending_transcript_if_ready(&mut self) { + if !self.transcript_loading_frame_shown { + return; + } + let Some(thread_id) = self.pending_transcript_open else { + return; + }; + let Some(SessionTranscriptState::Loaded(cells)) = self.transcript_cells.get(&thread_id) + else { + return; + }; + self.overlay = Some(Overlay::new_transcript( + cells.clone(), + self.pager_keymap.clone(), + )); + self.pending_transcript_open = None; + self.transcript_loading_frame_shown = false; + self.request_frame(); + } + + fn begin_transcript_loading(&mut self, thread_id: ThreadId) { + self.pending_transcript_open = Some(thread_id); + self.transcript_loading_frame_shown = false; + self.request_frame(); + } + + fn handle_overlay_event(&mut self, tui: &mut Tui, event: TuiEvent) -> Result<()> { + let Some(overlay) = &mut self.overlay else { + return Ok(()); + }; + overlay.handle_event(tui, event)?; + if overlay.is_done() { + self.overlay = None; + self.request_frame(); + } + Ok(()) + } + + fn open_selected_transcript(&mut self) { + let Some(row) = self.filtered_rows.get(self.selected) else { + return; + }; + let Some(thread_id) = row.thread_id else { + self.inline_error = Some("No transcript available for this session".to_string()); + self.request_frame(); + return; + }; + + match self.transcript_cells.get(&thread_id) { + Some(SessionTranscriptState::Loaded(_)) => { + self.begin_transcript_loading(thread_id); + } + Some(SessionTranscriptState::Loading) => { + self.begin_transcript_loading(thread_id); + } + Some(SessionTranscriptState::Failed) | None => { + self.transcript_cells + .insert(thread_id, SessionTranscriptState::Loading); + self.begin_transcript_loading(thread_id); + (self.picker_loader)(PickerLoadRequest::Transcript { thread_id }); + } + } + } + + fn handle_transcript_loading_key(&mut self, key: KeyEvent) -> Option { + match key { + KeyEvent { + code: KeyCode::Char('c'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => Some(SessionSelection::Exit), + _ => None, + } + } + async fn handle_key(&mut self, key: KeyEvent) -> Result> { self.inline_error = None; + if self.is_transcript_loading() { + return Ok(self.handle_transcript_loading_key(key)); + } + if !matches!(key.code, KeyCode::PageDown) { + self.pending_page_down_target = None; + } match key { KeyEvent { code: KeyCode::Esc, .. - } => return Ok(Some(SessionSelection::StartFresh)), + } => { + if self.query.is_empty() { + return Ok(Some(SessionSelection::StartFresh)); + } + self.clear_query_preserving_selection(); + } KeyEvent { code: KeyCode::Char('c'), modifiers, @@ -552,6 +1046,48 @@ impl PickerState { } if modifiers.contains(KeyModifiers::CONTROL) => { return Ok(Some(SessionSelection::Exit)); } + KeyEvent { + code: KeyCode::Char('t'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + self.open_selected_transcript(); + } + KeyEvent { + code: KeyCode::Char('e'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + self.toggle_selected_expansion(); + } + KeyEvent { + code: KeyCode::Char('\u{0014}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^T */ => { + self.open_selected_transcript(); + } + KeyEvent { + code: KeyCode::Char('\u{0005}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^E */ => { + self.toggle_selected_expansion(); + } + KeyEvent { + code: KeyCode::Char('o'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + self.toggle_density().await; + } + KeyEvent { + code: KeyCode::Char('\u{000f}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^O */ => { + self.toggle_density().await; + } KeyEvent { code: KeyCode::Enter, .. @@ -634,22 +1170,66 @@ impl PickerState { } } KeyEvent { - code: KeyCode::PageDown, + code: KeyCode::Home, .. } => { if !self.filtered_rows.is_empty() { - let step = self.view_rows.unwrap_or(10).max(1); - let max_index = self.filtered_rows.len().saturating_sub(1); - self.selected = (self.selected + step).min(max_index); + self.selected = 0; + self.ensure_selected_visible(); + self.request_frame(); + } + } + KeyEvent { + code: KeyCode::End, .. + } => { + if !self.filtered_rows.is_empty() { + self.selected = self.filtered_rows.len().saturating_sub(1); self.ensure_selected_visible(); self.maybe_load_more_for_scroll(); self.request_frame(); } } + KeyEvent { + code: KeyCode::PageDown, + .. + } => { + if !self.filtered_rows.is_empty() { + let step = self.view_rows.unwrap_or(10).max(1); + let target = self.selected.saturating_add(step); + let max_index = self.filtered_rows.len().saturating_sub(1); + if target > max_index && self.pagination.next_cursor.is_some() { + self.pending_page_down_target = Some(target); + self.load_more_if_needed(LoadTrigger::Scroll); + } else { + self.selected = target.min(max_index); + self.ensure_selected_visible(); + self.maybe_load_more_for_scroll(); + } + self.request_frame(); + } + } KeyEvent { code: KeyCode::Tab, .. } => { - self.toggle_sort_key(); + self.focus_next_toolbar_control(); + self.request_frame(); + } + KeyEvent { + code: KeyCode::BackTab, + .. + } => { + self.focus_previous_toolbar_control(); + self.request_frame(); + } + KeyEvent { + code: KeyCode::Left, + .. + } + | KeyEvent { + code: KeyCode::Right, + .. + } => { + self.change_focused_toolbar_value(); self.request_frame(); } KeyEvent { @@ -686,6 +1266,8 @@ impl PickerState { self.filtered_rows.clear(); self.seen_rows.clear(); self.selected = 0; + self.pending_page_down_target = None; + self.frozen_footer_percent = None; let search_token = if self.query.is_empty() { self.search_state = SearchState::Idle; @@ -703,18 +1285,19 @@ impl PickerState { }); self.request_frame(); - (self.page_loader)(PageLoadRequest { + (self.picker_loader)(PickerLoadRequest::Page(PageLoadRequest { cursor: None, request_token, search_token, + cwd_filter: self.active_cwd_filter(), provider_filter: self.provider_filter.clone(), sort_key: self.sort_key, - }); + })); } async fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> { match event { - BackgroundEvent::PageLoaded { + BackgroundEvent::Page { request_token, search_token, page, @@ -729,9 +1312,44 @@ impl PickerState { self.pagination.loading = LoadingState::Idle; let page = page.map_err(color_eyre::Report::from)?; self.ingest_page(page); + self.complete_pending_page_down(); let completed_token = pending.search_token.or(search_token); self.continue_search_if_token_matches(completed_token); } + BackgroundEvent::Preview { thread_id, preview } => { + self.transcript_previews.insert( + thread_id, + match preview { + Ok(lines) => TranscriptPreviewState::Loaded(lines), + Err(_) => TranscriptPreviewState::Failed, + }, + ); + self.request_frame(); + } + BackgroundEvent::Transcript { + thread_id, + transcript, + } => match transcript { + Ok(cells) => { + let should_open = self.pending_transcript_open == Some(thread_id); + self.transcript_cells + .insert(thread_id, SessionTranscriptState::Loaded(cells.clone())); + if should_open { + self.open_pending_transcript_if_ready(); + } + self.request_frame(); + } + Err(_) => { + self.transcript_cells + .insert(thread_id, SessionTranscriptState::Failed); + if self.pending_transcript_open == Some(thread_id) { + self.pending_transcript_open = None; + self.transcript_loading_frame_shown = false; + self.inline_error = Some("Could not load transcript preview".to_string()); + } + self.request_frame(); + } + }, } Ok(()) } @@ -741,6 +1359,7 @@ impl PickerState { self.pagination.num_scanned_files = 0; self.pagination.reached_scan_cap = false; self.pagination.loading = LoadingState::Idle; + self.frozen_footer_percent = None; } fn ingest_page(&mut self, page: PickerPage) { @@ -770,6 +1389,27 @@ impl PickerState { self.apply_filter(); } + fn complete_pending_page_down(&mut self) { + let Some(target) = self.pending_page_down_target else { + return; + }; + if self.filtered_rows.is_empty() { + return; + } + + let max_index = self.filtered_rows.len().saturating_sub(1); + if target > max_index && self.pagination.next_cursor.is_some() { + self.load_more_if_needed(LoadTrigger::Scroll); + return; + } + + self.pending_page_down_target = None; + self.selected = target.min(max_index); + self.ensure_selected_visible(); + self.maybe_load_more_for_scroll(); + self.request_frame(); + } + fn apply_filter(&mut self) { let base_iter = self .all_rows @@ -792,10 +1432,10 @@ impl PickerState { } fn row_matches_filter(&self, row: &Row) -> bool { - if self.show_all { + if self.filter_mode == SessionFilterMode::All { return true; } - let Some(filter_cwd) = self.filter_cwd.as_ref() else { + let Some(filter_cwd) = self.local_filter_cwd.as_ref() else { return true; }; let Some(row_cwd) = row.cwd.as_ref() else { @@ -828,6 +1468,26 @@ impl PickerState { self.load_more_if_needed(LoadTrigger::Search { token }); } + fn clear_query_preserving_selection(&mut self) { + let selected_key = self + .filtered_rows + .get(self.selected) + .and_then(Row::seen_key); + self.query.clear(); + self.search_state = SearchState::Idle; + self.apply_filter(); + if let Some(selected_key) = selected_key + && let Some(index) = self + .filtered_rows + .iter() + .position(|row| row.seen_key().as_ref() == Some(&selected_key)) + { + self.selected = index; + self.ensure_selected_visible(); + self.request_frame(); + } + } + fn continue_search_if_needed(&mut self) { let Some(token) = self.search_state.active_token() else { return; @@ -860,20 +1520,15 @@ impl PickerState { self.scroll_top = 0; return; } - let capacity = self.view_rows.unwrap_or(self.filtered_rows.len()).max(1); - + let viewport_rows = self.view_rows.unwrap_or(usize::MAX).max(1); if self.selected < self.scroll_top { self.scroll_top = self.selected; - } else { - let last_visible = self.scroll_top.saturating_add(capacity - 1); - if self.selected > last_visible { - self.scroll_top = self.selected.saturating_sub(capacity - 1); - } } - - let max_start = self.filtered_rows.len().saturating_sub(capacity); - if self.scroll_top > max_start { - self.scroll_top = max_start; + while self.rendered_height_between(self.scroll_top, self.selected) + > self.available_content_rows(viewport_rows) + && self.scroll_top < self.selected + { + self.scroll_top += 1; } } @@ -881,10 +1536,15 @@ impl PickerState { if minimum_rows == 0 { return; } - if self.filtered_rows.len() >= minimum_rows { + if self.pagination.loading.is_pending() || self.pagination.next_cursor.is_none() { return; } - if self.pagination.loading.is_pending() || self.pagination.next_cursor.is_none() { + let rendered_rows = if self.filtered_rows.is_empty() { + 0 + } else { + self.rendered_height_between(/*start*/ 0, self.filtered_rows.len() - 1) + }; + if rendered_rows >= self.available_content_rows(minimum_rows) { return; } if let Some(token) = self.search_state.active_token() { @@ -894,8 +1554,9 @@ impl PickerState { } } - fn update_view_rows(&mut self, rows: usize) { + fn update_viewport(&mut self, rows: usize, width: u16) { self.view_rows = if rows == 0 { None } else { Some(rows) }; + self.view_width = Some(width); self.ensure_selected_visible(); } @@ -922,6 +1583,7 @@ impl PickerState { let Some(cursor) = self.pagination.next_cursor.clone() else { return; }; + self.freeze_footer_percent(); let request_token = self.allocate_request_token(); let search_token = match trigger { LoadTrigger::Scroll => None, @@ -933,13 +1595,19 @@ impl PickerState { }); self.request_frame(); - (self.page_loader)(PageLoadRequest { + (self.picker_loader)(PickerLoadRequest::Page(PageLoadRequest { cursor: Some(cursor), request_token, search_token, + cwd_filter: self.active_cwd_filter(), provider_filter: self.provider_filter.clone(), sort_key: self.sort_key, - }); + })); + } + + fn freeze_footer_percent(&mut self) { + let list_height = self.view_rows.unwrap_or_default().min(u16::MAX as usize) as u16; + self.frozen_footer_percent = Some(picker_footer_scroll_percent(self, list_height)); } fn allocate_request_token(&mut self) -> usize { @@ -966,6 +1634,163 @@ impl PickerState { }; self.start_initial_load(); } + + fn toggle_filter_mode(&mut self) { + let next_filter_mode = self.filter_mode.toggle(self.filter_cwd.as_deref()); + if self.filter_mode == next_filter_mode { + return; + } + self.filter_mode = next_filter_mode; + self.start_initial_load(); + } + + fn active_cwd_filter(&self) -> Option { + match self.filter_mode { + SessionFilterMode::Cwd => self.filter_cwd.clone(), + SessionFilterMode::All => None, + } + } + + fn focus_previous_toolbar_control(&mut self) { + self.toolbar_focus = self.toolbar_focus.previous(); + } + + fn focus_next_toolbar_control(&mut self) { + self.toolbar_focus = self.toolbar_focus.next(); + } + + fn change_focused_toolbar_value(&mut self) { + match self.toolbar_focus { + ToolbarControl::Sort => self.toggle_sort_key(), + ToolbarControl::Filter => self.toggle_filter_mode(), + } + } + + async fn toggle_density(&mut self) { + self.density = self.density.toggle(); + self.ensure_selected_visible(); + if let Err(err) = self.persist_density().await { + warn!(error = %err, "failed to persist session picker view mode"); + self.inline_error = Some(format!("Failed to save view mode: {err}")); + } + self.request_frame(); + } + + async fn persist_density(&self) -> Result<()> { + let Some(persistence) = &self.view_persistence else { + return Ok(()); + }; + + ConfigEditsBuilder::new(&persistence.codex_home) + .with_profile(persistence.active_profile.as_deref()) + .set_session_picker_view(SessionPickerViewMode::from(self.density)) + .apply() + .await + .map_err(|err| color_eyre::eyre::eyre!("failed to write config.toml: {err}"))?; + + Ok(()) + } + + fn toggle_selected_expansion(&mut self) { + let Some(row) = self.filtered_rows.get(self.selected) else { + return; + }; + let Some(thread_id) = row.thread_id else { + return; + }; + if self.expanded_thread_id == Some(thread_id) { + self.expanded_thread_id = None; + self.request_frame(); + return; + } + self.expanded_thread_id = Some(thread_id); + if let std::collections::hash_map::Entry::Vacant(e) = + self.transcript_previews.entry(thread_id) + { + e.insert(TranscriptPreviewState::Loading); + (self.picker_loader)(PickerLoadRequest::Preview { thread_id }); + } + self.request_frame(); + } + + fn rendered_height_between(&self, start: usize, end_inclusive: usize) -> usize { + self.filtered_rows + .get(start..=end_inclusive) + .unwrap_or_default() + .iter() + .enumerate() + .map(|(offset, row)| { + let row_idx = start + offset; + let is_selected = row_idx == self.selected; + let is_expanded = is_selected + && row.thread_id.is_some() + && self.expanded_thread_id == row.thread_id; + render_session_lines( + row, + self, + is_selected, + is_expanded, + /*is_zebra*/ false, + self.view_width.unwrap_or(u16::MAX), + ) + .len() + }) + .sum::() + + self.row_separator_height() * end_inclusive.saturating_sub(start) + } + + fn has_more_above(&self) -> bool { + self.scroll_top > 0 + } + + fn has_more_below(&self, viewport_height: usize) -> bool { + if self.filtered_rows.is_empty() { + return false; + } + if self.pagination.next_cursor.is_some() { + return true; + } + let capacity = self.available_content_rows(viewport_height); + let mut used = 0usize; + for (offset, row) in self.filtered_rows[self.scroll_top..].iter().enumerate() { + let row_idx = self.scroll_top + offset; + let is_selected = row_idx == self.selected; + let is_expanded = + is_selected && row.thread_id.is_some() && self.expanded_thread_id == row.thread_id; + let row_height = render_session_lines( + row, + self, + is_selected, + is_expanded, + /*is_zebra*/ false, + self.view_width.unwrap_or(u16::MAX), + ) + .len(); + let separator_height = usize::from(offset > 0) * self.row_separator_height(); + if used + separator_height + row_height > capacity { + return true; + } + used += separator_height + row_height; + } + false + } + + fn available_content_rows(&self, viewport_height: usize) -> usize { + viewport_height + .saturating_sub(usize::from(self.has_more_above())) + .saturating_sub(usize::from( + self.pagination.next_cursor.is_some() + || self.selected + 1 < self.filtered_rows.len(), + )) + .max(1) + } + + fn row_separator_height(&self) -> usize { + match self.density { + SessionListDensity::Comfortable => 1, + SessionListDensity::Dense => 0, + } + } } fn row_from_app_server_thread(thread: Thread) -> Option { @@ -1036,83 +1861,582 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { let height = tui.terminal.size()?.height; tui.draw(height, |frame| { let area = frame.area(); - let [header, search, columns, list, hint] = Layout::vertical([ + let [header, _header_gap, search, _search_gap, list, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), - Constraint::Min(area.height.saturating_sub(4)), Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(PICKER_CHROME_HEIGHT)), + Constraint::Length(4), ]) .areas(area); + let chrome = |area: Rect| { + Rect::new( + area.x.saturating_add(1), + area.y, + area.width.saturating_sub(2), + area.height, + ) + }; + // Header - let header_line: Line = vec![ - state.action.title().bold().cyan(), - " ".into(), - "Sort:".dim(), - " ".into(), - sort_key_label(state.sort_key).magenta(), - ] - .into(); - frame.render_widget_ref(header_line, header); + let header_title = if default_bg().is_some_and(is_light) { + state.action.title().bold().fg(best_color((0, 100, 0))) + } else { + state.action.title().bold().cyan() + }; + let header_line: Line = vec![header_title].into(); + frame.render_widget_ref(header_line, chrome(header)); // Search line - frame.render_widget_ref(search_line(state), search); + let search = chrome(search); + frame.render_widget_ref(search_line(state, search.width), search); - let metrics = calculate_column_metrics( - &state.filtered_rows, - state.show_all, - state.relative_time_reference.unwrap_or_else(Utc::now), + let list = Rect::new( + list.x.saturating_add(2), + list.y, + list_viewport_width(list.width), + list.height, ); + render_list(frame, list, state); + if state.is_transcript_loading() { + render_transcript_loading_overlay(frame, list); + } - // Column headers and list - render_column_headers(frame, columns, &metrics, state.sort_key); - render_list(frame, list, state, &metrics); - - // Hint line - let action_label = state.action.action_label(); - let hint_line: Line = vec![ - key_hint::plain(KeyCode::Enter).into(), - format!(" to {action_label} ").dim(), - " ".dim(), - key_hint::plain(KeyCode::Esc).into(), - " to start new ".dim(), - " ".dim(), - key_hint::ctrl(KeyCode::Char('c')).into(), - " to quit ".dim(), - " ".dim(), - key_hint::plain(KeyCode::Tab).into(), - " to toggle sort ".dim(), - " ".dim(), - key_hint::plain(KeyCode::Up).into(), - "/".dim(), - key_hint::plain(KeyCode::Down).into(), - " to browse".dim(), - ] - .into(); - frame.render_widget_ref(hint_line, hint); + render_picker_footer(frame, footer, state, list.height); }) } -fn search_line(state: &PickerState) -> Line<'_> { +fn list_viewport_width(width: u16) -> u16 { + width.saturating_sub(PICKER_LIST_HORIZONTAL_INSET) +} + +fn search_line(state: &PickerState, width: u16) -> Line<'_> { if let Some(error) = state.inline_error.as_deref() { return Line::from(error.red()); } - if state.query.is_empty() { - return Line::from("Type to search".dim()); + let search = if state.query.is_empty() { + "Type to search".dim() + } else { + format!("Search: {}", state.query).into() + }; + let mut toolbar = toolbar_line(state, /*compact*/ false); + if toolbar.width() as u16 > width.saturating_sub(2) { + toolbar = toolbar_line(state, /*compact*/ true); } - Line::from(format!("Search: {}", state.query)) + let search_width = UnicodeWidthStr::width(search.content.as_ref()); + let toolbar_width = toolbar.width(); + let spacer_width = width + .saturating_sub((search_width + toolbar_width) as u16) + .max(2) as usize; + let available_search_width = width + .saturating_sub(toolbar_width as u16) + .saturating_sub(spacer_width as u16) as usize; + let search = if search_width > available_search_width { + let truncated = truncate_text(search.content.as_ref(), available_search_width); + if state.query.is_empty() { + truncated.dim() + } else { + truncated.into() + } + } else { + search + }; + + let mut spans = vec![search, " ".repeat(spacer_width).into()]; + spans.extend(toolbar.spans); + spans.into() } -fn render_list( +fn toolbar_line(state: &PickerState, compact: bool) -> Line<'static> { + let mut spans = Vec::new(); + spans.extend(filter_control_spans(state, compact)); + spans.push(" ".dim()); + spans.extend(sort_control_spans(state, compact)); + spans.into() +} + +fn sort_control_spans(state: &PickerState, compact: bool) -> Vec> { + let sort_focused = state.toolbar_focus == ToolbarControl::Sort; + if compact { + return vec![ + "Sort:".dim(), + toolbar_value( + sort_key_label(state.sort_key), + /*active*/ true, + sort_focused, + ), + ]; + } + vec![ + "Sort: ".dim(), + toolbar_value( + sort_key_label(ThreadSortKey::UpdatedAt), + state.sort_key == ThreadSortKey::UpdatedAt, + sort_focused, + ), + toolbar_value( + sort_key_label(ThreadSortKey::CreatedAt), + state.sort_key == ThreadSortKey::CreatedAt, + sort_focused, + ), + ] +} + +fn filter_control_spans(state: &PickerState, compact: bool) -> Vec> { + let filter_focused = state.toolbar_focus == ToolbarControl::Filter; + if compact || state.filter_cwd.is_none() { + return vec![ + "Filter:".dim(), + toolbar_value( + filter_mode_label(state.filter_mode), + /*active*/ true, + filter_focused, + ), + ]; + } + vec![ + "Filter: ".dim(), + toolbar_value( + filter_mode_label(SessionFilterMode::Cwd), + state.filter_mode == SessionFilterMode::Cwd, + filter_focused, + ), + toolbar_value( + filter_mode_label(SessionFilterMode::All), + state.filter_mode == SessionFilterMode::All, + filter_focused, + ), + ] +} + +fn toolbar_value(label: &'static str, active: bool, focused: bool) -> Span<'static> { + if active { + let value = format!("[{label}]"); + if focused { + value.magenta() + } else { + value.into() + } + } else { + format!(" {label} ").dim() + } +} + +fn filter_mode_label(filter_mode: SessionFilterMode) -> &'static str { + match filter_mode { + SessionFilterMode::Cwd => "Cwd", + SessionFilterMode::All => "All", + } +} + +struct PickerFooterHint { + key: &'static str, + wide_label: String, + compact_label: String, + priority: u8, +} + +fn render_picker_footer( frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState, - metrics: &ColumnMetrics, + list_height: u16, ) { + if area.width == 0 || area.height == 0 { + return; + } + + let separator = Rect::new(area.x, area.y, area.width, 1); + render_picker_footer_separator( + frame, + separator, + picker_footer_progress_label(state, list_height, area.width), + ); + + let lines = footer_hint_lines(state, area.width); + for (idx, line) in lines.into_iter().enumerate() { + let y = area.y.saturating_add(1 + idx as u16); + if y >= area.bottom() { + break; + } + frame.render_widget_ref(line, Rect::new(area.x, y, area.width, 1)); + } +} + +fn render_picker_footer_separator( + frame: &mut crate::custom_terminal::Frame, + area: Rect, + progress_label: String, +) { + if area.width == 0 { + return; + } + + let separator = "─".repeat(area.width as usize); + frame.render_widget_ref(Line::from(separator.dim()), area); + + let progress_width = UnicodeWidthStr::width(progress_label.as_str()) as u16; + if progress_width < area.width { + let percent_area = Rect::new( + area.x + area.width - progress_width - 1, + area.y, + progress_width, + 1, + ); + frame.render_widget_ref(Line::from(progress_label.dim()), percent_area); + } +} + +fn picker_footer_progress_label(state: &PickerState, list_height: u16, width: u16) -> String { + let position = if state.filtered_rows.is_empty() { + 0 + } else { + state.selected.saturating_add(1) + }; + let total = if state.pagination.loading.is_pending() { + format!("{}…", state.filtered_rows.len()) + } else { + state.filtered_rows.len().to_string() + }; + let percent = picker_footer_percent(state, list_height); + let labels = [ + format!(" {position} / {total} · {percent}% "), + format!(" {position}/{total} · {percent}% "), + format!(" {percent}% "), + ]; + labels + .into_iter() + .find(|label| UnicodeWidthStr::width(label.as_str()) < width as usize) + .unwrap_or_default() +} + +fn picker_footer_percent(state: &PickerState, list_height: u16) -> u8 { + if state.pagination.loading.is_pending() { + return state.frozen_footer_percent.unwrap_or_else(|| { + if state.filtered_rows.is_empty() { + 0 + } else { + picker_footer_scroll_percent(state, list_height) + } + }); + } + + picker_footer_scroll_percent(state, list_height) +} + +fn picker_footer_scroll_percent(state: &PickerState, list_height: u16) -> u8 { + if state.filtered_rows.is_empty() { + return 100; + } + + let content_rows = state.available_content_rows(list_height as usize); + let total_height = + state.rendered_height_between(/*start*/ 0, state.filtered_rows.len() - 1); + let max_scroll = total_height.saturating_sub(content_rows); + if max_scroll == 0 { + return 100; + } + let remaining_height = + state.rendered_height_between(state.scroll_top, state.filtered_rows.len() - 1); + if remaining_height <= content_rows { + return 100; + } + + let skipped_height = if state.scroll_top == 0 { + 0 + } else { + state.rendered_height_between(/*start*/ 0, state.scroll_top - 1) + }; + (((skipped_height.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() as u8 +} + +fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { + if state.is_transcript_loading() { + let hints = [ + PickerFooterHint { + key: "loading", + wide_label: String::from("transcript"), + compact_label: String::from("transcript"), + priority: 0, + }, + PickerFooterHint { + key: "ctrl+c", + wide_label: String::from("quit"), + compact_label: String::from("quit"), + priority: 1, + }, + ]; + let line = fit_footer_hints(&hints, FooterHintLabelMode::Wide, width) + .or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::Compact, width)) + .or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::KeyOnly, width)) + .unwrap_or_default(); + return vec![line, Line::default()]; + } + + let action_label = state.action.action_label(); + let (esc_label, esc_compact_label) = if state.query.is_empty() { + match state.launch_context { + SessionPickerLaunchContext::Startup => ("start new", "new"), + SessionPickerLaunchContext::ExistingSession => ("exit", "exit"), + } + } else { + ("clear search", "clear") + }; + let ctrl_c_label = match state.launch_context { + SessionPickerLaunchContext::Startup => "quit", + SessionPickerLaunchContext::ExistingSession => "exit", + }; + let density_label = match state.density { + SessionListDensity::Comfortable => "dense view", + SessionListDensity::Dense => "comfortable view", + }; + let density_compact_label = match state.density { + SessionListDensity::Comfortable => "dense", + SessionListDensity::Dense => "comfy", + }; + let first_row_hints = vec![ + PickerFooterHint { + key: "enter", + wide_label: action_label.to_string(), + compact_label: action_label.to_string(), + priority: 0, + }, + PickerFooterHint { + key: "esc", + wide_label: esc_label.to_string(), + compact_label: esc_compact_label.to_string(), + priority: 1, + }, + PickerFooterHint { + key: "ctrl+c", + wide_label: ctrl_c_label.to_string(), + compact_label: ctrl_c_label.to_string(), + priority: 2, + }, + PickerFooterHint { + key: "tab", + wide_label: String::from("focus sort/filter"), + compact_label: String::from("focus"), + priority: 7, + }, + PickerFooterHint { + key: "←/→", + wide_label: String::from("change option"), + compact_label: String::from("option"), + priority: 8, + }, + ]; + let second_row_hints = vec![ + PickerFooterHint { + key: "ctrl+o", + wide_label: density_label.to_string(), + compact_label: density_compact_label.to_string(), + priority: 3, + }, + PickerFooterHint { + key: "ctrl+t", + wide_label: String::from("transcript"), + compact_label: String::from("preview"), + priority: 4, + }, + PickerFooterHint { + key: "ctrl+e", + wide_label: String::from("expand"), + compact_label: String::from("exp"), + priority: 6, + }, + PickerFooterHint { + key: "↑/↓", + wide_label: String::from("browse"), + compact_label: String::from("browse"), + priority: 5, + }, + ]; + + vec![ + hint_line_for_row(&first_row_hints, width), + hint_line_for_row(&second_row_hints, width), + ] +} + +fn hint_line_for_row(hints: &[PickerFooterHint], width: u16) -> Line<'static> { + if width >= FOOTER_COMPACT_BREAKPOINT + && let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Wide, width) + { + return line; + } + if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Compact, width) { + return line; + } + if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::KeyOnly, width) { + return line; + } + + let mut retained = (0..hints.len()).collect::>(); + retained.sort_by_key(|idx| hints[*idx].priority); + for retain_count in (1..=retained.len()).rev() { + let mut candidate_indices = retained[..retain_count].to_vec(); + candidate_indices.sort_unstable(); + let candidate = candidate_indices + .iter() + .map(|idx| &hints[*idx]) + .collect::>(); + if let Some(line) = fit_footer_hint_refs(&candidate, FooterHintLabelMode::KeyOnly, width) { + return line; + } + } + Line::default() +} + +fn render_transcript_loading_overlay(frame: &mut crate::custom_terminal::Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + + let message = "Loading transcript…"; + let message_width = UnicodeWidthStr::width(message) as u16; + let overlay_width = if area.width >= message_width.saturating_add(10) { + message_width + 10 + } else { + area.width + }; + let overlay_height = if area.height >= 3 { 3 } else { 1 }; + let overlay = Rect::new( + area.x + area.width.saturating_sub(overlay_width) / 2, + area.y + area.height.saturating_sub(overlay_height) / 2, + overlay_width, + overlay_height, + ); + let style = transcript_loading_overlay_style(); + for y in overlay.y..overlay.bottom() { + for x in overlay.x..overlay.right() { + frame.buffer[(x, y)].set_symbol(" ").set_style(style); + } + } + + let message = truncate_text(message, overlay.width as usize); + let message_width = UnicodeWidthStr::width(message.as_str()) as u16; + let line = Rect::new( + overlay.x + overlay.width.saturating_sub(message_width) / 2, + overlay.y + overlay.height / 2, + message_width.min(overlay.width), + 1, + ); + frame.render_widget_ref(Line::from(message.bold()), line); +} + +fn transcript_loading_overlay_style() -> Style { + let Some(bg) = default_bg() else { + return Style::default().bg(Color::DarkGray); + }; + let (overlay, alpha) = if is_light(bg) { + ((0, 0, 0), 0.08) + } else { + ((255, 255, 255), 0.14) + }; + Style::default().bg(best_color(blend(overlay, bg, alpha))) +} + +#[derive(Clone, Copy)] +enum FooterHintLabelMode { + Wide, + Compact, + KeyOnly, +} + +fn fit_footer_hints( + hints: &[PickerFooterHint], + mode: FooterHintLabelMode, + width: u16, +) -> Option> { + let hint_refs = hints.iter().collect::>(); + fit_footer_hint_refs(&hint_refs, mode, width) +} + +fn fit_footer_hint_refs( + hints: &[&PickerFooterHint], + mode: FooterHintLabelMode, + width: u16, +) -> Option> { + let gap_width = FOOTER_HINT_GAP; + if footer_hints_width(hints, mode, gap_width) > width as usize { + return None; + } + + let mut spans = vec![ + " ".repeat(FOOTER_HINT_LEFT_PADDING) + .set_style(footer_hint_label_style()), + ]; + for (idx, hint) in hints.iter().enumerate() { + if idx > 0 { + spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style())); + } + spans.push(hint.key.set_style(footer_hint_key_style())); + let label = match mode { + FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()), + FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()), + FooterHintLabelMode::KeyOnly => None, + }; + if let Some(label) = label { + spans.push(" ".set_style(footer_hint_label_style())); + spans.push(label.to_string().set_style(footer_hint_label_style())); + } + } + Some(spans.into()) +} + +fn footer_hint_key_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::Black) + } else { + Style::default() + } +} + +fn footer_hint_label_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::DarkGray) + } else { + Style::default().dim() + } +} + +fn footer_hints_width( + hints: &[&PickerFooterHint], + mode: FooterHintLabelMode, + gap_width: usize, +) -> usize { + FOOTER_HINT_LEFT_PADDING + + hints + .iter() + .enumerate() + .map(|(idx, hint)| { + let label_width = match mode { + FooterHintLabelMode::Wide => { + 1 + UnicodeWidthStr::width(hint.wide_label.as_str()) + } + FooterHintLabelMode::Compact => { + 1 + UnicodeWidthStr::width(hint.compact_label.as_str()) + } + FooterHintLabelMode::KeyOnly => 0, + }; + let hint_width = UnicodeWidthStr::width(hint.key) + label_width; + if idx == 0 { + hint_width + } else { + hint_width + gap_width + } + }) + .sum::() +} + +fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) { if area.height == 0 { return; } + Clear.render(area, frame.buffer); let rows = &state.filtered_rows; if rows.is_empty() { @@ -1121,120 +2445,742 @@ fn render_list( return; } - let capacity = area.height as usize; - let start = state.scroll_top.min(rows.len().saturating_sub(1)); - let end = rows.len().min(start + capacity); - let labels = &metrics.labels; - let mut y = area.y; - - let visibility = column_visibility(area.width, metrics, state.sort_key); - let max_created_width = metrics.max_created_width; - let max_updated_width = metrics.max_updated_width; - let max_branch_width = metrics.max_branch_width; - let max_cwd_width = metrics.max_cwd_width; - - for (idx, (row, (created_label, updated_label, branch_label, cwd_label))) in rows[start..end] - .iter() - .zip(labels[start..end].iter()) - .enumerate() - { - let is_sel = start + idx == state.selected; - let marker = if is_sel { "> ".bold() } else { " ".into() }; - let marker_width = 2usize; - let created_span = if visibility.show_created { - Some(Span::from(format!("{created_label: = vec![marker]; - if let Some(created) = created_span { - spans.push(created); - spans.push(" ".into()); - } - if let Some(updated) = updated_span { - spans.push(updated); - spans.push(" ".into()); - } - if let Some(branch) = branch_span { - spans.push(branch); - spans.push(" ".into()); - } - if let Some(cwd) = cwd_span { - spans.push(cwd); - spans.push(" ".into()); - } - if add_leading_gap { - spans.push(" ".into()); - } - spans.push(preview.into()); - - let line: Line = spans.into(); - let rect = Rect::new(area.x, y, area.width, 1); - frame.render_widget_ref(line, rect); - y = y.saturating_add(1); + let show_more_above = state.has_more_above(); + let show_more_below = state.has_more_below(area.height as usize); + let content_area = Rect::new( + area.x, + area.y.saturating_add(u16::from(show_more_above)), + area.width, + area.height + .saturating_sub(u16::from(show_more_above)) + .saturating_sub(u16::from(show_more_below)), + ); + if show_more_above { + frame.render_widget_ref( + more_line("↑ more"), + Rect::new(area.x, area.y, area.width, 1), + ); } - if state.pagination.loading.is_pending() && y < area.y.saturating_add(area.height) { + let start = state.scroll_top.min(rows.len().saturating_sub(1)); + let mut y = content_area.y; + for (idx, row) in rows[start..].iter().enumerate() { + if y >= content_area.y.saturating_add(content_area.height) { + break; + } + let row_idx = start + idx; + let is_selected = row_idx == state.selected; + let is_expanded = + is_selected && row.thread_id.is_some() && state.expanded_thread_id == row.thread_id; + let is_zebra = row_idx.is_multiple_of(2); + for line in render_session_lines(row, state, is_selected, is_expanded, is_zebra, area.width) + { + if y >= content_area.y.saturating_add(content_area.height) { + break; + } + frame.render_widget_ref(line, Rect::new(area.x, y, area.width, 1)); + y = y.saturating_add(1); + } + if state.density == SessionListDensity::Comfortable + && y < content_area.y.saturating_add(content_area.height) + && start + idx + 1 < rows.len() + { + y = y.saturating_add(1); + } + } + + if state.pagination.loading.is_pending() + && y < content_area.y.saturating_add(content_area.height) + { let loading_line: Line = vec![" ".into(), "Loading older sessions…".italic().dim()].into(); let rect = Rect::new(area.x, y, area.width, 1); frame.render_widget_ref(loading_line, rect); } + if show_more_below { + let label = if state.pagination.loading.is_pending() { + "↓ loading more" + } else { + "↓ more" + }; + frame.render_widget_ref( + more_line(label), + Rect::new( + area.x, + area.y.saturating_add(area.height.saturating_sub(1)), + area.width, + 1, + ), + ); + } +} + +fn more_line(label: &'static str) -> Line<'static> { + vec![label.dim()].into() +} + +fn render_session_lines( + row: &Row, + state: &PickerState, + is_selected: bool, + is_expanded: bool, + is_zebra: bool, + width: u16, +) -> Vec> { + match state.density { + SessionListDensity::Comfortable => { + render_comfortable_session_lines(row, state, is_selected, is_expanded, is_zebra, width) + } + SessionListDensity::Dense => { + render_dense_session_lines(row, state, is_selected, is_expanded, is_zebra, width) + } + } +} + +fn render_comfortable_session_lines( + row: &Row, + state: &PickerState, + is_selected: bool, + is_expanded: bool, + is_zebra: bool, + width: u16, +) -> Vec> { + let marker = selection_marker(is_selected, is_expanded); + let title = truncate_text(row.display_preview(), width.saturating_sub(2) as usize); + let title = if is_selected { + selected_session_title_span(title) + } else { + title.into() + }; + let title_line = Line::from(vec![marker, title]); + let mut lines = vec![title_line]; + let row_style = if is_selected { + Some(dense_selected_style()) + } else if is_zebra { + Some(dense_zebra_style()) + } else { + None + }; + if let Some(style) = row_style { + lines = apply_session_row_background(lines, style, width); + } + if is_expanded { + lines.extend(render_transcript_preview_lines(row, state, width)); + return lines; + } + + let reference = state.relative_time_reference.unwrap_or_else(Utc::now); + let created = format_relative_time(reference, row.created_at); + let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); + let branch = row.git_branch.as_deref(); + let cwd = row + .cwd + .as_ref() + .map(|path| format_directory_display(path, /*max_width*/ None)); + let footer_lines = render_footer_lines( + state.sort_key, + &created, + &updated, + branch, + cwd.as_deref(), + state.filter_mode == SessionFilterMode::All, + width, + ); + if let Some(style) = row_style { + lines.extend(apply_session_row_background(footer_lines, style, width)); + } else { + lines.extend(footer_lines); + } + lines +} + +fn apply_session_row_background( + lines: Vec>, + style: Style, + width: u16, +) -> Vec> { + lines + .into_iter() + .map(|line| apply_line_background(line, style, width)) + .collect() +} + +fn apply_line_background(mut line: Line<'static>, style: Style, width: u16) -> Line<'static> { + let padding = (width as usize).saturating_sub(line.width()); + if padding > 0 { + line.spans.push(" ".repeat(padding).set_style(style)); + } + line.style = line.style.patch(style); + for span in &mut line.spans { + span.style = span.style.patch(style); + } + line +} + +fn render_dense_session_lines( + row: &Row, + state: &PickerState, + is_selected: bool, + is_expanded: bool, + is_zebra: bool, + width: u16, +) -> Vec> { + let marker = selection_marker(is_selected, is_expanded); + let reference = state.relative_time_reference.unwrap_or_else(Utc::now); + let created = format_relative_time(reference, row.created_at); + let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); + let date = match state.sort_key { + ThreadSortKey::CreatedAt => created, + ThreadSortKey::UpdatedAt => updated, + }; + let mut lines = vec![dense_summary_line(DenseSummaryInput { + marker, + date: &date, + title: row.display_preview(), + is_selected, + is_zebra, + width, + })]; + if is_expanded { + lines.extend(render_transcript_preview_lines(row, state, width)); + } + lines +} + +struct DenseSummaryInput<'a> { + marker: Span<'static>, + date: &'a str, + title: &'a str, + is_selected: bool, + is_zebra: bool, + width: u16, +} + +fn dense_summary_line(input: DenseSummaryInput<'_>) -> Line<'static> { + let marker_width = input.marker.width(); + let available = (input.width as usize).saturating_sub(marker_width); + let columns = dense_columns(available); + let title = if input.is_selected { + selected_session_title_span(dense_column_text(input.title, columns.title_width)) + } else { + dense_column_text(input.title, columns.title_width).into() + }; + + let spans = vec![ + input.marker, + dense_column_text(input.date, columns.date_width).dim(), + title, + ]; + let mut line = Line::from(spans); + if input.is_selected { + let padding = (input.width as usize).saturating_sub(line.width()); + if padding > 0 { + line.spans + .push(" ".repeat(padding).set_style(dense_selected_style())); + } + line = line.style(dense_selected_style()); + } else if input.is_zebra { + let padding = (input.width as usize).saturating_sub(line.width()); + if padding > 0 { + line.spans + .push(" ".repeat(padding).set_style(dense_zebra_style())); + } + line = line.style(dense_zebra_style()); + } + line +} + +struct DenseColumns { + date_width: usize, + title_width: usize, +} + +fn dense_columns(width: usize) -> DenseColumns { + let date_width = SESSION_META_DATE_WIDTH; + DenseColumns { + date_width, + title_width: width.saturating_sub(date_width), + } +} + +fn dense_zebra_style() -> Style { + dense_row_background_style(/*selected*/ false) +} + +fn dense_selected_style() -> Style { + selected_session_style().patch(dense_row_background_style(/*selected*/ true)) +} + +fn dense_row_background_style(selected: bool) -> Style { + let Some(bg) = default_bg() else { + return Style::default(); + }; + let (overlay, alpha) = if is_light(bg) { + ((0, 0, 0), if selected { 0.12 } else { 0.04 }) + } else { + ((255, 255, 255), if selected { 0.12 } else { 0.055 }) + }; + Style::default().bg(best_color(blend(overlay, bg, alpha))) +} + +fn dense_column_text(text: &str, width: usize) -> String { + let text = truncate_text(text, width.saturating_sub(1)); + let padding = width.saturating_sub(UnicodeWidthStr::width(text.as_str())); + format!("{text}{}", " ".repeat(padding)) +} + +fn selection_marker(is_selected: bool, is_expanded: bool) -> Span<'static> { + match (is_selected, is_expanded) { + (true, true) => "⌄ ".set_style(selected_session_style().bold()), + (true, false) => "❯ ".set_style(selected_session_style().bold()), + (false, _) => " ".into(), + } +} + +fn selected_session_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::Magenta) + } else { + Style::default().fg(Color::Yellow) + } +} + +fn selected_session_title_span(title: String) -> Span<'static> { + title.set_style(selected_session_style()) +} + +fn render_footer_lines( + sort_key: ThreadSortKey, + created: &str, + updated: &str, + branch: Option<&str>, + cwd: Option<&str>, + show_cwd: bool, + width: u16, +) -> Vec> { + let date = match sort_key { + ThreadSortKey::CreatedAt => created, + ThreadSortKey::UpdatedAt => updated, + }; + let mut parts = vec![FooterPart::Date(date.to_string())]; + if show_cwd { + parts.push(FooterPart::Cwd(cwd.map(str::to_string))); + } + parts.push(FooterPart::Branch(branch.map(str::to_string))); + pack_footer_parts(parts, width) +} + +enum FooterPart { + Date(String), + Branch(Option), + Cwd(Option), +} + +impl FooterPart { + fn text(&self) -> &str { + match self { + FooterPart::Date(text) => text, + FooterPart::Branch(Some(text)) | FooterPart::Cwd(Some(text)) => text, + FooterPart::Branch(None) => "no branch", + FooterPart::Cwd(None) => "no cwd", + } + } + + fn prefix(&self) -> Option<&'static str> { + match self { + FooterPart::Date(_) => None, + FooterPart::Branch(_) => Some(SESSION_META_BRANCH_ICON), + FooterPart::Cwd(_) => Some(SESSION_META_CWD_ICON), + } + } +} + +fn pack_footer_parts(parts: Vec, width: u16) -> Vec> { + let available_width = width as usize; + if available_width <= SESSION_META_INDENT_WIDTH { + return Vec::new(); + } + let cwd_width = cwd_column_width(available_width); + let all_parts_width = footer_parts_width(&parts, cwd_width); + if all_parts_width <= available_width { + return vec![footer_line(parts, available_width, cwd_width)]; + } + + let mut lines = Vec::with_capacity(parts.len()); + let mut current_parts = Vec::new(); + for part in parts { + let mut candidate_parts = std::mem::take(&mut current_parts); + candidate_parts.push(part); + if candidate_parts.len() > 1 + && footer_parts_width(&candidate_parts, cwd_width) > available_width + { + let previous_parts = candidate_parts + .drain(..candidate_parts.len().saturating_sub(1)) + .collect(); + lines.push(footer_line(previous_parts, available_width, cwd_width)); + } + current_parts = candidate_parts; + } + if !current_parts.is_empty() { + lines.push(footer_line(current_parts, available_width, cwd_width)); + } + lines +} + +fn cwd_column_width(width: usize) -> usize { + let available = width.saturating_sub( + SESSION_META_INDENT_WIDTH + SESSION_META_DATE_WIDTH + 2 * SESSION_META_FIELD_GAP_WIDTH, + ); + (available / 2).clamp(SESSION_META_MIN_CWD_WIDTH, SESSION_META_MAX_CWD_WIDTH) +} + +fn footer_parts_width(parts: &[FooterPart], cwd_width: usize) -> usize { + let content_width: usize = parts + .iter() + .enumerate() + .map(|(idx, part)| footer_part_width(part, idx + 1 < parts.len(), cwd_width)) + .sum(); + SESSION_META_INDENT_WIDTH + content_width +} + +fn footer_part_width(part: &FooterPart, padded: bool, cwd_width: usize) -> usize { + let prefix_width = part.prefix().map_or(0, UnicodeWidthStr::width); + let prefix_gap_width = usize::from(part.prefix().is_some() && !part.text().is_empty()); + let text_width = UnicodeWidthStr::width(part.text()); + let actual_width = prefix_width + prefix_gap_width + text_width; + match part { + FooterPart::Date(_) if padded => SESSION_META_DATE_WIDTH.max(actual_width), + FooterPart::Cwd(_) if padded => cwd_width, + _ => actual_width, + } +} + +fn footer_line(parts: Vec, width: usize, cwd_width: usize) -> Line<'static> { + let mut spans: Vec> = vec![" ".into()]; + let mut remaining_width = width.saturating_sub(SESSION_META_INDENT_WIDTH); + let part_count = parts.len(); + for (idx, part) in parts.into_iter().enumerate() { + if idx > 0 { + let gap_width = SESSION_META_FIELD_GAP_WIDTH.min(remaining_width); + if gap_width > 0 { + spans.push(" ".repeat(gap_width).dim()); + remaining_width = remaining_width.saturating_sub(gap_width); + } + } + let padded = idx + 1 < part_count; + let target_width = match part { + FooterPart::Date(_) if padded => Some(SESSION_META_DATE_WIDTH), + FooterPart::Cwd(_) if padded => Some(cwd_width), + FooterPart::Date(_) | FooterPart::Branch(_) | FooterPart::Cwd(_) => None, + }; + let used_width = push_footer_part(&mut spans, part, target_width, remaining_width); + remaining_width = remaining_width.saturating_sub(used_width); + if let Some(target_width) = target_width { + let padding = target_width.saturating_sub(used_width); + if padding > 0 { + spans.push(" ".repeat(padding).dim()); + remaining_width = remaining_width.saturating_sub(padding); + } + } + } + spans.into() +} + +fn push_footer_part( + spans: &mut Vec>, + part: FooterPart, + target_width: Option, + available_width: usize, +) -> usize { + let text = part.text().to_string(); + let Some(prefix) = part.prefix() else { + let text = truncate_text(&text, available_width); + let width = UnicodeWidthStr::width(text.as_str()); + spans.push(text.dim()); + return width; + }; + + let prefix_width = UnicodeWidthStr::width(prefix); + if available_width <= prefix_width { + let prefix = truncate_text(prefix, available_width); + let width = UnicodeWidthStr::width(prefix.as_str()); + spans.push(prefix.dim()); + return width; + } + + spans.push(prefix.dim()); + let mut used_width = prefix_width; + if !text.is_empty() && used_width < available_width { + spans.push(" ".dim()); + used_width += 1; + } + let text_width = target_width + .unwrap_or(available_width) + .saturating_sub(used_width) + .min(available_width.saturating_sub(used_width)); + let text = truncate_text(&text, text_width); + let rendered_text_width = UnicodeWidthStr::width(text.as_str()); + match part { + FooterPart::Branch(None) | FooterPart::Cwd(None) => spans.push(text.dim().italic()), + _ => spans.push(text.dim()), + } + used_width + rendered_text_width +} + +fn render_transcript_preview_lines( + row: &Row, + state: &PickerState, + width: u16, +) -> Vec> { + let mut details = render_expanded_session_details(row, state, width); + let Some(thread_id) = row.thread_id else { + return details; + }; + let preview_lines = match state.transcript_previews.get(&thread_id) { + Some(TranscriptPreviewState::Loading) => { + vec![vec![" │ ".dim(), "Loading recent transcript...".italic().dim()].into()] + } + Some(TranscriptPreviewState::Failed) => vec![ + vec![ + " │ ".dim(), + "Could not load transcript preview".italic().red(), + ] + .into(), + ], + Some(TranscriptPreviewState::Loaded(lines)) => { + render_conversation_preview_lines(lines, width) + } + None => Vec::new(), + }; + details.extend(preview_lines); + details +} + +fn render_expanded_session_details( + row: &Row, + state: &PickerState, + width: u16, +) -> Vec> { + let reference = state.relative_time_reference.unwrap_or_else(Utc::now); + let session = row + .thread_name + .as_deref() + .map(str::to_string) + .or_else(|| row.thread_id.map(|thread_id| thread_id.to_string())) + .unwrap_or_else(|| "-".to_string()); + let directory = row + .cwd + .as_ref() + .map(|path| format_directory_display(path, /*max_width*/ None)) + .unwrap_or_else(|| "-".to_string()); + let branch = row + .git_branch + .as_ref() + .map(|branch| format!("{SESSION_META_BRANCH_ICON} {branch}")) + .unwrap_or_else(|| format!("{SESSION_META_BRANCH_ICON} no branch")); + + vec![ + expanded_detail_line("Session:", &session, width), + expanded_time_detail_line("Created:", reference, row.created_at, width), + expanded_time_detail_line( + "Updated:", + reference, + row.updated_at.or(row.created_at), + width, + ), + expanded_detail_line("Directory:", &directory, width), + expanded_detail_line("Branch:", &branch, width), + vec![" │".dim()].into(), + vec![" │ ".dim(), "Conversation:".dim()].into(), + ] +} + +fn render_conversation_preview_lines( + lines: &[TranscriptPreviewLine], + width: u16, +) -> Vec> { + if lines.is_empty() { + return vec![ + vec![ + " └ ".dim(), + "No transcript preview available".italic().dim(), + ] + .into(), + ]; + } + + let mut rendered = Vec::new(); + for line in lines { + rendered.extend(render_transcript_content_lines(line, width)); + } + let rendered_len = rendered.len(); + rendered + .into_iter() + .enumerate() + .map(|(idx, line)| { + let prefix = if idx + 1 == rendered_len { + " └ " + } else { + " │ " + }; + prefix_transcript_line(prefix, line) + }) + .collect() +} + +fn render_transcript_content_lines(line: &TranscriptPreviewLine, width: u16) -> Vec> { + let content_width = width.saturating_sub(4) as usize; + let lines = match line.speaker { + TranscriptPreviewSpeaker::User => vec![conversation_content_line( + Line::from(line.text.clone()), + conversation_user_style(), + )], + TranscriptPreviewSpeaker::Assistant => { + let mut lines = Vec::new(); + append_markdown( + &line.text, /*width*/ None, /*cwd*/ None, &mut lines, + ); + for line in &mut lines { + *line = conversation_content_line(line.clone(), conversation_assistant_style()); + } + lines + } + }; + adaptive_wrap_lines(lines, RtOptions::new(content_width.max(/*other*/ 1))) +} + +fn conversation_content_line(mut line: Line<'static>, style: Style) -> Line<'static> { + line.style = line.style.patch(style); + for span in &mut line.spans { + span.style = span.style.patch(style); + } + line +} + +fn prefix_transcript_line(prefix: &'static str, line: Line<'static>) -> Line<'static> { + let mut spans = vec![prefix.set_style(transcript_prefix_style(&line))]; + spans.extend(line.spans); + Line::from(spans).style(line.style) +} + +fn transcript_prefix_style(line: &Line<'_>) -> Style { + let style = line + .spans + .iter() + .find(|span| !span.content.trim().is_empty()) + .map(|span| line.style.patch(span.style)) + .unwrap_or(line.style); + connector_style_from_content(style) +} + +fn connector_style_from_content(style: Style) -> Style { + Style { + fg: style.fg, + bg: style.bg, + ..Style::default() + } +} + +fn conversation_assistant_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::Gray) + } else { + Style::default().fg(Color::DarkGray) + } +} + +fn conversation_user_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::DarkGray).italic() + } else { + Style::default().fg(Color::Gray).italic() + } +} + +fn expanded_detail_line(label: &'static str, value: &str, width: u16) -> Line<'static> { + const LABEL_WIDTH: usize = 10; + let prefix_width = 4; + let gap_width = 2; + let value_width = (width as usize) + .saturating_sub(prefix_width + LABEL_WIDTH + gap_width) + .max(1); + vec![ + " │ ".dim(), + format!("{label:, + ts: Option>, + width: u16, +) -> Line<'static> { + let Some(ts) = ts else { + return expanded_detail_line(label, "-", width); + }; + let value = format!( + "{} · {}", + format_relative_time_long(reference, ts), + format_timestamp(ts) + ); + expanded_detail_line(label, &value, width) +} + +fn format_relative_time(reference: DateTime, ts: Option>) -> String { + let Some(ts) = ts else { + return "-".to_string(); + }; + let seconds = (reference - ts).num_seconds().max(0); + if seconds == 0 { + return "now".to_string(); + } + if seconds < 60 { + return format!("{seconds}s ago"); + } + let minutes = seconds / 60; + if minutes < 60 { + return format!("{minutes}m ago"); + } + let hours = minutes / 60; + if hours < 24 { + return format!("{hours}h ago"); + } + let days = hours / 24; + format!("{days}d ago") +} + +fn format_relative_time_long(reference: DateTime, ts: DateTime) -> String { + let seconds = (reference - ts).num_seconds().max(0); + if seconds == 0 { + return "now".to_string(); + } + if seconds < 60 { + return plural_time(seconds, "second"); + } + let minutes = seconds / 60; + if minutes < 60 { + return plural_time(minutes, "minute"); + } + let hours = minutes / 60; + if hours < 24 { + return plural_time(hours, "hour"); + } + plural_time(hours / 24, "day") +} + +fn plural_time(value: i64, unit: &str) -> String { + if value == 1 { + format!("1 {unit} ago") + } else { + format!("{value} {unit}s ago") + } +} + +fn format_timestamp(ts: DateTime) -> String { + ts.format("%Y-%m-%d %H:%M:%S").to_string() } fn render_empty_state_line(state: &PickerState) -> Line<'static> { @@ -1264,254 +3210,11 @@ fn render_empty_state_line(state: &PickerState) -> Line<'static> { vec!["No sessions yet".italic().dim()].into() } -fn human_time_ago(ts: DateTime, reference_now: DateTime) -> String { - let delta = reference_now - ts; - let secs = delta.num_seconds(); - if secs < 60 { - let n = secs.max(0); - if n == 1 { - format!("{n} second ago") - } else { - format!("{n} seconds ago") - } - } else if secs < 60 * 60 { - let m = secs / 60; - if m == 1 { - format!("{m} minute ago") - } else { - format!("{m} minutes ago") - } - } else if secs < 60 * 60 * 24 { - let h = secs / 3600; - if h == 1 { - format!("{h} hour ago") - } else { - format!("{h} hours ago") - } - } else { - let d = secs / (60 * 60 * 24); - if d == 1 { - format!("{d} day ago") - } else { - format!("{d} days ago") - } - } -} - -fn format_updated_label_at(row: &Row, reference_now: DateTime) -> String { - match (row.updated_at, row.created_at) { - (Some(updated), _) => human_time_ago(updated, reference_now), - (None, Some(created)) => human_time_ago(created, reference_now), - (None, None) => "-".to_string(), - } -} - -fn format_created_label_at(row: &Row, reference_now: DateTime) -> String { - match row.created_at { - Some(created) => human_time_ago(created, reference_now), - None => "-".to_string(), - } -} - -fn render_column_headers( - frame: &mut crate::custom_terminal::Frame, - area: Rect, - metrics: &ColumnMetrics, - sort_key: ThreadSortKey, -) { - if area.height == 0 { - return; - } - - let mut spans: Vec = vec![" ".into()]; - let visibility = column_visibility(area.width, metrics, sort_key); - if visibility.show_created { - let label = format!( - "{text:, -} - -/// Determines which columns to render given available terminal width. -/// -/// When the terminal is narrow, only one timestamp column is shown (whichever -/// matches the current sort key). Branch and CWD are hidden if their max -/// widths are zero (no data to show). -#[derive(Debug, PartialEq, Eq)] -struct ColumnVisibility { - show_created: bool, - show_updated: bool, - show_branch: bool, - show_cwd: bool, -} - -fn calculate_column_metrics( - rows: &[Row], - include_cwd: bool, - reference_now: DateTime, -) -> ColumnMetrics { - fn right_elide(s: &str, max: usize) -> String { - if s.chars().count() <= max { - return s.to_string(); - } - if max <= 1 { - return "…".to_string(); - } - let tail_len = max - 1; - let tail: String = s - .chars() - .rev() - .take(tail_len) - .collect::() - .chars() - .rev() - .collect(); - format!("…{tail}") - } - - let mut labels: Vec<(String, String, String, String)> = Vec::with_capacity(rows.len()); - let mut max_created_width = UnicodeWidthStr::width(CREATED_COLUMN_LABEL); - let mut max_updated_width = UnicodeWidthStr::width(UPDATED_COLUMN_LABEL); - let mut max_branch_width = UnicodeWidthStr::width("Branch"); - let mut max_cwd_width = if include_cwd { - UnicodeWidthStr::width("CWD") - } else { - 0 - }; - - for row in rows { - let created = format_created_label_at(row, reference_now); - let updated = format_updated_label_at(row, reference_now); - let branch_raw = row.git_branch.clone().unwrap_or_default(); - let branch = right_elide(&branch_raw, /*max*/ 24); - let cwd = if include_cwd { - let cwd_raw = row - .cwd - .as_ref() - .map(|p| display_path_for(p, std::path::Path::new("/"))) - .unwrap_or_default(); - right_elide(&cwd_raw, /*max*/ 24) - } else { - String::new() - }; - max_created_width = max_created_width.max(UnicodeWidthStr::width(created.as_str())); - max_updated_width = max_updated_width.max(UnicodeWidthStr::width(updated.as_str())); - max_branch_width = max_branch_width.max(UnicodeWidthStr::width(branch.as_str())); - max_cwd_width = max_cwd_width.max(UnicodeWidthStr::width(cwd.as_str())); - labels.push((created, updated, branch, cwd)); - } - - ColumnMetrics { - max_created_width, - max_updated_width, - max_branch_width, - max_cwd_width, - labels, - } -} - -/// Computes which columns fit in the available width. -/// -/// The algorithm reserves at least `MIN_PREVIEW_WIDTH` characters for the -/// conversation preview. If both timestamp columns don't fit, only the one -/// matching the current sort key is shown. -fn column_visibility( - area_width: u16, - metrics: &ColumnMetrics, - sort_key: ThreadSortKey, -) -> ColumnVisibility { - const MIN_PREVIEW_WIDTH: usize = 10; - - let show_branch = metrics.max_branch_width > 0; - let show_cwd = metrics.max_cwd_width > 0; - - // Calculate remaining width after all optional columns. - let mut preview_width = area_width as usize; - preview_width = preview_width.saturating_sub(2); // marker - if metrics.max_created_width > 0 { - preview_width = preview_width.saturating_sub(metrics.max_created_width + 2); - } - if metrics.max_updated_width > 0 { - preview_width = preview_width.saturating_sub(metrics.max_updated_width + 2); - } - if show_branch { - preview_width = preview_width.saturating_sub(metrics.max_branch_width + 2); - } - if show_cwd { - preview_width = preview_width.saturating_sub(metrics.max_cwd_width + 2); - } - - // If preview would be too narrow, hide the non-active timestamp column. - let show_both = preview_width >= MIN_PREVIEW_WIDTH; - let show_created = if show_both { - metrics.max_created_width > 0 - } else { - sort_key == ThreadSortKey::CreatedAt - }; - let show_updated = if show_both { - metrics.max_updated_width > 0 - } else { - sort_key == ThreadSortKey::UpdatedAt - }; - - ColumnVisibility { - show_created, - show_updated, - show_branch, - show_cwd, - } -} - #[cfg(test)] mod tests { use super::*; use chrono::Duration; + use codex_config::CONFIG_TOML_FILE; use codex_protocol::ThreadId; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -1525,6 +3228,7 @@ mod tests { use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; + use tempfile::tempdir; fn page( rows: Vec, @@ -1540,6 +3244,14 @@ mod tests { } } + fn page_only_loader(loader: impl Fn(PageLoadRequest) + Send + Sync + 'static) -> PickerLoader { + Arc::new(move |request| { + if let PickerLoadRequest::Page(request) = request { + loader(request); + } + }) + } + fn make_row(path: &str, ts: &str, preview: &str) -> Row { let timestamp = parse_timestamp_str(ts).expect("timestamp should parse"); Row { @@ -1554,6 +3266,38 @@ mod tests { } } + fn footer_lines_text(state: &PickerState, width: u16) -> String { + footer_hint_lines(state, width) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n") + } + + fn footer_snapshot(state: &PickerState, width: u16, list_height: u16) -> String { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let backend = VT100Backend::new(width, /*height*/ 4); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, 4)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_picker_footer(&mut frame, area, state, list_height); + } + terminal.flush().expect("flush"); + + terminal + .backend() + .to_string() + .lines() + .map(str::trim_end) + .collect::>() + .join("\n") + } + #[test] fn row_display_preview_prefers_thread_name() { let row = Row { @@ -1592,6 +3336,213 @@ mod tests { ); } + #[test] + fn row_search_matches_metadata_fields() { + let thread_id = + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"); + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("first message"), + thread_id: Some(thread_id), + thread_name: Some(String::from("My session")), + created_at: None, + updated_at: None, + cwd: Some(PathBuf::from("/tmp/codex-session-picker")), + git_branch: Some(String::from("fcoury/session-picker")), + }; + + assert!(row.matches_query("session-picker")); + assert!(row.matches_query("fcoury")); + assert!(row.matches_query(&thread_id.to_string()[..8])); + } + + #[test] + fn relative_time_formats_zero_seconds_as_now() { + let reference = DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z") + .expect("valid timestamp") + .with_timezone(&Utc); + + assert_eq!(format_relative_time(reference, Some(reference)), "now"); + assert_eq!( + format_relative_time(reference, Some(reference - Duration::seconds(1))), + "1s ago" + ); + } + + #[test] + fn long_relative_time_uses_words() { + let reference = DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z") + .expect("valid timestamp") + .with_timezone(&Utc); + + assert_eq!(format_relative_time_long(reference, reference), "now"); + assert_eq!( + format_relative_time_long(reference, reference - Duration::minutes(20)), + "20 minutes ago" + ); + assert_eq!( + format_relative_time_long(reference, reference - Duration::hours(1)), + "1 hour ago" + ); + } + + #[test] + fn expanded_session_details_include_metadata() { + let thread_id = + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.relative_time_reference = parse_timestamp_str("2026-05-02T14:48:19Z"); + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("first message"), + thread_id: Some(thread_id), + thread_name: Some(String::from("feat(tui): add raw scrollback mode")), + created_at: parse_timestamp_str("2026-05-02T14:31:08Z"), + updated_at: parse_timestamp_str("2026-05-02T14:48:19Z"), + cwd: Some(PathBuf::from("/Users/felipe.coury/code/codex")), + git_branch: Some(String::from("codex/raw-scrollback-mode")), + }; + + let rendered = render_expanded_session_details(&row, &state, /*width*/ 120) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + let expected_directory = + format_directory_display(row.cwd.as_deref().expect("cwd"), /*max_width*/ None); + + assert!(rendered.contains("Session: feat(tui): add raw scrollback mode")); + assert!(rendered.contains("Created: 17 minutes ago · 2026-05-02 14:31:08")); + assert!(rendered.contains("Updated: now · 2026-05-02 14:48:19")); + assert!(rendered.contains(&format!("Directory: {expected_directory}"))); + assert!(rendered.contains("Branch:  codex/raw-scrollback-mode")); + assert!(rendered.contains("Conversation:")); + } + + #[test] + fn footer_prioritizes_active_sort_timestamp() { + let updated = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "3h ago", + Some("main"), + Some("tmp/codex"), + /*show_cwd*/ true, + /*width*/ 80, + ); + let created = render_footer_lines( + ThreadSortKey::CreatedAt, + "5h ago", + "3h ago", + Some("main"), + Some("tmp/codex"), + /*show_cwd*/ true, + /*width*/ 80, + ); + + assert_eq!(updated.len(), 1); + assert_eq!(created.len(), 1); + assert!(updated[0].to_string().starts_with(" 3h ago")); + assert!(created[0].to_string().starts_with(" 5h ago")); + assert!(!updated[0].to_string().contains("created 5h ago")); + assert!(!created[0].to_string().contains("updated 3h ago")); + assert_metadata_order(&updated[0], "⌁ tmp/codex", " main"); + assert_metadata_order(&created[0], "⌁ tmp/codex", " main"); + } + + #[test] + fn footer_marks_missing_branch() { + let footer = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "3h ago", + /*branch*/ None, + Some("/tmp/codex"), + /*show_cwd*/ true, + /*width*/ 80, + ); + + assert_eq!(footer.len(), 1); + let rendered = footer[0].to_string(); + assert!(rendered.contains("⌁ /tmp/codex")); + assert!(rendered.contains(" no branch")); + assert_metadata_order(&footer[0], "⌁ /tmp/codex", " no branch"); + } + + #[test] + fn footer_branch_expands_when_line_has_room() { + let branch = "etraut/animations-false-improvements"; + let footer = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "4h ago", + Some(branch), + Some("~/code/codex.etraut-animations-false-improvements/codex-rs"), + /*show_cwd*/ true, + /*width*/ 140, + ); + + assert_eq!(footer.len(), 1); + assert!(footer[0].to_string().contains(branch)); + } + + #[test] + fn footer_cwd_truncates_to_responsive_column() { + let cwd = "~/code/codex.owner-extremely-long-worktree-name-that-needs-truncating/codex-rs"; + let branch = "owner/branch"; + let footer = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "4h ago", + Some(branch), + Some(cwd), + /*show_cwd*/ true, + /*width*/ 80, + ); + + assert_eq!(footer.len(), 1); + let footer = footer[0].to_string(); + assert!(!footer.contains(cwd)); + assert!(footer.contains("⌁ ~/code/codex.")); + assert!(footer.contains("...")); + assert!(footer.contains(" owner/branch")); + } + + #[test] + fn footer_omits_cwd_when_hidden() { + let footer = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "4h ago", + Some("owner/branch"), + Some("~/code/codex.owner-worktree/codex-rs"), + /*show_cwd*/ false, + /*width*/ 80, + ); + + assert_eq!(footer.len(), 1); + let footer = footer[0].to_string(); + assert!(footer.contains("4h ago")); + assert!(footer.contains(" owner/branch")); + assert!(!footer.contains("⌁")); + assert!(!footer.contains("~/code")); + } + + fn assert_metadata_order(line: &Line<'_>, first: &str, second: &str) { + let rendered = line.to_string(); + let first_index = rendered.find(first).expect("first metadata item"); + let second_index = rendered.find(second).expect("second metadata item"); + assert!(first_index < second_index); + } + #[test] fn remote_thread_list_params_omit_provider_filter() { let params = thread_list_params( @@ -1629,9 +3580,49 @@ mod tests { assert_eq!(params.source_kinds, None); } + #[test] + fn remote_picker_sends_cwd_filter_without_local_post_filtering() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + let remote_cwd = Some(PathBuf::from("/srv/link-project")); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::Any, + /*show_all*/ false, + remote_cwd.clone(), + SessionPickerAction::Resume, + ); + state.local_filter_cwd = local_picker_cwd_filter(&remote_cwd, /*is_remote*/ true); + + state.start_initial_load(); + + { + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].cwd_filter, remote_cwd); + } + + let row = Row { + path: None, + preview: String::from("remote session"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: Some(PathBuf::from("/srv/real-project")), + git_branch: None, + }; + + assert!(state.row_matches_filter(&row)); + } + #[test] fn remote_picker_does_not_filter_rows_by_local_cwd() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1658,10 +3649,8 @@ mod tests { fn resume_table_snapshot() { use crate::custom_terminal::Terminal; use crate::test_backend::VT100Backend; - use ratatui::layout::Constraint; - use ratatui::layout::Layout; - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1671,7 +3660,7 @@ mod tests { SessionPickerAction::Resume, ); - let now = Utc::now(); + let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp"); let rows = vec![ Row { path: Some(PathBuf::from("/tmp/a.jsonl")), @@ -1706,16 +3695,13 @@ mod tests { ]; state.all_rows = rows.clone(); state.filtered_rows = rows; - state.view_rows = Some(3); + state.relative_time_reference = Some(now); state.selected = 1; state.scroll_top = 0; - state.update_view_rows(/*rows*/ 3); - - state.relative_time_reference = Some(now); - let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all, now); + state.update_viewport(/*rows*/ 12, /*width*/ 80); let width: u16 = 80; - let height: u16 = 6; + let height: u16 = 12; let backend = VT100Backend::new(width, height); let mut terminal = Terminal::with_options(backend).expect("terminal"); terminal.set_viewport_area(Rect::new(0, 0, width, height)); @@ -1723,10 +3709,7 @@ mod tests { { let mut frame = terminal.get_frame(); let area = frame.area(); - let segments = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area); - render_column_headers(&mut frame, segments[0], &metrics, state.sort_key); - render_list(&mut frame, segments[1], &state, &metrics); + render_list(&mut frame, area, &state); } terminal.flush().expect("flush"); @@ -1739,7 +3722,7 @@ mod tests { use crate::custom_terminal::Terminal; use crate::test_backend::VT100Backend; - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1760,7 +3743,7 @@ mod tests { { let mut frame = terminal.get_frame(); - let line = search_line(&state); + let line = search_line(&state, frame.area().width); frame.render_widget_ref(line, frame.area()); } terminal.flush().expect("flush"); @@ -1769,9 +3752,1443 @@ mod tests { assert_snapshot!("resume_picker_search_error", snapshot); } + #[test] + fn hint_line_switches_esc_label_for_search_mode() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert!(footer_lines_text(&state, /*width*/ 220).contains("esc start new")); + + state.query = String::from("picker"); + + assert!(footer_lines_text(&state, /*width*/ 220).contains("esc clear search")); + } + + #[test] + fn hint_line_labels_cancel_keys_as_exit_for_existing_session_resume_picker() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.launch_context = SessionPickerLaunchContext::ExistingSession; + + let wide = footer_lines_text(&state, /*width*/ 220); + assert!(wide.contains("esc exit")); + assert!(wide.contains("ctrl+c exit")); + + let compact = footer_lines_text(&state, /*width*/ 119); + assert!(compact.contains("esc exit")); + assert!(compact.contains("ctrl+c exit")); + + state.query = String::from("picker"); + + assert!(footer_lines_text(&state, /*width*/ 220).contains("esc clear search")); + } + + #[test] + fn hint_line_switches_density_label() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+o dense view")); + assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+t transcript")); + assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+e expand")); + + state.density = SessionListDensity::Dense; + + assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+o comfortable view")); + } + + #[test] + fn hint_line_compacts_on_narrow_width() { + let loader = page_only_loader(|_| {}); + let state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let rendered = footer_lines_text(&state, /*width*/ 119); + + assert!(rendered.contains("esc new")); + assert!(rendered.contains("tab focus")); + assert!(rendered.contains("←/→ option")); + assert!(rendered.contains("ctrl+o dense")); + assert!(rendered.contains("ctrl+t preview")); + assert!(rendered.contains("ctrl+e exp")); + assert!(!rendered.contains("focus sort/filter")); + } + + #[test] + fn hint_line_snapshot_uses_distributed_wide_footer() { + let loader = page_only_loader(|_| {}); + let state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert_snapshot!( + "resume_picker_footer_wide", + footer_snapshot(&state, /*width*/ 220, /*list_height*/ 20) + ); + } + + #[test] + fn hint_line_snapshot_uses_compact_footer() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("picker"); + state.density = SessionListDensity::Dense; + + assert_snapshot!( + "resume_picker_footer_compact", + footer_snapshot(&state, /*width*/ 96, /*list_height*/ 20) + ); + } + + #[test] + fn hint_line_prioritizes_keybinds_when_very_narrow() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.density = SessionListDensity::Dense; + + let width = 38; + let lines = footer_hint_lines(&state, width); + let rendered = lines + .iter() + .map(Line::to_string) + .collect::>() + .join("\n"); + + assert!(lines.iter().all(|line| line.width() <= width as usize)); + assert!(rendered.contains("enter")); + assert!(rendered.contains("esc")); + assert!(rendered.contains("ctrl+c")); + assert!(rendered.contains("ctrl+o")); + assert!(rendered.contains("ctrl+t")); + assert!(rendered.contains("ctrl+e")); + assert!(rendered.contains("↑/↓")); + } + + #[test] + fn hint_line_shows_loading_transcript_mode() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.pending_transcript_open = Some(ThreadId::new()); + + let rendered = footer_lines_text(&state, /*width*/ 80); + + assert!(rendered.contains("loading transcript")); + assert!(rendered.contains("ctrl+c quit")); + assert!(!rendered.contains("enter")); + } + + #[test] + fn picker_footer_percent_reports_scroll_progress() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + + state.scroll_top = 0; + assert_eq!(picker_footer_percent(&state, /*list_height*/ 6), 0); + + state.scroll_top = state.filtered_rows.len() - 1; + assert_eq!(picker_footer_percent(&state, /*list_height*/ 6), 100); + } + + #[test] + fn picker_footer_progress_label_shows_position_total_and_percent() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + state.selected = 2; + + let label = picker_footer_progress_label(&state, /*list_height*/ 6, /*width*/ 80); + + assert_eq!(label, " 3 / 10 · 0% "); + assert!(!label.contains('-')); + } + + #[test] + fn picker_footer_progress_label_uses_known_count_when_more_pages_exist() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + state.selected = 2; + state.pagination.next_cursor = Some(PageCursor::AppServer(String::from("cursor-1"))); + + let label = picker_footer_progress_label(&state, /*list_height*/ 6, /*width*/ 80); + + assert_eq!(label, " 3 / 10 · 0% "); + } + + #[test] + fn picker_footer_progress_label_freezes_percent_while_loading() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + state.selected = 9; + state.scroll_top = 9; + state.pagination.next_cursor = Some(PageCursor::AppServer(String::from("cursor-1"))); + state.pagination.loading = LoadingState::Pending(PendingLoad { + request_token: 1, + search_token: None, + }); + state.frozen_footer_percent = Some(37); + + let label = picker_footer_progress_label(&state, /*list_height*/ 6, /*width*/ 80); + + assert_eq!(label, " 10 / 10… · 37% "); + } + + #[test] + fn picker_footer_percent_is_complete_when_not_scrollable() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert_eq!(picker_footer_percent(&state, /*list_height*/ 20), 100); + + state.filtered_rows = vec![make_row( + "/tmp/1.jsonl", + "2026-05-02T12:00:00Z", + "single row", + )]; + assert_eq!(picker_footer_percent(&state, /*list_height*/ 20), 100); + } + + #[tokio::test] + async fn ctrl_o_toggles_density_without_typing_into_search() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("pick"); + + state + .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + assert_eq!(state.query, "pick"); + } + + #[tokio::test] + async fn ctrl_t_requests_selected_session_transcript() { + let thread_id = ThreadId::new(); + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader: PickerLoader = Arc::new(move |request| { + if let PickerLoadRequest::Transcript { thread_id } = request { + request_sink.lock().unwrap().push(thread_id); + } + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Comfortable); + assert_eq!(*recorded_requests.lock().unwrap(), vec![thread_id]); + assert_eq!(state.pending_transcript_open, Some(thread_id)); + assert!(matches!( + state.transcript_cells.get(&thread_id), + Some(SessionTranscriptState::Loading) + )); + } + + #[tokio::test] + async fn transcript_loading_consumes_picker_input() { + let loader = page_only_loader(|_| {}); + let thread_id = ThreadId::new(); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![ + Row { + path: None, + preview: String::from("one"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }, + Row { + path: None, + preview: String::from("two"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }, + ]; + state.pending_transcript_open = Some(thread_id); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await + .unwrap(); + + assert!(selection.is_none()); + assert_eq!(state.selected, 0); + assert_eq!(state.query, ""); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert!(selection.is_none()); + assert_eq!(state.query, ""); + } + + #[tokio::test] + async fn transcript_loading_still_allows_ctrl_c_exit() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.pending_transcript_open = Some(ThreadId::new()); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert!(matches!(selection, Some(SessionSelection::Exit))); + } + + #[test] + fn transcript_loading_overlay_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + let thread_id = ThreadId::new(); + state.pending_transcript_open = Some(thread_id); + state.filtered_rows = vec![ + Row { + path: None, + preview: String::from("Find pending threads and emails"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }, + Row { + path: None, + preview: String::from("Plan raw scrollback mode"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }, + ]; + state.update_viewport(/*rows*/ 7, /*width*/ 80); + + let width: u16 = 80; + let height: u16 = 7; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + render_transcript_loading_overlay(&mut frame, area); + } + terminal.flush().expect("flush"); + + let snapshot = terminal + .backend() + .to_string() + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + assert_snapshot!("resume_picker_transcript_loading_overlay", snapshot); + } + + #[tokio::test] + async fn raw_ctrl_t_requests_selected_session_transcript() { + let thread_id = ThreadId::new(); + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader: PickerLoader = Arc::new(move |request| { + if let PickerLoadRequest::Transcript { thread_id } = request { + request_sink.lock().unwrap().push(thread_id); + } + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('\u{0014}'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(*recorded_requests.lock().unwrap(), vec![thread_id]); + } + + #[tokio::test] + async fn ctrl_t_on_row_without_thread_id_shows_inline_error() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("preview"), + thread_id: None, + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!( + state.inline_error.as_deref(), + Some("No transcript available for this session") + ); + } + + #[tokio::test] + async fn loaded_transcript_waits_for_loading_frame_before_opening_overlay() { + use crate::history_cell::PlainHistoryCell; + + let thread_id = ThreadId::new(); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.pending_transcript_open = Some(thread_id); + let cells: TranscriptCells = + vec![Arc::new(PlainHistoryCell::new(vec!["transcript".into()]))]; + + state + .handle_background_event(BackgroundEvent::Transcript { + thread_id, + transcript: Ok(cells), + }) + .await + .unwrap(); + + assert!(state.overlay.is_none()); + assert_eq!(state.pending_transcript_open, Some(thread_id)); + assert!(matches!( + state.transcript_cells.get(&thread_id), + Some(SessionTranscriptState::Loaded(_)) + )); + + assert!(state.note_transcript_loading_frame_drawn()); + state.open_pending_transcript_if_ready(); + + assert!(matches!(state.overlay, Some(Overlay::Transcript(_)))); + assert_eq!(state.pending_transcript_open, None); + } + + #[tokio::test] + async fn cached_transcript_still_shows_loading_frame_before_opening_overlay() { + use crate::history_cell::PlainHistoryCell; + + let thread_id = ThreadId::new(); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + state.transcript_cells.insert( + thread_id, + SessionTranscriptState::Loaded(vec![Arc::new(PlainHistoryCell::new(vec![ + "transcript".into(), + ]))]), + ); + + state + .handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert!(state.overlay.is_none()); + assert_eq!(state.pending_transcript_open, Some(thread_id)); + + assert!(state.note_transcript_loading_frame_drawn()); + state.open_pending_transcript_if_ready(); + + assert!(matches!(state.overlay, Some(Overlay::Transcript(_)))); + assert_eq!(state.pending_transcript_open, None); + } + + #[tokio::test] + async fn ctrl_o_persists_density_preference() { + let tmp = tempdir().expect("tmpdir"); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.view_persistence = Some(SessionPickerViewPersistence { + codex_home: tmp.path().to_path_buf(), + active_profile: None, + }); + + state + .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + let contents = + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!( + contents, + r#"[tui] +session_picker_view = "dense" +"# + ); + } + + #[tokio::test] + async fn ctrl_o_persists_density_preference_for_active_profile() { + let tmp = tempdir().expect("tmpdir"); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.view_persistence = Some(SessionPickerViewPersistence { + codex_home: tmp.path().to_path_buf(), + active_profile: Some(String::from("work")), + }); + + state + .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + let contents = + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!( + contents, + r#"[profiles.work.tui] +session_picker_view = "dense" +"# + ); + } + + #[tokio::test] + async fn ctrl_o_keeps_toggled_density_when_persistence_fails() { + let tmp = tempdir().expect("tmpdir"); + let codex_home_file = tmp.path().join("codex-home-file"); + std::fs::write(&codex_home_file, "not a directory").expect("write codex home file"); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.view_persistence = Some(SessionPickerViewPersistence { + codex_home: codex_home_file, + active_profile: None, + }); + + state + .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + assert!( + state + .inline_error + .as_deref() + .is_some_and(|error| error.contains("Failed to save view mode")), + "expected persistence error, got {:?}", + state.inline_error + ); + } + + #[tokio::test] + async fn raw_ctrl_o_toggles_density_without_typing_into_search() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("pick"); + + state + .handle_key(KeyEvent::new(KeyCode::Char('\u{000f}'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + assert_eq!(state.query, "pick"); + } + + #[tokio::test] + async fn space_appends_to_search_query() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("resize"); + + state + .handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)) + .await + .unwrap(); + state + .handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.query, "resize r"); + assert_eq!(state.expanded_thread_id, None); + } + + #[tokio::test] + async fn ctrl_e_toggles_selected_session_expansion() { + let thread_id = ThreadId::new(); + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader: PickerLoader = Arc::new(move |request| { + if let PickerLoadRequest::Preview { thread_id } = request { + request_sink.lock().unwrap().push(thread_id); + } + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.expanded_thread_id, Some(thread_id)); + assert_eq!(*recorded_requests.lock().unwrap(), vec![thread_id]); + + state + .handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.expanded_thread_id, None); + } + + #[tokio::test] + async fn raw_ctrl_e_toggles_selected_session_expansion() { + let thread_id = ThreadId::new(); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('\u{0005}'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.expanded_thread_id, Some(thread_id)); + } + + #[test] + fn search_line_renders_sort_and_filter_tabs() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ false, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + let width: u16 = 100; + let backend = VT100Backend::new(width, /*height*/ 1); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, 1)); + + { + let mut frame = terminal.get_frame(); + let line = search_line(&state, frame.area().width); + frame.render_widget_ref(line, frame.area()); + } + terminal.flush().expect("flush"); + + assert_snapshot!( + "resume_picker_search_line_sort_filter_tabs", + terminal.backend().to_string() + ); + } + + #[test] + fn search_line_compacts_toolbar_on_narrow_width() { + let loader = page_only_loader(|_| {}); + let state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ false, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + let line = search_line(&state, /*width*/ 40).to_string(); + + assert!(line.contains("Filter:[Cwd]")); + assert!(line.contains("Sort:[Updated]")); + assert!(line.find("Filter:[Cwd]") < line.find("Sort:[Updated]")); + } + + fn dense_snapshot_row() -> Row { + Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from( + "Propose session picker redesign with enough title text to exercise truncation", + ), + thread_id: Some( + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"), + ), + thread_name: None, + created_at: parse_timestamp_str("2026-04-28T16:30:00Z"), + updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"), + cwd: Some(PathBuf::from( + "/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs", + )), + git_branch: Some(String::from("fcoury/session-picker")), + } + } + + fn render_dense_row_snapshot( + show_all: bool, + filter_cwd: Option, + width: u16, + ) -> String { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let row = dense_snapshot_row(); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + show_all, + filter_cwd, + SessionPickerAction::Resume, + ); + state.density = SessionListDensity::Dense; + state.all_rows = vec![row.clone()]; + state.filtered_rows = vec![row]; + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + + let backend = VT100Backend::new(width, /*height*/ 3); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, 3)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + terminal.backend().to_string() + } + + #[test] + fn dense_session_snapshot_omits_cwd_in_cwd_filter() { + assert_snapshot!( + "resume_picker_dense_cwd", + render_dense_row_snapshot( + /*show_all*/ false, + Some(PathBuf::from( + "/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs" + )), + /*width*/ 100, + ) + ); + } + + #[test] + fn dense_session_snapshot_includes_cwd_in_all_filter() { + assert_snapshot!( + "resume_picker_dense_all", + render_dense_row_snapshot( + /*show_all*/ true, /*filter_cwd*/ None, /*width*/ 120, + ) + ); + } + + #[test] + fn dense_session_snapshot_auto_hides_cwd_when_narrow() { + assert_snapshot!( + "resume_picker_dense_all_auto_hidden_cwd", + render_dense_row_snapshot( + /*show_all*/ true, /*filter_cwd*/ None, /*width*/ 100, + ) + ); + } + + #[test] + fn dense_session_snapshot_forces_cwd_when_narrow() { + assert_snapshot!( + "resume_picker_dense_all_forced_cwd", + render_dense_row_snapshot( + /*show_all*/ true, /*filter_cwd*/ None, /*width*/ 48, + ) + ); + } + + #[test] + fn dense_session_snapshot_drops_metadata_when_narrow() { + assert_snapshot!( + "resume_picker_dense_narrow", + render_dense_row_snapshot( + /*show_all*/ true, /*filter_cwd*/ None, /*width*/ 48, + ) + ); + } + + #[test] + fn dense_session_line_prefers_thread_name_over_preview() { + let mut row = dense_snapshot_row(); + row.preview = String::from("Raw conversation preview"); + row.thread_name = Some(String::from("Named session")); + + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + + let rendered = render_dense_session_lines( + &row, &state, /*is_selected*/ false, /*is_expanded*/ false, + /*is_zebra*/ false, /*width*/ 100, + ) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + assert!(rendered.contains("Named session")); + assert!(!rendered.contains("Raw conversation preview")); + } + + #[test] + fn dense_selected_summary_line_uses_full_width_selection_style() { + let line = dense_summary_line(DenseSummaryInput { + marker: selection_marker(/*is_selected*/ true, /*is_expanded*/ false), + date: "15m ago", + title: "Selected dense row", + is_selected: true, + is_zebra: false, + width: 80, + }); + + assert_eq!(line.width(), 80); + assert_eq!(line.style.fg, selected_session_style().fg); + assert_eq!(line.spans[0].content, "❯ "); + } + + #[test] + fn dense_zebra_summary_line_uses_full_width_background() { + let line = dense_summary_line(DenseSummaryInput { + marker: selection_marker(/*is_selected*/ false, /*is_expanded*/ false), + date: "15m ago", + title: "Zebra dense row", + is_selected: false, + is_zebra: true, + width: 80, + }); + + assert_eq!(line.width(), 80); + assert_eq!(line.style.bg, dense_zebra_style().bg); + } + + #[test] + fn comfortable_zebra_lines_use_full_width_background() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.relative_time_reference = + Some(parse_timestamp_str("2026-05-02T12:00:00Z").expect("timestamp")); + let row = make_row( + "/tmp/a.jsonl", + "2026-05-02T11:45:00Z", + "Zebra comfortable row", + ); + + let lines = render_comfortable_session_lines( + &row, &state, /*is_selected*/ false, /*is_expanded*/ false, + /*is_zebra*/ true, /*width*/ 100, + ); + + assert_eq!(lines.len(), 2); + assert!(lines.iter().all(|line| line.width() == 100)); + assert!( + lines + .iter() + .all(|line| line.style.bg == dense_zebra_style().bg) + ); + } + + #[test] + fn dense_session_snapshot_uses_no_blank_lines_between_rows() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let mut first = dense_snapshot_row(); + first.preview = String::from("First dense row"); + let mut second = dense_snapshot_row(); + second.preview = String::from("Second dense row"); + second.git_branch = Some(String::from("fcoury/other-branch")); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ false, + Some(PathBuf::from( + "/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs", + )), + SessionPickerAction::Resume, + ); + state.density = SessionListDensity::Dense; + state.all_rows = vec![first.clone(), second.clone()]; + state.filtered_rows = vec![first, second]; + state.selected = 1; + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + + let backend = VT100Backend::new(/*width*/ 80, /*height*/ 2); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, 80, 2)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + assert_snapshot!( + "resume_picker_dense_no_blank_lines", + terminal.backend().to_string() + ); + } + + #[test] + fn expanded_session_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let thread_id = + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"); + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("Investigate picker expansion"), + thread_id: Some(thread_id), + thread_name: None, + created_at: parse_timestamp_str("2026-04-28T16:30:00Z"), + updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"), + cwd: Some(PathBuf::from("/tmp/codex")), + git_branch: Some(String::from("fcoury/session-picker")), + }; + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.all_rows = vec![row.clone()]; + state.filtered_rows = vec![row]; + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + state.expanded_thread_id = Some(thread_id); + state.transcript_previews.insert( + thread_id, + TranscriptPreviewState::Loaded(vec![ + TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::User, + text: String::from("Show me the recent transcript"), + }, + TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::Assistant, + text: String::from("Here are the *last* few lines."), + }, + ]), + ); + + let width: u16 = 90; + let height: u16 = 11; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + let rendered = terminal + .backend() + .to_string() + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + + assert_snapshot!("resume_picker_expanded_session", rendered); + } + + #[test] + fn narrow_session_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("Investigate picker expansion"), + thread_id: Some( + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"), + ), + thread_name: None, + created_at: parse_timestamp_str("2026-04-28T16:30:00Z"), + updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"), + cwd: Some(PathBuf::from("/tmp/codex")), + git_branch: Some(String::from("fcoury/session-picker")), + }; + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.all_rows = vec![row.clone()]; + state.filtered_rows = vec![row]; + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + + let width: u16 = 58; + let height: u16 = 6; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + assert_snapshot!( + "resume_picker_narrow_session", + terminal.backend().to_string() + ); + } + + #[test] + fn session_list_more_indicators_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp"); + state.all_rows = (0..5) + .map(|idx| Row { + path: Some(PathBuf::from(format!("/tmp/{idx}.jsonl"))), + preview: format!("item-{idx}"), + thread_id: None, + thread_name: None, + created_at: Some(now - Duration::hours(idx)), + updated_at: Some(now - Duration::minutes(idx * 5)), + cwd: None, + git_branch: None, + }) + .collect(); + state.filtered_rows = state.all_rows.clone(); + state.relative_time_reference = Some(now); + state.selected = 2; + state.scroll_top = 1; + state.update_viewport(/*rows*/ 6, /*width*/ 80); + + let width: u16 = 80; + let height: u16 = 6; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + assert_snapshot!( + "resume_picker_more_indicators", + terminal.backend().to_string() + ); + } + + #[test] + fn density_toggle_clears_stale_more_indicator() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp"); + state.all_rows = (0..4) + .map(|idx| Row { + path: Some(PathBuf::from(format!("/tmp/{idx}.jsonl"))), + preview: format!("item-{idx}"), + thread_id: None, + thread_name: None, + created_at: Some(now - Duration::hours(idx)), + updated_at: Some(now - Duration::minutes(idx * 5)), + cwd: None, + git_branch: None, + }) + .collect(); + state.filtered_rows = state.all_rows.clone(); + state.relative_time_reference = Some(now); + + let width: u16 = 80; + let height: u16 = 6; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + state.update_viewport(height as usize, width); + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + assert!(terminal.backend().to_string().contains("↓ more")); + + state.density = SessionListDensity::Dense; + state.update_viewport(height as usize, width); + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + assert!(!terminal.backend().to_string().contains("↓ more")); + } + #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1828,7 +5245,7 @@ mod tests { fn ensure_minimum_rows_prefetches_when_underfilled() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); - let loader: PageLoader = Arc::new(move |req: PageLoadRequest| { + let loader = page_only_loader(move |req: PageLoadRequest| { request_sink.lock().unwrap().push(req); }); @@ -1859,54 +5276,87 @@ mod tests { } #[test] - fn column_visibility_hides_extra_date_column_when_narrow() { - let metrics = ColumnMetrics { - max_created_width: 8, - max_updated_width: 12, - max_branch_width: 0, - max_cwd_width: 0, - labels: Vec::new(), - }; + fn ensure_minimum_rows_does_not_prefetch_when_comfortable_cards_fill_view() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); - let created = column_visibility(/*area_width*/ 30, &metrics, ThreadSortKey::CreatedAt); - assert_eq!( - created, - ColumnVisibility { - show_created: true, - show_updated: false, - show_branch: false, - show_cwd: false, - } + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, ); + state.reset_pagination(); + state.ingest_page(page( + vec![ + make_row("/tmp/a.jsonl", "2025-01-01T00:00:00Z", "one"), + make_row("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "two"), + make_row("/tmp/c.jsonl", "2025-01-03T00:00:00Z", "three"), + make_row("/tmp/d.jsonl", "2025-01-04T00:00:00Z", "four"), + ], + Some("2025-01-05T00:00:00Z"), + /*num_scanned_files*/ 4, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 6, /*width*/ 80); - let updated = column_visibility(/*area_width*/ 30, &metrics, ThreadSortKey::UpdatedAt); - assert_eq!( - updated, - ColumnVisibility { - show_created: false, - show_updated: true, - show_branch: false, - show_cwd: false, - } - ); + state.ensure_minimum_rows_for_view(/*minimum_rows*/ 6); - let wide = column_visibility(/*area_width*/ 40, &metrics, ThreadSortKey::CreatedAt); - assert_eq!( - wide, - ColumnVisibility { - show_created: true, - show_updated: true, - show_branch: false, - show_cwd: false, - } + assert!(recorded_requests.lock().unwrap().is_empty()); + } + + #[test] + fn ensure_minimum_rows_still_prefetches_when_dense_rows_underfill_view() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, ); + state.density = SessionListDensity::Dense; + state.reset_pagination(); + state.ingest_page(page( + vec![ + make_row("/tmp/a.jsonl", "2025-01-01T00:00:00Z", "one"), + make_row("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "two"), + ], + Some("2025-01-03T00:00:00Z"), + /*num_scanned_files*/ 2, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 10, /*width*/ 80); + + state.ensure_minimum_rows_for_view(/*minimum_rows*/ 10); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert!(guard[0].search_token.is_none()); + } + + #[test] + fn list_viewport_width_matches_rendered_list_inset() { + assert_eq!(list_viewport_width(/*width*/ 80), 76); + assert_eq!(list_viewport_width(/*width*/ 3), 0); } #[tokio::test] async fn toggle_sort_key_reloads_with_new_sort() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); - let loader: PageLoader = Arc::new(move |req: PageLoadRequest| { + let loader = page_only_loader(move |req: PageLoadRequest| { request_sink.lock().unwrap().push(req); }); @@ -1930,15 +5380,123 @@ mod tests { .handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) .await .unwrap(); + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); let guard = recorded_requests.lock().unwrap(); assert_eq!(guard.len(), 2); assert_eq!(guard[1].sort_key, ThreadSortKey::CreatedAt); } + #[tokio::test] + async fn default_filter_focus_arrows_reload_with_new_filter() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ false, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + state.start_initial_load(); + { + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].cwd_filter, Some(PathBuf::from("/tmp/project"))); + } + + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 2); + assert_eq!(guard[1].cwd_filter, None); + } + + #[tokio::test] + async fn all_filter_can_switch_back_to_cwd_when_cwd_candidate_exists() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + state.start_initial_load(); + { + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].cwd_filter, None); + } + + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 2); + assert_eq!(guard[1].cwd_filter, Some(PathBuf::from("/tmp/project"))); + } + + #[tokio::test] + async fn filter_stays_all_when_no_cwd_candidate_exists() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::Any, + /*show_all*/ false, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert_eq!( + search_line(&state, /*width*/ 80) + .to_string() + .matches("Cwd") + .count(), + 0 + ); + + state.start_initial_load(); + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].cwd_filter, None); + } + #[tokio::test] async fn page_navigation_uses_view_rows() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1961,7 +5519,7 @@ mod tests { items, /*next_cursor*/ None, /*num_scanned_files*/ 20, /*reached_scan_cap*/ false, )); - state.update_view_rows(/*rows*/ 5); + state.update_viewport(/*rows*/ 5, /*width*/ 80); assert_eq!(state.selected, 0); state @@ -1981,11 +5539,71 @@ mod tests { .await .unwrap(); assert_eq!(state.selected, 5); + + state + .handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) + .await + .unwrap(); + assert_eq!(state.selected, 19); + + state + .handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)) + .await + .unwrap(); + assert_eq!(state.selected, 0); + } + + #[tokio::test] + async fn end_jumps_to_last_known_row_and_starts_loading_more() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let items = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + state.reset_pagination(); + state.ingest_page(page( + items, + Some("cursor-1"), + /*num_scanned_files*/ 10, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + + state + .handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.selected, 9); + assert!(state.pagination.loading.is_pending()); + assert_eq!(recorded_requests.lock().unwrap().len(), 1); + assert_eq!( + picker_footer_progress_label(&state, /*list_height*/ 5, /*width*/ 80), + " 10 / 10… · 100% " + ); } #[tokio::test] async fn enter_on_row_without_resolvable_thread_id_shows_inline_error() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -2024,7 +5642,7 @@ mod tests { #[tokio::test] async fn enter_on_pathless_thread_uses_thread_id() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -2091,9 +5709,217 @@ mod tests { assert_eq!(row.thread_name, Some(String::from("Named thread"))); } + #[test] + fn thread_to_transcript_cells_renders_core_message_types() { + use transcript::thread_to_transcript_cells; + + let thread_id = ThreadId::new(); + let thread = Thread { + id: thread_id.to_string(), + forked_from_id: None, + preview: String::from("preview"), + ephemeral: false, + model_provider: String::from("openai"), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: String::from("0.0.0"), + source: codex_app_server_protocol::SessionSource::Cli, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![codex_app_server_protocol::Turn { + id: String::from("turn-1"), + items: vec![ + ThreadItem::UserMessage { + id: String::from("user-1"), + content: vec![codex_app_server_protocol::UserInput::Text { + text: String::from("hello from user"), + text_elements: Vec::new(), + }], + }, + ThreadItem::AgentMessage { + id: String::from("agent-1"), + text: String::from("hello from assistant"), + phase: None, + memory_citation: None, + }, + ThreadItem::Plan { + id: String::from("plan-1"), + text: String::from("1. Do the thing"), + }, + ], + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + }; + + let rendered = thread_to_transcript_cells(&thread, RawReasoningVisibility::Visible) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + assert!(rendered.contains("hello from user")); + assert!(rendered.contains("hello from assistant")); + assert!(rendered.contains("Proposed Plan")); + assert!(rendered.contains("Do the thing")); + } + + #[test] + fn thread_to_transcript_cells_hides_raw_reasoning_when_not_enabled() { + use transcript::thread_to_transcript_cells; + + let thread_id = ThreadId::new(); + let thread = Thread { + id: thread_id.to_string(), + forked_from_id: None, + preview: String::from("preview"), + ephemeral: false, + model_provider: String::from("openai"), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: String::from("0.0.0"), + source: codex_app_server_protocol::SessionSource::Cli, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![codex_app_server_protocol::Turn { + id: String::from("turn-1"), + items: vec![ThreadItem::Reasoning { + id: String::from("reasoning-1"), + summary: Vec::new(), + content: vec![String::from("private raw chain of thought")], + }], + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + }; + + let hidden = thread_to_transcript_cells(&thread, RawReasoningVisibility::Hidden) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + let visible = thread_to_transcript_cells(&thread, RawReasoningVisibility::Visible) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + assert!(!hidden.contains("private raw chain of thought")); + assert!(visible.contains("private raw chain of thought")); + } + + #[test] + fn thread_to_transcript_cells_shows_raw_reasoning_over_summary_when_enabled() { + use transcript::thread_to_transcript_cells; + + let thread_id = ThreadId::new(); + let thread = Thread { + id: thread_id.to_string(), + forked_from_id: None, + preview: String::from("preview"), + ephemeral: false, + model_provider: String::from("openai"), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: String::from("0.0.0"), + source: codex_app_server_protocol::SessionSource::Cli, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![codex_app_server_protocol::Turn { + id: String::from("turn-1"), + items: vec![ThreadItem::Reasoning { + id: String::from("reasoning-1"), + summary: vec![String::from("public summary")], + content: vec![String::from("raw reasoning content")], + }], + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + }; + + let rendered = thread_to_transcript_cells(&thread, RawReasoningVisibility::Visible) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + assert!(rendered.contains("raw reasoning content")); + assert!(!rendered.contains("public summary")); + } + #[tokio::test] - async fn up_at_bottom_does_not_scroll_when_visible() { - let loader: PageLoader = Arc::new(|_| {}); + async fn moving_to_last_card_scrolls_when_cards_exceed_viewport() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let mut items = Vec::new(); + for idx in 0..3 { + let ts = format!("2025-02-{:02}T00:00:00Z", idx + 1); + let preview = format!("item-{idx}"); + let path = format!("/tmp/item-{idx}.jsonl"); + items.push(make_row(&path, &ts, &preview)); + } + + state.reset_pagination(); + state.ingest_page(page( + items, /*next_cursor*/ None, /*num_scanned_files*/ 3, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + + state + .handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await + .unwrap(); + assert_eq!(state.scroll_top, 1); + + state + .handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.selected, 2); + assert_eq!(state.scroll_top, 2); + } + + #[tokio::test] + async fn up_from_bottom_keeps_viewport_stable_when_card_remains_visible() { + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -2116,28 +5942,102 @@ mod tests { items, /*next_cursor*/ None, /*num_scanned_files*/ 10, /*reached_scan_cap*/ false, )); - state.update_view_rows(/*rows*/ 5); + state.update_viewport(/*rows*/ 5, /*width*/ 80); state.selected = state.filtered_rows.len().saturating_sub(1); state.ensure_selected_visible(); let initial_top = state.scroll_top; - assert_eq!(initial_top, state.filtered_rows.len().saturating_sub(5)); + assert_eq!(initial_top, state.filtered_rows.len().saturating_sub(1)); state .handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) .await .unwrap(); - assert_eq!(state.scroll_top, initial_top); + assert_eq!(state.scroll_top, initial_top.saturating_sub(1)); assert_eq!(state.selected, state.filtered_rows.len().saturating_sub(2)); } + #[tokio::test] + async fn up_scrolls_only_after_crossing_top_edge() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let mut items = Vec::new(); + for idx in 0..10 { + let ts = format!("2025-02-{:02}T00:00:00Z", idx + 1); + let preview = format!("item-{idx}"); + let path = format!("/tmp/item-{idx}.jsonl"); + items.push(make_row(&path, &ts, &preview)); + } + + state.reset_pagination(); + state.ingest_page(page( + items, /*next_cursor*/ None, /*num_scanned_files*/ 10, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + state.selected = 8; + state.scroll_top = 8; + + state + .handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.selected, 7); + assert_eq!(state.scroll_top, 7); + } + + #[test] + fn list_reports_more_rows_above_and_below() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let mut items = Vec::new(); + for idx in 0..5 { + let ts = format!("2025-02-{:02}T00:00:00Z", idx + 1); + let preview = format!("item-{idx}"); + let path = format!("/tmp/item-{idx}.jsonl"); + items.push(make_row(&path, &ts, &preview)); + } + + state.reset_pagination(); + state.ingest_page(page( + items, /*next_cursor*/ None, /*num_scanned_files*/ 5, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + + assert!(!state.has_more_above()); + assert!(state.has_more_below(/*viewport_height*/ 5)); + + state.scroll_top = 2; + + assert!(state.has_more_above()); + assert!(state.has_more_below(/*viewport_height*/ 5)); + } + #[tokio::test] async fn set_query_loads_until_match_and_respects_scan_cap() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); - let loader: PageLoader = Arc::new(move |req: PageLoadRequest| { + let loader = page_only_loader(move |req: PageLoadRequest| { request_sink.lock().unwrap().push(req); }); @@ -2170,7 +6070,7 @@ mod tests { }; state - .handle_background_event(BackgroundEvent::PageLoaded { + .handle_background_event(BackgroundEvent::Page { request_token: first_request.request_token, search_token: first_request.search_token, page: Ok(page( @@ -2192,7 +6092,7 @@ mod tests { assert!(state.filtered_rows.is_empty()); state - .handle_background_event(BackgroundEvent::PageLoaded { + .handle_background_event(BackgroundEvent::Page { request_token: second_request.request_token, search_token: second_request.search_token, page: Ok(page( @@ -2221,7 +6121,7 @@ mod tests { }; state - .handle_background_event(BackgroundEvent::PageLoaded { + .handle_background_event(BackgroundEvent::Page { request_token: second_request.request_token, search_token: second_request.search_token, page: Ok(page( @@ -2236,7 +6136,7 @@ mod tests { assert_eq!(recorded_requests.lock().unwrap().len(), 1); state - .handle_background_event(BackgroundEvent::PageLoaded { + .handle_background_event(BackgroundEvent::Page { request_token: active_request.request_token, search_token: active_request.search_token, page: Ok(page( @@ -2253,4 +6153,61 @@ mod tests { assert!(!state.search_state.is_active()); assert!(state.pagination.reached_scan_cap); } + + #[tokio::test] + async fn esc_with_empty_query_starts_fresh() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .await + .expect("handle key"); + + assert!(matches!(selection, Some(SessionSelection::StartFresh))); + } + + #[tokio::test] + async fn esc_with_query_clears_search_and_preserves_selected_result() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.reset_pagination(); + state.ingest_page(page( + vec![ + make_row("/tmp/alpha.jsonl", "2025-01-03T00:00:00Z", "alpha"), + make_row("/tmp/beta.jsonl", "2025-01-02T00:00:00Z", "beta"), + ], + /*next_cursor*/ None, + /*num_scanned_files*/ 2, + /*reached_scan_cap*/ false, + )); + state.set_query(String::from("beta")); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .await + .expect("handle key"); + + assert!(selection.is_none()); + assert!(state.query.is_empty()); + assert_eq!(state.filtered_rows.len(), 2); + assert_eq!( + state.filtered_rows[state.selected].path.as_deref(), + Some(Path::new("/tmp/beta.jsonl")) + ); + } } diff --git a/codex-rs/tui/src/resume_picker/transcript.rs b/codex-rs/tui/src/resume_picker/transcript.rs new file mode 100644 index 0000000000..4fe75efe63 --- /dev/null +++ b/codex-rs/tui/src/resume_picker/transcript.rs @@ -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>; + +#[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 { + 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 { + let lines = match item { + ThreadItem::HookPrompt { fragments, .. } => fragments + .iter() + .map(|fragment| { + vec![ + "hook prompt: ".dim(), + fragment.text.trim().to_string().into(), + ] + .into() + }) + .collect::>(), + ThreadItem::CommandExecution { + command, + status, + aggregated_output, + exit_code, + .. + } => { + let mut lines: Vec> = + 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)) +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap new file mode 100644 index 0000000000..62cb8d199b --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap new file mode 100644 index 0000000000..94f74b55f3 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap new file mode 100644 index 0000000000..9b8c39ef50 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 48,)" +--- +❯ 15m ago Propose session picker redesig... diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap new file mode 100644 index 0000000000..64120ef26a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap new file mode 100644 index 0000000000..9b8c39ef50 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 48,)" +--- +❯ 15m ago Propose session picker redesig... diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap new file mode 100644 index 0000000000..50e37a46b9 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_expanded_session.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_expanded_session.snap new file mode 100644 index 0000000000..23470e5a6f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_expanded_session.snap @@ -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. diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_compact.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_compact.snap new file mode 100644 index 0000000000..65fb6f7857 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_compact.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_wide.snap new file mode 100644 index 0000000000..13c111dc1d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_wide.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap new file mode 100644 index 0000000000..76a645aa07 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap new file mode 100644 index 0000000000..db583b9957 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap new file mode 100644 index 0000000000..f48b6543cd --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: terminal.backend().to_string() +--- +Type to search Filter: [Cwd] All Sort: [Updated] Created diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap index 8948163563..b882050d58 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap new file mode 100644 index 0000000000..fa6b47cbbd --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap @@ -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