mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
## Why TUI preferences and persistence paths belong to the local client, while thread configuration and account requirements may come from the active app server. Keeping both in the same resolved `Config` can replace live local preferences when a thread is resumed, forked, reconnected, or switched. ## What changed - Add `LocalSettings` as the TUI-owned source for UI, history, notice, and local persistence settings, and preserve it across session lifecycle transitions. - Write preference changes to the selected user config file and reload local settings from disk when changing roots. - Use the app server's account response for authentication-dependent onboarding and status UI instead of the local model-provider configuration. - Retain platform family and OS metadata reported by remote app servers. ## Testing - Cover local setting defaults, overrides, persistence destinations, reloads, and preservation across widget replacement and root switching. - Cover remote platform metadata and server-controlled authentication UI. GitOrigin-RevId: dbc2965fb1d6cbecb3f973f6133f3dcd4467b753
6940 lines
231 KiB
Rust
6940 lines
231 KiB
Rust
use std::collections::HashMap;
|
||
use std::collections::HashSet;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
|
||
use crate::app_server_session::AppServerSession;
|
||
use crate::clipboard_paste::normalize_pasted_search_query;
|
||
use crate::color::blend;
|
||
use crate::color::is_light;
|
||
use crate::key_hint::KeyBindingListExt;
|
||
use crate::key_hint::is_plain_text_key_event;
|
||
use crate::keymap::ListAction;
|
||
use crate::keymap::ListKeymap;
|
||
use crate::keymap::PagerKeymap;
|
||
use crate::keymap::RuntimeChordKeymap;
|
||
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::thread_transcript::RawReasoningVisibility;
|
||
use crate::thread_transcript::TranscriptCells;
|
||
use crate::thread_transcript::load_session_transcript;
|
||
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_client::AppServerRequestHandle;
|
||
use codex_app_server_protocol::ClientRequest;
|
||
use codex_app_server_protocol::RequestId;
|
||
use codex_app_server_protocol::Thread;
|
||
use codex_app_server_protocol::ThreadArchiveParams;
|
||
use codex_app_server_protocol::ThreadArchiveResponse;
|
||
use codex_app_server_protocol::ThreadHistoryMode;
|
||
use codex_app_server_protocol::ThreadListCwdFilter;
|
||
use codex_app_server_protocol::ThreadListParams;
|
||
use codex_app_server_protocol::ThreadSortKey;
|
||
use codex_app_server_protocol::ThreadUnarchiveParams;
|
||
use codex_app_server_protocol::ThreadUnarchiveResponse;
|
||
use codex_config::types::SessionPickerViewMode;
|
||
use codex_protocol::ThreadId;
|
||
use codex_utils_path as path_utils;
|
||
use color_eyre::eyre::Result;
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyEventKind;
|
||
use crossterm::event::KeyModifiers;
|
||
use ratatui::layout::Constraint;
|
||
use ratatui::layout::Layout;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::layout::Size;
|
||
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::sync::oneshot;
|
||
use tokio_stream::StreamExt;
|
||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||
use tracing::warn;
|
||
use unicode_width::UnicodeWidthStr;
|
||
use uuid::Uuid;
|
||
|
||
mod archive;
|
||
mod page_loading;
|
||
|
||
use page_loading::PageLoadMode;
|
||
use page_loading::PaginationState;
|
||
|
||
#[path = "resume_picker_transcript_preview.rs"]
|
||
mod transcript_preview;
|
||
|
||
pub(crate) use transcript_preview::load_transcript_preview;
|
||
|
||
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 {
|
||
pub path: Option<PathBuf>,
|
||
pub thread_id: ThreadId,
|
||
/// History mode observed during selection, if the server provided one.
|
||
pub history_mode: Option<ThreadHistoryMode>,
|
||
}
|
||
|
||
impl SessionTarget {
|
||
pub fn display_label(&self) -> String {
|
||
self.path
|
||
.as_ref()
|
||
.map(|path| path.display().to_string())
|
||
.unwrap_or_else(|| format!("thread {}", self.thread_id))
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub enum SessionSelection {
|
||
StartFresh,
|
||
AgentsOverview,
|
||
Resume(SessionTarget),
|
||
Fork(SessionTarget),
|
||
Exit,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub enum SessionPickerAction {
|
||
Resume,
|
||
Fork,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub enum SessionPickerLaunchContext {
|
||
Startup,
|
||
ExistingSession { current_thread_id: Option<ThreadId> },
|
||
}
|
||
|
||
impl SessionPickerAction {
|
||
fn title(self) -> &'static str {
|
||
match self {
|
||
SessionPickerAction::Resume => "Resume a previous session",
|
||
SessionPickerAction::Fork => "Fork a previous session",
|
||
}
|
||
}
|
||
|
||
fn action_label(self) -> &'static str {
|
||
match self {
|
||
SessionPickerAction::Resume => "resume",
|
||
SessionPickerAction::Fork => "fork",
|
||
}
|
||
}
|
||
|
||
fn selection(self, target_session: SessionTarget) -> SessionSelection {
|
||
match self {
|
||
SessionPickerAction::Resume => SessionSelection::Resume(target_session),
|
||
SessionPickerAction::Fork => SessionSelection::Fork(target_session),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct PageLoadRequest {
|
||
cursor: Option<PageCursor>,
|
||
request_token: usize,
|
||
search_token: Option<usize>,
|
||
mode: PageLoadMode,
|
||
cwd_filter: Option<PathBuf>,
|
||
status: SessionStatus,
|
||
provider_filter: ProviderFilter,
|
||
sort_key: ThreadSortKey,
|
||
}
|
||
|
||
enum PickerLoadRequest {
|
||
Page(PageLoadRequest),
|
||
Preview {
|
||
thread_id: ThreadId,
|
||
},
|
||
Transcript {
|
||
thread_id: ThreadId,
|
||
cancellation: oneshot::Receiver<()>,
|
||
},
|
||
Archive {
|
||
thread_id: ThreadId,
|
||
},
|
||
Unarchive {
|
||
thread_id: ThreadId,
|
||
},
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
enum ProviderFilter {
|
||
Any,
|
||
MatchDefault(String),
|
||
}
|
||
|
||
#[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 SessionStatus {
|
||
Active,
|
||
Archived,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum ToolbarControl {
|
||
Filter,
|
||
Status,
|
||
Sort,
|
||
}
|
||
|
||
impl ToolbarControl {
|
||
fn previous(self, action: SessionPickerAction) -> Self {
|
||
match self {
|
||
Self::Filter => Self::Sort,
|
||
Self::Status => Self::Filter,
|
||
Self::Sort if matches!(action, SessionPickerAction::Resume) => Self::Status,
|
||
Self::Sort => Self::Filter,
|
||
}
|
||
}
|
||
|
||
fn next(self, action: SessionPickerAction) -> Self {
|
||
match self {
|
||
Self::Filter if matches!(action, SessionPickerAction::Resume) => Self::Status,
|
||
Self::Filter | Self::Status => 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<SessionPickerViewMode> for SessionListDensity {
|
||
fn from(mode: SessionPickerViewMode) -> Self {
|
||
match mode {
|
||
SessionPickerViewMode::Comfortable => Self::Comfortable,
|
||
SessionPickerViewMode::Dense => Self::Dense,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<SessionListDensity> for SessionPickerViewMode {
|
||
fn from(density: SessionListDensity) -> Self {
|
||
match density {
|
||
SessionListDensity::Comfortable => Self::Comfortable,
|
||
SessionListDensity::Dense => Self::Dense,
|
||
}
|
||
}
|
||
}
|
||
|
||
type PickerLoader = Arc<dyn Fn(PickerLoadRequest) + Send + Sync>;
|
||
enum BackgroundEvent {
|
||
Page {
|
||
request_token: usize,
|
||
search_token: Option<usize>,
|
||
page: std::io::Result<PickerPage>,
|
||
},
|
||
Preview {
|
||
thread_id: ThreadId,
|
||
preview: std::io::Result<Vec<TranscriptPreviewLine>>,
|
||
},
|
||
Transcript {
|
||
thread_id: ThreadId,
|
||
transcript: std::io::Result<TranscriptCells>,
|
||
},
|
||
Archive {
|
||
thread_id: ThreadId,
|
||
result: std::io::Result<()>,
|
||
},
|
||
Unarchive {
|
||
thread_id: ThreadId,
|
||
result: std::io::Result<SessionTarget>,
|
||
},
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
enum PageCursor {
|
||
AppServer(String),
|
||
}
|
||
|
||
struct PickerPage {
|
||
rows: Vec<Row>,
|
||
history_modes: HashMap<ThreadId, ThreadHistoryMode>,
|
||
next_cursor: Option<PageCursor>,
|
||
num_scanned_files: usize,
|
||
reached_scan_cap: bool,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct SessionPickerViewPersistence {
|
||
codex_home: PathBuf,
|
||
}
|
||
|
||
struct SessionPickerRunOptions {
|
||
show_all: bool,
|
||
filter_cwd: Option<PathBuf>,
|
||
local_filter_cwd: Option<PathBuf>,
|
||
action: SessionPickerAction,
|
||
launch_context: SessionPickerLaunchContext,
|
||
provider_filter: ProviderFilter,
|
||
initial_density: SessionListDensity,
|
||
view_persistence: Option<SessionPickerViewPersistence>,
|
||
pager_keymap: PagerKeymap,
|
||
list_keymap: ListKeymap,
|
||
initial_page_mode: PageLoadMode,
|
||
chord_keymap: Arc<RuntimeChordKeymap>,
|
||
}
|
||
|
||
/// Interactive session picker that lists app-server threads with simple search,
|
||
/// lazy transcript previews, and pagination.
|
||
///
|
||
/// Sessions render as compact multi-line records with stable metadata first and
|
||
/// the conversation preview last. Users can focus the 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
|
||
/// picker deduplicates across pages to handle overlapping windows when new
|
||
/// sessions appear during pagination.
|
||
///
|
||
/// Filtering happens in two layers:
|
||
/// 1. Provider, source, and eligible working-directory filtering at the backend.
|
||
/// 2. Typed search filtering over loaded rows in the picker.
|
||
pub async fn run_resume_picker_with_app_server(
|
||
tui: &mut Tui,
|
||
config: &Config,
|
||
local_settings: &crate::local_settings::LocalSettings,
|
||
show_all: bool,
|
||
include_non_interactive: bool,
|
||
app_server: AppServerSession,
|
||
) -> Result<SessionSelection> {
|
||
let archive_request_handle = app_server.request_handle();
|
||
run_resume_picker_with_launch_context(
|
||
tui,
|
||
config,
|
||
local_settings,
|
||
show_all,
|
||
include_non_interactive,
|
||
app_server,
|
||
archive_request_handle,
|
||
SessionPickerLaunchContext::Startup,
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[expect(
|
||
clippy::too_many_arguments,
|
||
reason = "keep local preferences separate while the legacy Config parameter is still required"
|
||
)]
|
||
pub async fn run_resume_picker_from_existing_session_with_app_server(
|
||
tui: &mut Tui,
|
||
config: &Config,
|
||
local_settings: &crate::local_settings::LocalSettings,
|
||
show_all: bool,
|
||
include_non_interactive: bool,
|
||
app_server: AppServerSession,
|
||
archive_request_handle: AppServerRequestHandle,
|
||
current_thread_id: Option<ThreadId>,
|
||
) -> Result<SessionSelection> {
|
||
run_resume_picker_with_launch_context(
|
||
tui,
|
||
config,
|
||
local_settings,
|
||
show_all,
|
||
include_non_interactive,
|
||
app_server,
|
||
archive_request_handle,
|
||
SessionPickerLaunchContext::ExistingSession { current_thread_id },
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[expect(
|
||
clippy::too_many_arguments,
|
||
reason = "keep local preferences separate while the legacy Config parameter is still required"
|
||
)]
|
||
async fn run_resume_picker_with_launch_context(
|
||
tui: &mut Tui,
|
||
config: &Config,
|
||
local_settings: &crate::local_settings::LocalSettings,
|
||
show_all: bool,
|
||
include_non_interactive: bool,
|
||
app_server: AppServerSession,
|
||
archive_request_handle: AppServerRequestHandle,
|
||
launch_context: SessionPickerLaunchContext,
|
||
) -> Result<SessionSelection> {
|
||
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
|
||
let uses_remote_workspace = app_server.uses_remote_workspace();
|
||
let cwd_filter = picker_cwd_filter(
|
||
config.cwd.as_path(),
|
||
/*show_all*/ false,
|
||
uses_remote_workspace,
|
||
app_server.remote_cwd_override(),
|
||
);
|
||
let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, uses_remote_workspace);
|
||
let provider_filter = picker_provider_filter(config, uses_remote_workspace);
|
||
let runtime_keymap = picker_runtime_keymap(local_settings)?;
|
||
let options = SessionPickerRunOptions {
|
||
show_all,
|
||
filter_cwd: cwd_filter,
|
||
local_filter_cwd,
|
||
action: SessionPickerAction::Resume,
|
||
launch_context,
|
||
provider_filter,
|
||
initial_density: SessionListDensity::from(
|
||
local_settings.tui.session_picker_view.unwrap_or_default(),
|
||
),
|
||
view_persistence: Some(SessionPickerViewPersistence {
|
||
codex_home: local_settings.codex_home.to_path_buf(),
|
||
}),
|
||
pager_keymap: runtime_keymap.pager,
|
||
list_keymap: runtime_keymap.list,
|
||
initial_page_mode: if uses_remote_workspace {
|
||
PageLoadMode::StoreDefault
|
||
} else {
|
||
PageLoadMode::StateDbOnly
|
||
},
|
||
chord_keymap: runtime_keymap.chords,
|
||
};
|
||
run_session_picker_with_loader(
|
||
tui,
|
||
options,
|
||
spawn_app_server_page_loader(
|
||
app_server,
|
||
archive_request_handle,
|
||
include_non_interactive,
|
||
raw_reasoning_visibility(config),
|
||
(!uses_remote_workspace).then(|| config.clone()),
|
||
bg_tx,
|
||
),
|
||
bg_rx,
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub async fn run_fork_picker_with_app_server(
|
||
tui: &mut Tui,
|
||
config: &Config,
|
||
local_settings: &crate::local_settings::LocalSettings,
|
||
show_all: bool,
|
||
app_server: AppServerSession,
|
||
) -> Result<SessionSelection> {
|
||
let archive_request_handle = app_server.request_handle();
|
||
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
|
||
let uses_remote_workspace = app_server.uses_remote_workspace();
|
||
let cwd_filter = picker_cwd_filter(
|
||
config.cwd.as_path(),
|
||
/*show_all*/ false,
|
||
uses_remote_workspace,
|
||
app_server.remote_cwd_override(),
|
||
);
|
||
let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, uses_remote_workspace);
|
||
let provider_filter = picker_provider_filter(config, uses_remote_workspace);
|
||
let runtime_keymap = picker_runtime_keymap(local_settings)?;
|
||
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(
|
||
local_settings.tui.session_picker_view.unwrap_or_default(),
|
||
),
|
||
view_persistence: Some(SessionPickerViewPersistence {
|
||
codex_home: local_settings.codex_home.to_path_buf(),
|
||
}),
|
||
pager_keymap: runtime_keymap.pager,
|
||
list_keymap: runtime_keymap.list,
|
||
initial_page_mode: if uses_remote_workspace {
|
||
PageLoadMode::StoreDefault
|
||
} else {
|
||
PageLoadMode::StateDbOnly
|
||
},
|
||
chord_keymap: runtime_keymap.chords,
|
||
};
|
||
run_session_picker_with_loader(
|
||
tui,
|
||
options,
|
||
spawn_app_server_page_loader(
|
||
app_server,
|
||
archive_request_handle,
|
||
/*include_non_interactive*/ false,
|
||
raw_reasoning_visibility(config),
|
||
(!uses_remote_workspace).then(|| config.clone()),
|
||
bg_tx,
|
||
),
|
||
bg_rx,
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn run_session_picker_with_loader(
|
||
tui: &mut Tui,
|
||
options: SessionPickerRunOptions,
|
||
picker_loader: PickerLoader,
|
||
bg_rx: mpsc::UnboundedReceiver<BackgroundEvent>,
|
||
) -> Result<SessionSelection> {
|
||
let alt = AltScreenGuard::enter(tui);
|
||
let mut state = PickerState::new(
|
||
alt.tui.frame_requester(),
|
||
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.list_keymap = options.list_keymap;
|
||
state.chord_keymap = options.chord_keymap;
|
||
state.launch_context = options.launch_context;
|
||
state.initial_page_mode = options.initial_page_mode;
|
||
state.start_initial_load();
|
||
state.request_frame();
|
||
|
||
if let Ok(size) = alt.tui.terminal.size() {
|
||
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, alt.tui.terminal.last_known_screen_size)?;
|
||
if state.launch_context == SessionPickerLaunchContext::Startup {
|
||
alt.tui.discard_pending_input_before_interactive_screen()?;
|
||
}
|
||
let mut tui_events = alt.tui.event_stream().fuse();
|
||
let mut background_events = UnboundedReceiverStream::new(bg_rx).fuse();
|
||
|
||
loop {
|
||
tokio::select! {
|
||
Some(ev) = tui_events.next() => {
|
||
let screen_size = alt.tui.screen_size_for_event(&ev)?;
|
||
let ev = if let TuiEvent::Key(key) = ev {
|
||
let Some(key) = state.route_key_chord(key) else {
|
||
continue;
|
||
};
|
||
TuiEvent::Key(key)
|
||
} else {
|
||
ev
|
||
};
|
||
if state.overlay.is_some() {
|
||
state.handle_overlay_event(alt.tui, ev)?;
|
||
continue;
|
||
}
|
||
match ev {
|
||
TuiEvent::Key(key) => {
|
||
if matches!(key.kind, KeyEventKind::Release) {
|
||
continue;
|
||
}
|
||
if let Some(sel) = state.handle_key(key).await? {
|
||
return Ok(sel);
|
||
}
|
||
}
|
||
TuiEvent::Paste(pasted) => {
|
||
state.handle_paste(pasted);
|
||
}
|
||
TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => {
|
||
let list_width = list_viewport_width(screen_size.width);
|
||
let list_height =
|
||
usize::from(screen_size.height.saturating_sub(PICKER_CHROME_HEIGHT));
|
||
state.update_viewport(list_height, list_width);
|
||
state.ensure_minimum_rows_for_view(list_height);
|
||
draw_picker(alt.tui, &state, screen_size)?;
|
||
if state.note_transcript_loading_frame_drawn() {
|
||
state.open_pending_transcript_if_ready();
|
||
}
|
||
}
|
||
TuiEvent::FocusLost => {}
|
||
}
|
||
}
|
||
Some(event) = background_events.next() => {
|
||
if let Some(selection) = state.handle_background_event(event).await? {
|
||
return Ok(selection);
|
||
}
|
||
}
|
||
else => break,
|
||
}
|
||
}
|
||
|
||
// Fallback – treat as cancel/new
|
||
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<PathBuf>,
|
||
uses_remote_workspace: bool,
|
||
) -> Option<PathBuf> {
|
||
if uses_remote_workspace {
|
||
None
|
||
} else {
|
||
cwd_filter.clone()
|
||
}
|
||
}
|
||
|
||
fn picker_provider_filter(config: &Config, uses_remote_workspace: bool) -> ProviderFilter {
|
||
if uses_remote_workspace {
|
||
ProviderFilter::Any
|
||
} else {
|
||
ProviderFilter::MatchDefault(config.model_provider_id.to_string())
|
||
}
|
||
}
|
||
|
||
fn picker_runtime_keymap(config: &crate::local_settings::LocalSettings) -> Result<RuntimeKeymap> {
|
||
RuntimeKeymap::from_config(&config.tui.keymap)
|
||
.map_err(|err| color_eyre::eyre::eyre!("invalid keymap configuration: {err}"))
|
||
}
|
||
|
||
fn picker_cwd_filter(
|
||
config_cwd: &Path,
|
||
show_all: bool,
|
||
uses_remote_workspace: bool,
|
||
remote_cwd_override: Option<&Path>,
|
||
) -> Option<PathBuf> {
|
||
if show_all {
|
||
None
|
||
} else if uses_remote_workspace {
|
||
remote_cwd_override.map(Path::to_path_buf)
|
||
} else {
|
||
Some(config_cwd.to_path_buf())
|
||
}
|
||
}
|
||
|
||
fn spawn_app_server_page_loader(
|
||
app_server: AppServerSession,
|
||
archive_request_handle: AppServerRequestHandle,
|
||
include_non_interactive: bool,
|
||
raw_reasoning_visibility: RawReasoningVisibility,
|
||
config: Option<Config>,
|
||
bg_tx: mpsc::UnboundedSender<BackgroundEvent>,
|
||
) -> PickerLoader {
|
||
let (request_tx, mut request_rx) = mpsc::unbounded_channel::<PickerLoadRequest>();
|
||
|
||
tokio::spawn(async move {
|
||
let mut app_server = app_server;
|
||
while let Some(request) = request_rx.recv().await {
|
||
match request {
|
||
PickerLoadRequest::Page(request) => {
|
||
let cursor = request.cursor.map(|PageCursor::AppServer(cursor)| cursor);
|
||
let params = thread_list_params(
|
||
cursor,
|
||
request.cwd_filter.as_deref(),
|
||
request.status,
|
||
request.provider_filter,
|
||
request.sort_key,
|
||
include_non_interactive,
|
||
matches!(request.mode, PageLoadMode::StateDbOnly),
|
||
);
|
||
let page = load_app_server_page(&mut app_server, params).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, config.as_ref()).await;
|
||
let _ = bg_tx.send(BackgroundEvent::Preview { thread_id, preview });
|
||
}
|
||
PickerLoadRequest::Transcript {
|
||
thread_id,
|
||
cancellation,
|
||
} => {
|
||
tokio::select! {
|
||
transcript = load_session_transcript(
|
||
&mut app_server,
|
||
thread_id,
|
||
raw_reasoning_visibility,
|
||
config.as_ref(),
|
||
) => {
|
||
let _ = bg_tx.send(BackgroundEvent::Transcript {
|
||
thread_id,
|
||
transcript,
|
||
});
|
||
}
|
||
_ = cancellation => {}
|
||
}
|
||
}
|
||
PickerLoadRequest::Archive { thread_id } => {
|
||
let result = archive_request_handle
|
||
.request_typed::<ThreadArchiveResponse>(ClientRequest::ThreadArchive {
|
||
request_id: RequestId::String(format!(
|
||
"resume-picker-archive-{}",
|
||
Uuid::new_v4()
|
||
)),
|
||
params: ThreadArchiveParams {
|
||
thread_id: thread_id.to_string(),
|
||
},
|
||
})
|
||
.await
|
||
.map(|_| ())
|
||
.map_err(std::io::Error::other);
|
||
let _ = bg_tx.send(BackgroundEvent::Archive { thread_id, result });
|
||
}
|
||
PickerLoadRequest::Unarchive { thread_id } => {
|
||
let result = archive_request_handle
|
||
.request_typed::<ThreadUnarchiveResponse>(ClientRequest::ThreadUnarchive {
|
||
request_id: RequestId::String(format!(
|
||
"resume-picker-unarchive-{}",
|
||
Uuid::new_v4()
|
||
)),
|
||
params: ThreadUnarchiveParams {
|
||
thread_id: thread_id.to_string(),
|
||
},
|
||
})
|
||
.await
|
||
.map(|response| SessionTarget {
|
||
path: response.thread.path,
|
||
thread_id,
|
||
history_mode: Some(response.thread.history_mode),
|
||
})
|
||
.map_err(std::io::Error::other);
|
||
let _ = bg_tx.send(BackgroundEvent::Unarchive { thread_id, result });
|
||
}
|
||
}
|
||
}
|
||
if let Err(err) = app_server.shutdown().await {
|
||
warn!(%err, "Failed to shut down app-server picker session");
|
||
}
|
||
});
|
||
|
||
Arc::new(move |request: PickerLoadRequest| {
|
||
let _ = request_tx.send(request);
|
||
})
|
||
}
|
||
|
||
/// Returns the human-readable column header for the given sort key.
|
||
fn sort_key_label(sort_key: ThreadSortKey) -> &'static str {
|
||
match sort_key {
|
||
ThreadSortKey::CreatedAt => "Created",
|
||
ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt | ThreadSortKey::SectionPosition => {
|
||
"Updated"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// RAII guard that ensures we leave the alt-screen on scope exit.
|
||
struct AltScreenGuard<'a> {
|
||
tui: &'a mut Tui,
|
||
}
|
||
|
||
impl<'a> AltScreenGuard<'a> {
|
||
fn enter(tui: &'a mut Tui) -> Self {
|
||
let _ = tui.enter_alt_screen();
|
||
Self { tui }
|
||
}
|
||
}
|
||
|
||
impl Drop for AltScreenGuard<'_> {
|
||
fn drop(&mut self) {
|
||
let _ = self.tui.leave_alt_screen();
|
||
}
|
||
}
|
||
|
||
struct PickerState {
|
||
requester: FrameRequester,
|
||
relative_time_reference: Option<DateTime<Utc>>,
|
||
pagination: PaginationState,
|
||
all_rows: Vec<Row>,
|
||
filtered_rows: Vec<Row>,
|
||
thread_history_modes: HashMap<ThreadId, ThreadHistoryMode>,
|
||
seen_rows: HashSet<SeenRowKey>,
|
||
selected: usize,
|
||
scroll_top: usize,
|
||
pending_page_down_target: Option<usize>,
|
||
frozen_footer_percent: Option<u8>,
|
||
query: String,
|
||
search_state: SearchState,
|
||
next_request_token: usize,
|
||
next_search_token: usize,
|
||
picker_loader: PickerLoader,
|
||
view_rows: Option<usize>,
|
||
view_width: Option<u16>,
|
||
provider_filter: ProviderFilter,
|
||
filter_mode: SessionFilterMode,
|
||
status: SessionStatus,
|
||
filter_cwd: Option<PathBuf>,
|
||
local_filter_cwd: Option<PathBuf>,
|
||
toolbar_focus: ToolbarControl,
|
||
density: SessionListDensity,
|
||
launch_context: SessionPickerLaunchContext,
|
||
view_persistence: Option<SessionPickerViewPersistence>,
|
||
action: SessionPickerAction,
|
||
sort_key: ThreadSortKey,
|
||
inline_error: Option<String>,
|
||
archive_state: archive::ArchiveState,
|
||
expanded_thread_id: Option<ThreadId>,
|
||
transcript_previews: HashMap<ThreadId, TranscriptPreviewState>,
|
||
transcript_cells: HashMap<ThreadId, SessionTranscriptState>,
|
||
pending_transcript_open: Option<ThreadId>,
|
||
pending_transcript_cancellation: Option<oneshot::Sender<()>>,
|
||
transcript_loading_frame_shown: bool,
|
||
overlay: Option<Overlay>,
|
||
pager_keymap: PagerKeymap,
|
||
list_keymap: ListKeymap,
|
||
initial_page_mode: PageLoadMode,
|
||
chord_keymap: Arc<RuntimeChordKeymap>,
|
||
chord_matcher: crate::keymap::KeyChordMatcher,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
enum SearchState {
|
||
Idle,
|
||
Active { token: usize },
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
enum TranscriptPreviewState {
|
||
Loading,
|
||
Loaded(Vec<TranscriptPreviewLine>),
|
||
Failed,
|
||
}
|
||
|
||
enum SessionTranscriptState {
|
||
Loading,
|
||
Loaded(TranscriptCells),
|
||
Failed,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub(crate) struct TranscriptPreviewLine {
|
||
speaker: TranscriptPreviewSpeaker,
|
||
text: String,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum TranscriptPreviewSpeaker {
|
||
User,
|
||
Assistant,
|
||
}
|
||
|
||
enum LoadTrigger {
|
||
Scroll,
|
||
Search { token: usize },
|
||
}
|
||
|
||
async fn load_app_server_page(
|
||
app_server: &mut AppServerSession,
|
||
params: ThreadListParams,
|
||
) -> std::io::Result<PickerPage> {
|
||
let response = app_server
|
||
.thread_list(params)
|
||
.await
|
||
.map_err(std::io::Error::other)?;
|
||
let num_scanned_files = response.data.len();
|
||
let (rows, history_modes): (Vec<_>, HashMap<_, _>) = response
|
||
.data
|
||
.into_iter()
|
||
.filter_map(|thread| {
|
||
let history_mode = thread.history_mode;
|
||
let row = row_from_app_server_thread(thread)?;
|
||
let thread_id = row.thread_id?;
|
||
Some((row, (thread_id, history_mode)))
|
||
})
|
||
.unzip();
|
||
|
||
Ok(PickerPage {
|
||
rows,
|
||
history_modes,
|
||
next_cursor: response.next_cursor.map(PageCursor::AppServer),
|
||
num_scanned_files,
|
||
reached_scan_cap: false,
|
||
})
|
||
}
|
||
|
||
impl SearchState {
|
||
fn active_token(&self) -> Option<usize> {
|
||
match self {
|
||
SearchState::Idle => None,
|
||
SearchState::Active { token } => Some(*token),
|
||
}
|
||
}
|
||
|
||
fn is_active(&self) -> bool {
|
||
self.active_token().is_some()
|
||
}
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct Row {
|
||
path: Option<PathBuf>,
|
||
preview: String,
|
||
thread_id: Option<ThreadId>,
|
||
thread_name: Option<String>,
|
||
created_at: Option<DateTime<Utc>>,
|
||
updated_at: Option<DateTime<Utc>>,
|
||
cwd: Option<PathBuf>,
|
||
git_branch: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||
enum SeenRowKey {
|
||
Path(PathBuf),
|
||
Thread(ThreadId),
|
||
}
|
||
|
||
impl Row {
|
||
fn seen_key(&self) -> Option<SeenRowKey> {
|
||
if let Some(path) = self.path.clone() {
|
||
return Some(SeenRowKey::Path(path));
|
||
}
|
||
self.thread_id.map(SeenRowKey::Thread)
|
||
}
|
||
|
||
fn display_preview(&self) -> &str {
|
||
self.thread_name.as_deref().unwrap_or(&self.preview)
|
||
}
|
||
|
||
fn matches_query(&self, query: &str) -> bool {
|
||
if self.preview.to_lowercase().contains(query) {
|
||
return true;
|
||
}
|
||
if let Some(thread_name) = self.thread_name.as_ref()
|
||
&& thread_name.to_lowercase().contains(query)
|
||
{
|
||
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
|
||
}
|
||
}
|
||
|
||
impl PickerState {
|
||
fn new(
|
||
requester: FrameRequester,
|
||
picker_loader: PickerLoader,
|
||
provider_filter: ProviderFilter,
|
||
show_all: bool,
|
||
filter_cwd: Option<PathBuf>,
|
||
action: SessionPickerAction,
|
||
) -> Self {
|
||
Self {
|
||
requester,
|
||
relative_time_reference: None,
|
||
pagination: PaginationState::new(),
|
||
all_rows: Vec::new(),
|
||
filtered_rows: Vec::new(),
|
||
thread_history_modes: HashMap::new(),
|
||
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,
|
||
picker_loader,
|
||
view_rows: None,
|
||
view_width: None,
|
||
provider_filter,
|
||
filter_mode: SessionFilterMode::from_show_all(show_all, filter_cwd.as_deref()),
|
||
status: SessionStatus::Active,
|
||
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,
|
||
archive_state: archive::ArchiveState::default(),
|
||
expanded_thread_id: None,
|
||
transcript_previews: HashMap::new(),
|
||
transcript_cells: HashMap::new(),
|
||
pending_transcript_open: None,
|
||
pending_transcript_cancellation: None,
|
||
transcript_loading_frame_shown: false,
|
||
overlay: None,
|
||
pager_keymap: RuntimeKeymap::defaults().pager,
|
||
list_keymap: RuntimeKeymap::defaults().list,
|
||
initial_page_mode: PageLoadMode::StoreDefault,
|
||
chord_keymap: Arc::default(),
|
||
chord_matcher: crate::keymap::KeyChordMatcher::default(),
|
||
}
|
||
}
|
||
|
||
fn route_key_chord(&mut self, key: KeyEvent) -> Option<KeyEvent> {
|
||
let context = if self.overlay.is_some() {
|
||
crate::keymap::KeymapContext::Pager
|
||
} else {
|
||
crate::keymap::KeymapContext::List
|
||
};
|
||
match self.chord_matcher.advance(
|
||
key,
|
||
&self.chord_keymap,
|
||
crate::keymap::KeymapContextSet::new(context),
|
||
tokio::time::Instant::now(),
|
||
) {
|
||
crate::keymap::KeyChordMatch::PassThrough => Some(key),
|
||
crate::keymap::KeyChordMatch::Completed(dispatch_event) => Some(dispatch_event),
|
||
crate::keymap::KeyChordMatch::Pending(_)
|
||
| crate::keymap::KeyChordMatch::Cancelled
|
||
| crate::keymap::KeyChordMatch::Ignored => None,
|
||
}
|
||
}
|
||
|
||
fn request_frame(&self) {
|
||
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);
|
||
let (cancellation_tx, cancellation) = oneshot::channel();
|
||
self.pending_transcript_cancellation = Some(cancellation_tx);
|
||
(self.picker_loader)(PickerLoadRequest::Transcript {
|
||
thread_id,
|
||
cancellation,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
fn handle_transcript_loading_key(&mut self, key: KeyEvent) -> Option<SessionSelection> {
|
||
match key {
|
||
KeyEvent {
|
||
code: KeyCode::Char('c'),
|
||
modifiers,
|
||
..
|
||
} if modifiers.contains(KeyModifiers::CONTROL) => Some(SessionSelection::Exit),
|
||
key if self.list_keymap.cancel.is_pressed(key) => {
|
||
if let Some(thread_id) = self.pending_transcript_open.take()
|
||
&& matches!(
|
||
self.transcript_cells.get(&thread_id),
|
||
Some(SessionTranscriptState::Loading)
|
||
)
|
||
{
|
||
self.transcript_cells.remove(&thread_id);
|
||
}
|
||
if let Some(cancellation) = self.pending_transcript_cancellation.take() {
|
||
let _ = cancellation.send(());
|
||
}
|
||
self.transcript_loading_frame_shown = false;
|
||
self.request_frame();
|
||
None
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
async fn handle_key(&mut self, key: KeyEvent) -> Result<Option<SessionSelection>> {
|
||
self.inline_error = None;
|
||
if self.is_transcript_loading() {
|
||
return Ok(self.handle_transcript_loading_key(key));
|
||
}
|
||
if !self.list_keymap.page_down.is_pressed(key) {
|
||
self.pending_page_down_target = None;
|
||
}
|
||
// The session picker is always searchable, so plain text belongs to
|
||
// the query first. Modified list bindings still route through the
|
||
// runtime keymap below.
|
||
let allow_plain_char_navigation = !is_plain_text_key_event(key);
|
||
match key {
|
||
KeyEvent {
|
||
code: KeyCode::Char('c'),
|
||
modifiers,
|
||
..
|
||
} if modifiers.contains(KeyModifiers::CONTROL) => {
|
||
return Ok(Some(SessionSelection::Exit));
|
||
}
|
||
_ if self.list_keymap.cancel.is_pressed(key) => {
|
||
if self.query.is_empty() {
|
||
return Ok(Some(SessionSelection::StartFresh));
|
||
}
|
||
self.clear_query_preserving_selection();
|
||
}
|
||
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;
|
||
}
|
||
_ if self.list_keymap.accept.is_pressed(key)
|
||
&& !matches!(self.archive_state, archive::ArchiveState::Idle) => {}
|
||
_ if self.list_keymap.accept.is_pressed(key) => {
|
||
if let Some(row) = self.filtered_rows.get(self.selected) {
|
||
let path = row.path.clone();
|
||
let thread_id = match row.thread_id {
|
||
Some(thread_id) => Some(thread_id),
|
||
None => match path.as_ref() {
|
||
Some(path) => {
|
||
resolve_session_thread_id(path.as_path(), /*id_str_if_uuid*/ None)
|
||
.await
|
||
}
|
||
None => None,
|
||
},
|
||
};
|
||
if let Some(thread_id) = thread_id {
|
||
if self.status == SessionStatus::Archived {
|
||
self.request_unarchive(thread_id);
|
||
return Ok(None);
|
||
}
|
||
return Ok(Some(self.action.selection(SessionTarget {
|
||
path,
|
||
thread_id,
|
||
history_mode: self.thread_history_modes.get(&thread_id).copied(),
|
||
})));
|
||
}
|
||
self.inline_error = Some(match path {
|
||
Some(path) => {
|
||
format!("Failed to read session metadata from {}", path.display())
|
||
}
|
||
None => {
|
||
String::from("Failed to read session metadata from selected session")
|
||
}
|
||
});
|
||
self.request_frame();
|
||
}
|
||
}
|
||
_ if allow_plain_char_navigation && self.list_keymap.move_up.is_pressed(key) => {
|
||
if self.selected > 0 {
|
||
self.selected -= 1;
|
||
self.ensure_selected_visible();
|
||
}
|
||
self.request_frame();
|
||
}
|
||
_ if allow_plain_char_navigation && self.list_keymap.move_down.is_pressed(key) => {
|
||
if self.selected + 1 < self.filtered_rows.len() {
|
||
self.selected += 1;
|
||
self.ensure_selected_visible();
|
||
}
|
||
self.maybe_load_more_for_scroll();
|
||
self.request_frame();
|
||
}
|
||
_ if allow_plain_char_navigation && self.list_keymap.page_up.is_pressed(key) => {
|
||
let step = self.view_rows.unwrap_or(10).max(1);
|
||
if self.selected > 0 {
|
||
self.selected = self.selected.saturating_sub(step);
|
||
self.ensure_selected_visible();
|
||
self.request_frame();
|
||
}
|
||
}
|
||
_ if allow_plain_char_navigation && self.list_keymap.jump_top.is_pressed(key)
|
||
&& !self.filtered_rows.is_empty() => {
|
||
self.selected = 0;
|
||
self.ensure_selected_visible();
|
||
self.request_frame();
|
||
}
|
||
_ if allow_plain_char_navigation && self.list_keymap.jump_bottom.is_pressed(key)
|
||
&& !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();
|
||
}
|
||
_ if allow_plain_char_navigation && self.list_keymap.page_down.is_pressed(key)
|
||
&& !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.focus_next_toolbar_control();
|
||
self.request_frame();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::BackTab,
|
||
..
|
||
} => {
|
||
self.focus_previous_toolbar_control();
|
||
self.request_frame();
|
||
}
|
||
_ if allow_plain_char_navigation
|
||
&& (self.list_keymap.move_left.is_pressed(key)
|
||
|| self.list_keymap.move_right.is_pressed(key)) =>
|
||
{
|
||
self.change_focused_toolbar_value();
|
||
self.request_frame();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Backspace,
|
||
..
|
||
} => {
|
||
let mut new_query = self.query.clone();
|
||
new_query.pop();
|
||
self.set_query(new_query);
|
||
}
|
||
_ if self.archive_shortcut_available()
|
||
&& crate::key_hint::ctrl(KeyCode::Char('a')).is_press(key) =>
|
||
{
|
||
self.request_archive_for_selected_session();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char(c),
|
||
modifiers,
|
||
..
|
||
}
|
||
// basic text input for search
|
||
if !modifiers.contains(KeyModifiers::CONTROL)
|
||
&& !modifiers.contains(KeyModifiers::ALT)
|
||
=> {
|
||
let mut new_query = self.query.clone();
|
||
new_query.push(c);
|
||
self.set_query(new_query);
|
||
}
|
||
_ => {}
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
fn handle_paste(&mut self, pasted: String) {
|
||
if self.is_transcript_loading() {
|
||
return;
|
||
}
|
||
let Some(pasted) = normalize_pasted_search_query(&pasted) else {
|
||
return;
|
||
};
|
||
let mut new_query = self.query.clone();
|
||
if !new_query.is_empty() && !new_query.ends_with(char::is_whitespace) {
|
||
new_query.push(' ');
|
||
}
|
||
new_query.push_str(&pasted);
|
||
self.set_query(new_query);
|
||
}
|
||
|
||
fn start_initial_load(&mut self) {
|
||
self.relative_time_reference = Some(Utc::now());
|
||
self.reset_pagination();
|
||
self.all_rows.clear();
|
||
self.filtered_rows.clear();
|
||
self.thread_history_modes.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;
|
||
None
|
||
} else {
|
||
let token = self.allocate_search_token();
|
||
self.search_state = SearchState::Active { token };
|
||
Some(token)
|
||
};
|
||
|
||
let request_token = self.allocate_request_token();
|
||
let mode = self.initial_page_mode;
|
||
self.pagination
|
||
.start_load(request_token, search_token, mode);
|
||
self.request_frame();
|
||
|
||
(self.picker_loader)(PickerLoadRequest::Page(PageLoadRequest {
|
||
cursor: None,
|
||
request_token,
|
||
search_token,
|
||
mode,
|
||
cwd_filter: self.active_cwd_filter(),
|
||
status: self.status,
|
||
provider_filter: self.provider_filter.clone(),
|
||
sort_key: self.sort_key,
|
||
}));
|
||
}
|
||
|
||
async fn handle_background_event(
|
||
&mut self,
|
||
event: BackgroundEvent,
|
||
) -> Result<Option<SessionSelection>> {
|
||
match event {
|
||
BackgroundEvent::Page {
|
||
request_token,
|
||
search_token,
|
||
page,
|
||
} => {
|
||
let Some(pending) = self.pagination.finish_load(request_token) else {
|
||
return Ok(None);
|
||
};
|
||
let page_has_rows = matches!(&page, Ok(page) if !page.rows.is_empty());
|
||
// Fall back only when the initial DB listing is unusable. Once SQLite returns
|
||
// rows, its pagination is authoritative and an empty later page ends the list.
|
||
let should_restart_from_store = pending.mode == PageLoadMode::StateDbOnly
|
||
&& self.all_rows.is_empty()
|
||
&& !page_has_rows;
|
||
if should_restart_from_store {
|
||
let request_token = self.allocate_request_token();
|
||
let search_token = pending.search_token.or(search_token);
|
||
self.pagination.reset();
|
||
self.pagination.start_load(
|
||
request_token,
|
||
search_token,
|
||
PageLoadMode::StoreDefault,
|
||
);
|
||
(self.picker_loader)(PickerLoadRequest::Page(PageLoadRequest {
|
||
cursor: None,
|
||
request_token,
|
||
search_token,
|
||
mode: PageLoadMode::StoreDefault,
|
||
cwd_filter: self.active_cwd_filter(),
|
||
status: self.status,
|
||
provider_filter: self.provider_filter.clone(),
|
||
sort_key: self.sort_key,
|
||
}));
|
||
return Ok(None);
|
||
}
|
||
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.pending_transcript_cancellation = None;
|
||
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_cancellation = None;
|
||
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();
|
||
}
|
||
},
|
||
BackgroundEvent::Archive { thread_id, result } => {
|
||
self.handle_archive_result(thread_id, result);
|
||
}
|
||
BackgroundEvent::Unarchive { thread_id, result } => {
|
||
return Ok(self.handle_unarchive_result(thread_id, result));
|
||
}
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
fn reset_pagination(&mut self) {
|
||
self.pagination.reset();
|
||
self.frozen_footer_percent = None;
|
||
}
|
||
|
||
fn ingest_page(&mut self, page: PickerPage) {
|
||
let PickerPage {
|
||
rows,
|
||
history_modes,
|
||
next_cursor,
|
||
num_scanned_files,
|
||
reached_scan_cap,
|
||
} = page;
|
||
self.pagination
|
||
.complete_page(next_cursor, num_scanned_files, reached_scan_cap);
|
||
self.thread_history_modes.extend(history_modes);
|
||
|
||
for row in rows {
|
||
if let Some(seen_key) = row.seen_key() {
|
||
if self.seen_rows.insert(seen_key) {
|
||
self.all_rows.push(row);
|
||
}
|
||
} else {
|
||
self.all_rows.push(row);
|
||
}
|
||
}
|
||
|
||
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
|
||
.iter()
|
||
.filter(|row| self.row_matches_filter(row));
|
||
if self.query.is_empty() {
|
||
self.filtered_rows = base_iter.cloned().collect();
|
||
} else {
|
||
let q = self.query.to_lowercase();
|
||
self.filtered_rows = base_iter.filter(|r| r.matches_query(&q)).cloned().collect();
|
||
}
|
||
if self.selected >= self.filtered_rows.len() {
|
||
self.selected = self.filtered_rows.len().saturating_sub(1);
|
||
}
|
||
if self.filtered_rows.is_empty() {
|
||
self.scroll_top = 0;
|
||
}
|
||
self.ensure_selected_visible();
|
||
self.request_frame();
|
||
}
|
||
|
||
fn row_matches_filter(&self, row: &Row) -> bool {
|
||
if self.filter_mode == SessionFilterMode::All {
|
||
return true;
|
||
}
|
||
let Some(filter_cwd) = self.local_filter_cwd.as_ref() else {
|
||
return true;
|
||
};
|
||
let Some(row_cwd) = row.cwd.as_ref() else {
|
||
return false;
|
||
};
|
||
paths_match(row_cwd, filter_cwd)
|
||
}
|
||
|
||
fn set_query(&mut self, new_query: String) {
|
||
if self.query == new_query {
|
||
return;
|
||
}
|
||
self.query = new_query;
|
||
self.selected = 0;
|
||
self.apply_filter();
|
||
if self.query.is_empty() {
|
||
self.search_state = SearchState::Idle;
|
||
return;
|
||
}
|
||
if !self.filtered_rows.is_empty() {
|
||
self.search_state = SearchState::Idle;
|
||
return;
|
||
}
|
||
if self.pagination.reached_scan_cap || self.pagination.next_cursor.is_none() {
|
||
self.search_state = SearchState::Idle;
|
||
return;
|
||
}
|
||
let token = self.allocate_search_token();
|
||
self.search_state = SearchState::Active { token };
|
||
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;
|
||
};
|
||
if !self.filtered_rows.is_empty() {
|
||
self.search_state = SearchState::Idle;
|
||
return;
|
||
}
|
||
if self.pagination.reached_scan_cap || self.pagination.next_cursor.is_none() {
|
||
self.search_state = SearchState::Idle;
|
||
return;
|
||
}
|
||
self.load_more_if_needed(LoadTrigger::Search { token });
|
||
}
|
||
|
||
fn continue_search_if_token_matches(&mut self, completed_token: Option<usize>) {
|
||
let Some(active) = self.search_state.active_token() else {
|
||
return;
|
||
};
|
||
if let Some(token) = completed_token
|
||
&& token != active
|
||
{
|
||
return;
|
||
}
|
||
self.continue_search_if_needed();
|
||
}
|
||
|
||
fn ensure_selected_visible(&mut self) {
|
||
if self.filtered_rows.is_empty() {
|
||
self.scroll_top = 0;
|
||
return;
|
||
}
|
||
let viewport_rows = self.view_rows.unwrap_or(usize::MAX).max(1);
|
||
if self.selected < self.scroll_top {
|
||
self.scroll_top = self.selected;
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
|
||
fn ensure_minimum_rows_for_view(&mut self, minimum_rows: usize) {
|
||
if minimum_rows == 0 {
|
||
return;
|
||
}
|
||
if self.pagination.is_loading() || self.pagination.next_cursor.is_none() {
|
||
return;
|
||
}
|
||
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() {
|
||
self.load_more_if_needed(LoadTrigger::Search { token });
|
||
} else {
|
||
self.load_more_if_needed(LoadTrigger::Scroll);
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
fn maybe_load_more_for_scroll(&mut self) {
|
||
if self.pagination.is_loading() || self.pagination.next_cursor.is_none() {
|
||
return;
|
||
}
|
||
if self.filtered_rows.is_empty() {
|
||
return;
|
||
}
|
||
let remaining = self.filtered_rows.len().saturating_sub(self.selected + 1);
|
||
if remaining <= LOAD_NEAR_THRESHOLD {
|
||
self.load_more_if_needed(LoadTrigger::Scroll);
|
||
}
|
||
}
|
||
|
||
fn load_more_if_needed(&mut self, trigger: LoadTrigger) {
|
||
let Some((cursor, mode)) = self.pagination.next_page() else {
|
||
return;
|
||
};
|
||
self.freeze_footer_percent();
|
||
let request_token = self.allocate_request_token();
|
||
let search_token = match trigger {
|
||
LoadTrigger::Scroll => None,
|
||
LoadTrigger::Search { token } => Some(token),
|
||
};
|
||
self.pagination
|
||
.start_load(request_token, search_token, mode);
|
||
self.request_frame();
|
||
|
||
(self.picker_loader)(PickerLoadRequest::Page(PageLoadRequest {
|
||
cursor: Some(cursor),
|
||
request_token,
|
||
search_token,
|
||
mode,
|
||
cwd_filter: self.active_cwd_filter(),
|
||
status: self.status,
|
||
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 {
|
||
let token = self.next_request_token;
|
||
self.next_request_token = self.next_request_token.wrapping_add(1);
|
||
token
|
||
}
|
||
|
||
fn allocate_search_token(&mut self) -> usize {
|
||
let token = self.next_search_token;
|
||
self.next_search_token = self.next_search_token.wrapping_add(1);
|
||
token
|
||
}
|
||
|
||
/// Cycles the sort order between creation time and last-updated time.
|
||
///
|
||
/// Triggers a full reload because the backend must re-sort all sessions.
|
||
/// The existing `all_rows` are cleared and pagination restarts from the
|
||
/// beginning with the new sort key.
|
||
fn toggle_sort_key(&mut self) {
|
||
self.sort_key = match self.sort_key {
|
||
ThreadSortKey::CreatedAt => ThreadSortKey::UpdatedAt,
|
||
ThreadSortKey::UpdatedAt
|
||
| ThreadSortKey::RecencyAt
|
||
| ThreadSortKey::SectionPosition => ThreadSortKey::CreatedAt,
|
||
};
|
||
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 toggle_status(&mut self) {
|
||
self.status = match self.status {
|
||
SessionStatus::Active => SessionStatus::Archived,
|
||
SessionStatus::Archived => SessionStatus::Active,
|
||
};
|
||
self.start_initial_load();
|
||
}
|
||
|
||
fn active_cwd_filter(&self) -> Option<PathBuf> {
|
||
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(self.action);
|
||
}
|
||
|
||
fn focus_next_toolbar_control(&mut self) {
|
||
self.toolbar_focus = self.toolbar_focus.next(self.action);
|
||
}
|
||
|
||
fn change_focused_toolbar_value(&mut self) {
|
||
match self.toolbar_focus {
|
||
ToolbarControl::Sort => self.toggle_sort_key(),
|
||
ToolbarControl::Filter => self.toggle_filter_mode(),
|
||
ToolbarControl::Status => self.toggle_status(),
|
||
}
|
||
}
|
||
|
||
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)
|
||
.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::<usize>()
|
||
+ 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<Row> {
|
||
let thread_id = match ThreadId::from_string(&thread.id) {
|
||
Ok(thread_id) => thread_id,
|
||
Err(err) => {
|
||
warn!(thread_id = thread.id, %err, "Skipping app-server picker row with invalid id");
|
||
return None;
|
||
}
|
||
};
|
||
let preview = thread.preview.trim();
|
||
Some(Row {
|
||
path: thread.path,
|
||
preview: if preview.is_empty() {
|
||
String::from("(no message yet)")
|
||
} else {
|
||
preview.to_string()
|
||
},
|
||
thread_id: Some(thread_id),
|
||
thread_name: thread.name,
|
||
created_at: chrono::DateTime::from_timestamp(thread.created_at, 0)
|
||
.map(|dt| dt.with_timezone(&Utc)),
|
||
updated_at: chrono::DateTime::from_timestamp(thread.updated_at, 0)
|
||
.map(|dt| dt.with_timezone(&Utc)),
|
||
cwd: Some(thread.cwd.to_path_buf()),
|
||
git_branch: thread.git_info.and_then(|git_info| git_info.branch),
|
||
})
|
||
}
|
||
|
||
fn thread_list_params(
|
||
cursor: Option<String>,
|
||
cwd_filter: Option<&Path>,
|
||
status: SessionStatus,
|
||
provider_filter: ProviderFilter,
|
||
sort_key: ThreadSortKey,
|
||
include_non_interactive: bool,
|
||
use_state_db_only: bool,
|
||
) -> ThreadListParams {
|
||
ThreadListParams {
|
||
cursor,
|
||
limit: Some(PAGE_SIZE as u32),
|
||
sort_key: Some(sort_key),
|
||
sort_direction: None,
|
||
model_providers: match provider_filter {
|
||
ProviderFilter::Any => None,
|
||
ProviderFilter::MatchDefault(default_provider) => Some(vec![default_provider]),
|
||
},
|
||
source_kinds: Some(crate::resume_source_kinds(include_non_interactive)),
|
||
archived: Some(status == SessionStatus::Archived),
|
||
section_id: None,
|
||
project_id: None,
|
||
parent_thread_id: None,
|
||
ancestor_thread_id: None,
|
||
cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().into_owned())),
|
||
use_state_db_only,
|
||
search_term: None,
|
||
}
|
||
}
|
||
|
||
fn paths_match(a: &Path, b: &Path) -> bool {
|
||
path_utils::paths_match_after_normalization(a, b)
|
||
}
|
||
|
||
#[cfg_attr(not(test), allow(dead_code))]
|
||
fn parse_timestamp_str(ts: &str) -> Option<DateTime<Utc>> {
|
||
chrono::DateTime::parse_from_rfc3339(ts)
|
||
.map(|dt| dt.with_timezone(&Utc))
|
||
.ok()
|
||
}
|
||
|
||
fn draw_picker(tui: &mut Tui, state: &PickerState, screen_size: Size) -> std::io::Result<()> {
|
||
// Render full-screen overlay
|
||
tui.draw(screen_size.height, |frame| {
|
||
let area = frame.area();
|
||
let [header, _header_gap, search, _search_gap, list, footer] = Layout::vertical([
|
||
Constraint::Length(1),
|
||
Constraint::Length(1),
|
||
Constraint::Length(1),
|
||
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_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
|
||
let search = chrome(search);
|
||
frame.render_widget_ref(&search_line(state, search.width), search);
|
||
|
||
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);
|
||
}
|
||
|
||
render_picker_footer(frame, footer, state, list.height);
|
||
})
|
||
}
|
||
|
||
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());
|
||
}
|
||
let search = if state.query.is_empty() {
|
||
"Type to search".dim()
|
||
} else {
|
||
format!("Search: {}", state.query).into()
|
||
};
|
||
let search_width = UnicodeWidthStr::width(search.content.as_ref());
|
||
let mut toolbar = toolbar_line(state, /*compact*/ false);
|
||
if search_width.saturating_add(toolbar.width()) > usize::from(width.saturating_sub(2)) {
|
||
toolbar = toolbar_line(state, /*compact*/ true);
|
||
}
|
||
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 toolbar_line(state: &PickerState, compact: bool) -> Line<'static> {
|
||
let mut spans = Vec::new();
|
||
let separator = if compact && matches!(state.action, SessionPickerAction::Resume) {
|
||
" "
|
||
} else {
|
||
" "
|
||
};
|
||
spans.extend(filter_control_spans(state, compact));
|
||
spans.push(separator.dim());
|
||
if matches!(state.action, SessionPickerAction::Resume) {
|
||
let status_focused = state.toolbar_focus == ToolbarControl::Status;
|
||
if compact {
|
||
let active_status = match state.status {
|
||
SessionStatus::Active => "Active",
|
||
SessionStatus::Archived => "Archived",
|
||
};
|
||
spans.push(toolbar_value(
|
||
active_status,
|
||
/*active*/ true,
|
||
status_focused,
|
||
));
|
||
} else {
|
||
spans.push("Status: ".dim());
|
||
spans.push(toolbar_value(
|
||
"Active",
|
||
state.status == SessionStatus::Active,
|
||
status_focused,
|
||
));
|
||
spans.push(toolbar_value(
|
||
"Archived",
|
||
state.status == SessionStatus::Archived,
|
||
status_focused,
|
||
));
|
||
}
|
||
spans.push(separator.dim());
|
||
}
|
||
spans.extend(sort_control_spans(state, compact));
|
||
spans.into()
|
||
}
|
||
|
||
fn sort_control_spans(state: &PickerState, compact: bool) -> Vec<Span<'static>> {
|
||
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<Span<'static>> {
|
||
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: String,
|
||
wide_label: String,
|
||
compact_label: String,
|
||
priority: u8,
|
||
}
|
||
|
||
fn render_picker_footer(
|
||
frame: &mut crate::custom_terminal::Frame,
|
||
area: Rect,
|
||
state: &PickerState,
|
||
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.is_loading() {
|
||
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.is_loading() {
|
||
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<Line<'static>> {
|
||
if state.is_transcript_loading() {
|
||
let hints = [
|
||
PickerFooterHint {
|
||
key: "loading".to_string(),
|
||
wide_label: String::from("transcript"),
|
||
compact_label: String::from("transcript"),
|
||
priority: 0,
|
||
},
|
||
PickerFooterHint {
|
||
key: "ctrl+c".to_string(),
|
||
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 = if state.status == SessionStatus::Archived {
|
||
"restore"
|
||
} else {
|
||
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 mut first_row_hints = Vec::new();
|
||
if let Some(accept) = state.list_keymap.primary_hint(ListAction::Accept) {
|
||
first_row_hints.push(PickerFooterHint {
|
||
key: accept.display_label(),
|
||
wide_label: action_label.to_string(),
|
||
compact_label: action_label.to_string(),
|
||
priority: 0,
|
||
});
|
||
}
|
||
if !state.filtered_rows.is_empty() && state.archive_shortcut_available() {
|
||
first_row_hints.push(PickerFooterHint {
|
||
key: "ctrl+a".to_string(),
|
||
wide_label: String::from("archive"),
|
||
compact_label: String::from("archive"),
|
||
priority: 2,
|
||
});
|
||
}
|
||
if let Some(cancel) = state.list_keymap.primary_hint(ListAction::Cancel) {
|
||
first_row_hints.push(PickerFooterHint {
|
||
key: cancel.display_label(),
|
||
wide_label: esc_label.to_string(),
|
||
compact_label: esc_compact_label.to_string(),
|
||
priority: 1,
|
||
});
|
||
}
|
||
first_row_hints.extend([
|
||
PickerFooterHint {
|
||
key: "ctrl+c".to_string(),
|
||
wide_label: ctrl_c_label.to_string(),
|
||
compact_label: ctrl_c_label.to_string(),
|
||
priority: 2,
|
||
},
|
||
PickerFooterHint {
|
||
key: "tab".to_string(),
|
||
wide_label: String::from("focus sort/filter"),
|
||
compact_label: String::from("focus"),
|
||
priority: 7,
|
||
},
|
||
]);
|
||
let option_keys = [ListAction::MoveLeft, ListAction::MoveRight]
|
||
.into_iter()
|
||
.filter_map(|action| state.list_keymap.primary_hint(action))
|
||
.map(super::key_hint::ShortcutHint::display_label)
|
||
.collect::<Vec<_>>()
|
||
.join("/");
|
||
if !option_keys.is_empty() {
|
||
first_row_hints.push(PickerFooterHint {
|
||
key: option_keys,
|
||
wide_label: String::from("change option"),
|
||
compact_label: String::from("option"),
|
||
priority: 8,
|
||
});
|
||
}
|
||
let mut second_row_hints = vec![
|
||
PickerFooterHint {
|
||
key: "ctrl+o".to_string(),
|
||
wide_label: density_label.to_string(),
|
||
compact_label: density_compact_label.to_string(),
|
||
priority: 3,
|
||
},
|
||
PickerFooterHint {
|
||
key: "ctrl+t".to_string(),
|
||
wide_label: String::from("transcript"),
|
||
compact_label: String::from("preview"),
|
||
priority: 4,
|
||
},
|
||
PickerFooterHint {
|
||
key: "ctrl+e".to_string(),
|
||
wide_label: String::from("expand"),
|
||
compact_label: String::from("exp"),
|
||
priority: 6,
|
||
},
|
||
];
|
||
let browse_keys = [ListAction::MoveUp, ListAction::MoveDown]
|
||
.into_iter()
|
||
.filter_map(|action| state.list_keymap.primary_hint(action))
|
||
.map(super::key_hint::ShortcutHint::display_label)
|
||
.collect::<Vec<_>>()
|
||
.join("/");
|
||
if !browse_keys.is_empty() {
|
||
second_row_hints.push(PickerFooterHint {
|
||
key: browse_keys,
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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<Line<'static>> {
|
||
let hint_refs = hints.iter().collect::<Vec<_>>();
|
||
fit_footer_hint_refs(&hint_refs, mode, width)
|
||
}
|
||
|
||
fn fit_footer_hint_refs(
|
||
hints: &[&PickerFooterHint],
|
||
mode: FooterHintLabelMode,
|
||
width: u16,
|
||
) -> Option<Line<'static>> {
|
||
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.clone().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.as_str()) + label_width;
|
||
if idx == 0 {
|
||
hint_width
|
||
} else {
|
||
hint_width + gap_width
|
||
}
|
||
})
|
||
.sum::<usize>()
|
||
}
|
||
|
||
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() {
|
||
let message = render_empty_state_line(state);
|
||
frame.render_widget_ref(&message, area);
|
||
return;
|
||
}
|
||
|
||
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),
|
||
);
|
||
}
|
||
|
||
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.is_loading() && 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.is_loading() {
|
||
"↓ 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<Line<'static>> {
|
||
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<Line<'static>> {
|
||
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<Line<'static>>,
|
||
style: Style,
|
||
width: u16,
|
||
) -> Vec<Line<'static>> {
|
||
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<Line<'static>> {
|
||
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 | ThreadSortKey::RecencyAt | ThreadSortKey::SectionPosition => {
|
||
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<Line<'static>> {
|
||
let date = match sort_key {
|
||
ThreadSortKey::CreatedAt => created,
|
||
ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt | ThreadSortKey::SectionPosition => {
|
||
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<String>),
|
||
Cwd(Option<String>),
|
||
}
|
||
|
||
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<FooterPart>, width: u16) -> Vec<Line<'static>> {
|
||
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<FooterPart>, width: usize, cwd_width: usize) -> Line<'static> {
|
||
let mut spans: Vec<Span<'static>> = 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<Span<'static>>,
|
||
part: FooterPart,
|
||
target_width: Option<usize>,
|
||
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<Line<'static>> {
|
||
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<Line<'static>> {
|
||
let reference = state.relative_time_reference.unwrap_or_else(Utc::now);
|
||
let session = match (row.thread_name.as_deref(), row.thread_id) {
|
||
(Some(thread_name), Some(thread_id)) => format!("{thread_name} ({thread_id})"),
|
||
(Some(thread_name), None) => thread_name.to_string(),
|
||
(None, Some(thread_id)) => thread_id.to_string(),
|
||
(None, None) => "-".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<Line<'static>> {
|
||
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<Line<'static>> {
|
||
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:<LABEL_WIDTH$}").dim(),
|
||
" ".dim(),
|
||
truncate_text(value, value_width).into(),
|
||
]
|
||
.into()
|
||
}
|
||
|
||
fn expanded_time_detail_line(
|
||
label: &'static str,
|
||
reference: DateTime<Utc>,
|
||
ts: Option<DateTime<Utc>>,
|
||
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<Utc>, ts: Option<DateTime<Utc>>) -> 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<Utc>, ts: DateTime<Utc>) -> 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<Utc>) -> String {
|
||
ts.format("%Y-%m-%d %H:%M:%S").to_string()
|
||
}
|
||
|
||
fn render_empty_state_line(state: &PickerState) -> Line<'static> {
|
||
if !state.query.is_empty() {
|
||
if state.search_state.is_active()
|
||
|| (state.pagination.is_loading() && state.pagination.next_cursor.is_some())
|
||
{
|
||
return vec!["Searching…".italic().dim()].into();
|
||
}
|
||
if state.pagination.reached_scan_cap {
|
||
let msg = format!(
|
||
"Search scanned first {} sessions; more may exist",
|
||
state.pagination.num_scanned_files
|
||
);
|
||
return vec![Span::from(msg).italic().dim()].into();
|
||
}
|
||
return vec!["No results for your search".italic().dim()].into();
|
||
}
|
||
|
||
if state.pagination.is_loading() {
|
||
if state.all_rows.is_empty() && state.pagination.num_scanned_files == 0 {
|
||
return vec!["Loading sessions…".italic().dim()].into();
|
||
}
|
||
return vec!["Loading older sessions…".italic().dim()].into();
|
||
}
|
||
|
||
vec!["No sessions yet".italic().dim()].into()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use chrono::Duration;
|
||
use codex_app_server_protocol::ThreadItem;
|
||
use codex_app_server_protocol::ThreadSourceKind;
|
||
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;
|
||
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
use insta::assert_snapshot;
|
||
use pretty_assertions::assert_eq;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
use std::sync::Mutex;
|
||
use tempfile::tempdir;
|
||
|
||
fn page(
|
||
rows: Vec<Row>,
|
||
next_cursor: Option<&str>,
|
||
num_scanned_files: usize,
|
||
reached_scan_cap: bool,
|
||
) -> PickerPage {
|
||
PickerPage {
|
||
rows,
|
||
history_modes: HashMap::new(),
|
||
next_cursor: next_cursor.map(|cursor| PageCursor::AppServer(cursor.to_string())),
|
||
num_scanned_files,
|
||
reached_scan_cap,
|
||
}
|
||
}
|
||
|
||
fn page_only_loader(loader: impl Fn(PageLoadRequest) + Send + Sync + 'static) -> PickerLoader {
|
||
Arc::new(move |request| {
|
||
if let PickerLoadRequest::Page(request) = request {
|
||
loader(request);
|
||
}
|
||
})
|
||
}
|
||
|
||
async fn deliver_page(
|
||
state: &mut PickerState,
|
||
request: &PageLoadRequest,
|
||
page: std::io::Result<PickerPage>,
|
||
) {
|
||
state
|
||
.handle_background_event(BackgroundEvent::Page {
|
||
request_token: request.request_token,
|
||
search_token: request.search_token,
|
||
page,
|
||
})
|
||
.await
|
||
.expect("page event");
|
||
}
|
||
|
||
fn ok_page(rows: Vec<Row>, next_cursor: Option<&str>) -> std::io::Result<PickerPage> {
|
||
let n = rows.len();
|
||
Ok(page(rows, next_cursor, n, /*reached_scan_cap*/ false))
|
||
}
|
||
|
||
fn make_row(path: &str, ts: &str, preview: &str) -> Row {
|
||
let timestamp = parse_timestamp_str(ts).expect("timestamp should parse");
|
||
Row {
|
||
path: Some(PathBuf::from(path)),
|
||
preview: preview.to_string(),
|
||
thread_id: None,
|
||
thread_name: None,
|
||
created_at: Some(timestamp),
|
||
updated_at: Some(timestamp),
|
||
cwd: None,
|
||
git_branch: None,
|
||
}
|
||
}
|
||
|
||
fn local_db_first_state() -> (PickerState, Arc<Mutex<Vec<PageLoadRequest>>>) {
|
||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||
let request_sink = Arc::clone(&requests);
|
||
let loader = page_only_loader(move |request| {
|
||
request_sink.lock().unwrap().push(request);
|
||
});
|
||
let mut state = PickerState::new(
|
||
FrameRequester::test_dummy(),
|
||
loader,
|
||
ProviderFilter::MatchDefault(String::from("openai")),
|
||
/*show_all*/ true,
|
||
/*filter_cwd*/ None,
|
||
SessionPickerAction::Resume,
|
||
);
|
||
state.initial_page_mode = PageLoadMode::StateDbOnly;
|
||
(state, requests)
|
||
}
|
||
|
||
fn footer_lines_text(state: &PickerState, width: u16) -> String {
|
||
footer_hint_lines(state, width)
|
||
.into_iter()
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.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::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
#[test]
|
||
fn row_display_preview_prefers_thread_name() {
|
||
let row = Row {
|
||
path: Some(PathBuf::from("/tmp/a.jsonl")),
|
||
preview: String::from("first message"),
|
||
thread_id: None,
|
||
thread_name: Some(String::from("My session")),
|
||
created_at: None,
|
||
updated_at: None,
|
||
cwd: None,
|
||
git_branch: None,
|
||
};
|
||
|
||
assert_eq!(row.display_preview(), "My session");
|
||
}
|
||
|
||
#[test]
|
||
fn local_picker_thread_list_params_include_cwd_filter() {
|
||
let cwd_filter = picker_cwd_filter(
|
||
Path::new("/tmp/project"),
|
||
/*show_all*/ false,
|
||
/*uses_remote_workspace*/ false,
|
||
/*remote_cwd_override*/ None,
|
||
);
|
||
let params = thread_list_params(
|
||
Some(String::from("cursor-1")),
|
||
cwd_filter.as_deref(),
|
||
SessionStatus::Active,
|
||
ProviderFilter::MatchDefault(String::from("openai")),
|
||
ThreadSortKey::UpdatedAt,
|
||
/*include_non_interactive*/ false,
|
||
/*use_state_db_only*/ true,
|
||
);
|
||
|
||
assert_eq!(
|
||
params.cwd,
|
||
Some(ThreadListCwdFilter::One(String::from("/tmp/project")))
|
||
);
|
||
assert!(params.use_state_db_only);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn local_db_first_treats_empty_later_page_as_end_of_db_listing() {
|
||
let (mut state, requests) = local_db_first_state();
|
||
state.start_initial_load();
|
||
let db_request = requests.lock().unwrap()[0].clone();
|
||
deliver_page(
|
||
&mut state,
|
||
&db_request,
|
||
ok_page(
|
||
vec![make_row(
|
||
"/tmp/indexed.jsonl",
|
||
"2025-01-03T00:00:00Z",
|
||
"indexed metadata",
|
||
)],
|
||
Some("db-cursor"),
|
||
),
|
||
)
|
||
.await;
|
||
|
||
state.maybe_load_more_for_scroll();
|
||
let later_db_request = requests.lock().unwrap()[1].clone();
|
||
assert_eq!(later_db_request.mode, PageLoadMode::StateDbOnly);
|
||
deliver_page(
|
||
&mut state,
|
||
&later_db_request,
|
||
ok_page(Vec::new(), /*next_cursor*/ None),
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(requests.lock().unwrap().len(), 2);
|
||
assert_eq!(state.all_rows[0].preview, "indexed metadata");
|
||
assert!(state.pagination.next_cursor.is_none());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn local_db_first_falls_back_when_initial_db_page_is_empty() {
|
||
let (mut state, requests) = local_db_first_state();
|
||
state.start_initial_load();
|
||
let db_request = requests.lock().unwrap()[0].clone();
|
||
deliver_page(
|
||
&mut state,
|
||
&db_request,
|
||
ok_page(Vec::new(), /*next_cursor*/ None),
|
||
)
|
||
.await;
|
||
|
||
let fallback_request = requests.lock().unwrap()[1].clone();
|
||
assert_eq!(fallback_request.mode, PageLoadMode::StoreDefault);
|
||
assert!(fallback_request.cursor.is_none());
|
||
state.relative_time_reference =
|
||
Some(parse_timestamp_str("2025-01-04T00:00:00Z").expect("timestamp"));
|
||
state.update_viewport(/*rows*/ 12, /*width*/ 80);
|
||
let render_state = |state: &PickerState| {
|
||
use crate::custom_terminal::Terminal;
|
||
use crate::test_backend::VT100Backend;
|
||
|
||
let backend = VT100Backend::new(/*width*/ 80, /*height*/ 12);
|
||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||
terminal.set_viewport_area(Rect::new(
|
||
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 12,
|
||
));
|
||
{
|
||
let mut frame = terminal.get_frame();
|
||
let area = frame.area();
|
||
render_list(&mut frame, area, state);
|
||
}
|
||
terminal.flush().expect("flush");
|
||
terminal.backend().to_string()
|
||
};
|
||
let loading_snapshot = render_state(&state);
|
||
|
||
deliver_page(
|
||
&mut state,
|
||
&fallback_request,
|
||
ok_page(
|
||
vec![
|
||
make_row(
|
||
"/tmp/first.jsonl",
|
||
"2025-01-03T00:00:00Z",
|
||
"first store result",
|
||
),
|
||
make_row(
|
||
"/tmp/repaired.jsonl",
|
||
"2025-01-03T00:00:00Z",
|
||
"repaired store result",
|
||
),
|
||
make_row(
|
||
"/tmp/third.jsonl",
|
||
"2025-01-03T00:00:00Z",
|
||
"third store result",
|
||
),
|
||
],
|
||
/*next_cursor*/ None,
|
||
),
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(
|
||
state
|
||
.all_rows
|
||
.iter()
|
||
.map(|row| row.preview.as_str())
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
"first store result",
|
||
"repaired store result",
|
||
"third store result"
|
||
]
|
||
);
|
||
assert_eq!(state.pagination.num_scanned_files, 3);
|
||
let repaired_snapshot = render_state(&state);
|
||
assert_snapshot!(
|
||
"resume_picker_db_fallback_transition",
|
||
format!(
|
||
"---- loading fallback ----\n{loading_snapshot}\n---- repaired results ----\n{repaired_snapshot}"
|
||
)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn local_db_first_accepts_db_thread_id_without_rollout_validation() {
|
||
let (mut state, requests) = local_db_first_state();
|
||
state.start_initial_load();
|
||
let db_request = requests.lock().unwrap()[0].clone();
|
||
let thread_id = ThreadId::new();
|
||
let mut row = make_row(
|
||
"/tmp/missing-rollout.jsonl",
|
||
"2025-01-01T00:00:00Z",
|
||
"indexed metadata",
|
||
);
|
||
row.thread_id = Some(thread_id);
|
||
let mut listed_page = ok_page(vec![row], /*next_cursor*/ None)
|
||
.expect("indexed thread page should be available");
|
||
listed_page
|
||
.history_modes
|
||
.insert(thread_id, ThreadHistoryMode::Legacy);
|
||
deliver_page(&mut state, &db_request, Ok(listed_page)).await;
|
||
|
||
let selection = state
|
||
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||
.await
|
||
.expect("enter should not abort the picker");
|
||
assert!(matches!(
|
||
selection,
|
||
Some(SessionSelection::Resume(SessionTarget {
|
||
thread_id: selected_thread_id,
|
||
history_mode: Some(ThreadHistoryMode::Legacy),
|
||
..
|
||
})) if selected_thread_id == thread_id
|
||
));
|
||
}
|
||
|
||
#[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::<Vec<_>>()
|
||
.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 (019dabc1-0ef5-7431-b81c-03037f51f62c)"
|
||
));
|
||
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(
|
||
Some(String::from("cursor-1")),
|
||
Some(Path::new("repo/on/server")),
|
||
SessionStatus::Active,
|
||
ProviderFilter::Any,
|
||
ThreadSortKey::UpdatedAt,
|
||
/*include_non_interactive*/ false,
|
||
/*use_state_db_only*/ false,
|
||
);
|
||
|
||
assert_eq!(params.cursor, Some(String::from("cursor-1")));
|
||
assert_eq!(params.model_providers, None);
|
||
assert_eq!(
|
||
params.source_kinds,
|
||
Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode])
|
||
);
|
||
assert_eq!(
|
||
params.cwd,
|
||
Some(ThreadListCwdFilter::One(String::from("repo/on/server")))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn remote_thread_list_params_can_include_non_interactive_sources() {
|
||
let params = thread_list_params(
|
||
Some(String::from("cursor-1")),
|
||
/*cwd_filter*/ None,
|
||
SessionStatus::Active,
|
||
ProviderFilter::Any,
|
||
ThreadSortKey::UpdatedAt,
|
||
/*include_non_interactive*/ true,
|
||
/*use_state_db_only*/ false,
|
||
);
|
||
|
||
assert_eq!(params.cursor, Some(String::from("cursor-1")));
|
||
assert_eq!(params.model_providers, None);
|
||
let source_kinds = crate::resume_source_kinds(/*include_non_interactive*/ true);
|
||
assert_eq!(params.source_kinds, Some(source_kinds));
|
||
}
|
||
|
||
#[test]
|
||
fn remote_picker_sends_cwd_filter_without_local_post_filtering() {
|
||
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = 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, /*uses_remote_workspace*/ true);
|
||
|
||
state.start_initial_load();
|
||
|
||
{
|
||
let guard = recorded_requests.lock().unwrap();
|
||
assert_eq!(guard.len(), 1);
|
||
assert_eq!(guard[0].cwd_filter, remote_cwd);
|
||
assert_eq!(guard[0].mode, PageLoadMode::StoreDefault);
|
||
}
|
||
|
||
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 = page_only_loader(|_| {});
|
||
let state = PickerState::new(
|
||
FrameRequester::test_dummy(),
|
||
loader,
|
||
ProviderFilter::Any,
|
||
/*show_all*/ false,
|
||
/*filter_cwd*/ None,
|
||
SessionPickerAction::Resume,
|
||
);
|
||
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/remote-project")),
|
||
git_branch: None,
|
||
};
|
||
|
||
assert!(state.row_matches_filter(&row));
|
||
}
|
||
|
||
#[test]
|
||
fn resume_table_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");
|
||
let rows = vec![
|
||
Row {
|
||
path: Some(PathBuf::from("/tmp/a.jsonl")),
|
||
preview: String::from("Fix resume picker timestamps"),
|
||
thread_id: None,
|
||
thread_name: None,
|
||
created_at: Some(now - Duration::minutes(16)),
|
||
updated_at: Some(now - Duration::seconds(42)),
|
||
cwd: None,
|
||
git_branch: None,
|
||
},
|
||
Row {
|
||
path: Some(PathBuf::from("/tmp/b.jsonl")),
|
||
preview: String::from("Investigate lazy pagination cap"),
|
||
thread_id: None,
|
||
thread_name: None,
|
||
created_at: Some(now - Duration::hours(1)),
|
||
updated_at: Some(now - Duration::minutes(35)),
|
||
cwd: None,
|
||
git_branch: None,
|
||
},
|
||
Row {
|
||
path: Some(PathBuf::from("/tmp/c.jsonl")),
|
||
preview: String::from("Explain the codebase"),
|
||
thread_id: None,
|
||
thread_name: None,
|
||
created_at: Some(now - Duration::hours(2)),
|
||
updated_at: Some(now - Duration::hours(2)),
|
||
cwd: None,
|
||
git_branch: None,
|
||
},
|
||
];
|
||
state.all_rows = rows.clone();
|
||
state.filtered_rows = rows;
|
||
state.relative_time_reference = Some(now);
|
||
state.selected = 1;
|
||
state.scroll_top = 0;
|
||
state.update_viewport(/*rows*/ 12, /*width*/ 80);
|
||
|
||
let width: u16 = 80;
|
||
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));
|
||
|
||
{
|
||
let mut frame = terminal.get_frame();
|
||
let area = frame.area();
|
||
render_list(&mut frame, area, &state);
|
||
}
|
||
terminal.flush().expect("flush");
|
||
|
||
let snapshot = terminal.backend().to_string();
|
||
assert_snapshot!("resume_picker_table", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn resume_search_error_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,
|
||
);
|
||
state.inline_error = Some(String::from(
|
||
"Failed to read session metadata from /tmp/missing.jsonl",
|
||
));
|
||
|
||
let width: u16 = 80;
|
||
let height: u16 = 1;
|
||
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 line = search_line(&state, frame.area().width);
|
||
frame.render_widget_ref(&line, frame.area());
|
||
}
|
||
terminal.flush().expect("flush");
|
||
|
||
let snapshot = terminal.backend().to_string();
|
||
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 {
|
||
current_thread_id: None,
|
||
};
|
||
|
||
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.list_keymap.move_left = vec![crate::key_hint::ctrl(KeyCode::Char('h'))];
|
||
state.list_keymap.move_right = vec![crate::key_hint::ctrl(KeyCode::Char('l'))];
|
||
let remapped_footer = footer_lines_text(&state, /*width*/ 220);
|
||
assert!(
|
||
remapped_footer.contains("ctrl + h/ctrl + l change option"),
|
||
"{remapped_footer}"
|
||
);
|
||
state.list_keymap.move_left.clear();
|
||
state.list_keymap.move_right.clear();
|
||
assert!(!footer_lines_text(&state, /*width*/ 220).contains("change option"));
|
||
|
||
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::<Vec<_>>()
|
||
.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.start_load(
|
||
/*request_token*/ 1,
|
||
/*search_token*/ None,
|
||
PageLoadMode::StoreDefault,
|
||
);
|
||
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<Mutex<Vec<ThreadId>>> = 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 escape_cancels_transcript_loading_and_restores_picker_navigation() {
|
||
let thread_id = ThreadId::new();
|
||
let cancellation = Arc::new(Mutex::new(None));
|
||
let cancellation_sink = cancellation.clone();
|
||
let loader: PickerLoader = Arc::new(move |request| {
|
||
if let PickerLoadRequest::Transcript { cancellation, .. } = request {
|
||
*cancellation_sink.lock().unwrap() = Some(cancellation);
|
||
}
|
||
});
|
||
let mut state = PickerState::new(
|
||
FrameRequester::test_dummy(),
|
||
loader,
|
||
ProviderFilter::MatchDefault(String::from("openai")),
|
||
/*show_all*/ true,
|
||
/*filter_cwd*/ None,
|
||
SessionPickerAction::Resume,
|
||
);
|
||
let mut first = make_row("/tmp/1.jsonl", "2026-05-02T12:00:00Z", "one");
|
||
first.thread_id = Some(thread_id);
|
||
state.filtered_rows = vec![
|
||
first,
|
||
make_row("/tmp/2.jsonl", "2026-05-02T12:00:00Z", "two"),
|
||
];
|
||
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL))
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(state.pending_transcript_open, Some(thread_id));
|
||
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
||
.await
|
||
.unwrap();
|
||
|
||
assert_eq!(state.pending_transcript_open, None);
|
||
assert!(!state.transcript_cells.contains_key(&thread_id));
|
||
assert!(
|
||
cancellation
|
||
.lock()
|
||
.unwrap()
|
||
.as_mut()
|
||
.expect("transcript cancellation receiver")
|
||
.try_recv()
|
||
.is_ok()
|
||
);
|
||
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(state.selected, 1);
|
||
}
|
||
|
||
#[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::<Vec<_>>()
|
||
.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<Mutex<Vec<ThreadId>>> = 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(),
|
||
});
|
||
|
||
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_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,
|
||
});
|
||
|
||
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<Mutex<Vec<ThreadId>>> = 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("[Active]"));
|
||
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<PathBuf>,
|
||
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::<Vec<_>>()
|
||
.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::<Vec<_>>()
|
||
.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");
|
||
terminal.swap_buffers();
|
||
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 = 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/a.jsonl", "2025-01-03T00:00:00Z", "third"),
|
||
make_row("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "second"),
|
||
],
|
||
Some("2025-01-02T00:00:00Z"),
|
||
/*num_scanned_files*/ 2,
|
||
/*reached_scan_cap*/ false,
|
||
));
|
||
|
||
state.ingest_page(page(
|
||
vec![
|
||
make_row("/tmp/a.jsonl", "2025-01-03T00:00:00Z", "duplicate"),
|
||
make_row("/tmp/c.jsonl", "2025-01-01T00:00:00Z", "first"),
|
||
],
|
||
Some("2025-01-01T00:00:00Z"),
|
||
/*num_scanned_files*/ 2,
|
||
/*reached_scan_cap*/ false,
|
||
));
|
||
|
||
state.ingest_page(page(
|
||
vec![make_row("/tmp/d.jsonl", "2024-12-31T23:00:00Z", "very old")],
|
||
/*next_cursor*/ None,
|
||
/*num_scanned_files*/ 1,
|
||
/*reached_scan_cap*/ false,
|
||
));
|
||
|
||
let previews: Vec<_> = state
|
||
.filtered_rows
|
||
.iter()
|
||
.map(|row| row.preview.as_str())
|
||
.collect();
|
||
assert_eq!(previews, vec!["third", "second", "first", "very old"]);
|
||
|
||
let unique_paths = state
|
||
.filtered_rows
|
||
.iter()
|
||
.map(|row| row.path.clone())
|
||
.collect::<std::collections::HashSet<_>>();
|
||
assert_eq!(unique_paths.len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn ensure_minimum_rows_prefetches_when_underfilled() {
|
||
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = 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.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,
|
||
));
|
||
|
||
assert!(recorded_requests.lock().unwrap().is_empty());
|
||
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 ensure_minimum_rows_does_not_prefetch_when_comfortable_cards_fill_view() {
|
||
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = 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.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);
|
||
|
||
state.ensure_minimum_rows_for_view(/*minimum_rows*/ 6);
|
||
|
||
assert!(recorded_requests.lock().unwrap().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn ensure_minimum_rows_still_prefetches_when_dense_rows_underfill_view() {
|
||
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = 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<Mutex<Vec<PageLoadRequest>>> = 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.start_initial_load();
|
||
{
|
||
let guard = recorded_requests.lock().unwrap();
|
||
assert_eq!(guard.len(), 1);
|
||
assert_eq!(guard[0].sort_key, ThreadSortKey::UpdatedAt);
|
||
}
|
||
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
||
.await
|
||
.unwrap();
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
||
.await
|
||
.unwrap();
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL))
|
||
.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<Mutex<Vec<PageLoadRequest>>> = 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<Mutex<Vec<PageLoadRequest>>> = 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 status_changes_when_directory_filter_is_unavailable() {
|
||
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = 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();
|
||
state
|
||
.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[0].status, SessionStatus::Active);
|
||
assert_eq!(guard[0].cwd_filter, None);
|
||
assert_eq!(guard[1].status, SessionStatus::Archived);
|
||
assert_eq!(guard[1].cwd_filter, None);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn page_navigation_uses_view_rows() {
|
||
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..20 {
|
||
let ts = format!("2025-01-{: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*/ 20,
|
||
/*reached_scan_cap*/ false,
|
||
));
|
||
state.update_viewport(/*rows*/ 5, /*width*/ 80);
|
||
|
||
assert_eq!(state.selected, 0);
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(state.selected, 5);
|
||
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(state.selected, 10);
|
||
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
|
||
.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 page_and_jump_navigation_use_list_keymap() {
|
||
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.list_keymap.page_down = vec![crate::key_hint::ctrl(KeyCode::Char('d'))];
|
||
state.list_keymap.page_up = vec![crate::key_hint::ctrl(KeyCode::Char('u'))];
|
||
state.list_keymap.jump_bottom = vec![crate::key_hint::ctrl(KeyCode::Char('y'))];
|
||
state.list_keymap.jump_top = vec![crate::key_hint::ctrl(KeyCode::Char('a'))];
|
||
|
||
let mut items = Vec::new();
|
||
for idx in 0..20 {
|
||
let ts = format!("2025-01-{: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*/ 20,
|
||
/*reached_scan_cap*/ false,
|
||
));
|
||
state.update_viewport(/*rows*/ 5, /*width*/ 80);
|
||
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(state.selected, 0);
|
||
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(state.selected, 5);
|
||
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL))
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(state.selected, 0);
|
||
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL))
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(state.selected, 19);
|
||
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL))
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(state.selected, 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn ctrl_c_exits_even_when_cancel_is_remapped_to_ctrl_c() {
|
||
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.list_keymap.cancel = vec![crate::key_hint::ctrl(KeyCode::Char('c'))];
|
||
|
||
let selection = state
|
||
.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
|
||
.await
|
||
.unwrap();
|
||
|
||
assert!(matches!(selection, Some(SessionSelection::Exit)));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn end_jumps_to_last_known_row_and_starts_loading_more() {
|
||
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = 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.is_loading());
|
||
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 = 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 row = Row {
|
||
path: Some(PathBuf::from("/tmp/missing.jsonl")),
|
||
preview: String::from("missing metadata"),
|
||
thread_id: None,
|
||
thread_name: None,
|
||
created_at: None,
|
||
updated_at: None,
|
||
cwd: None,
|
||
git_branch: None,
|
||
};
|
||
state.all_rows = vec![row.clone()];
|
||
state.filtered_rows = vec![row];
|
||
|
||
let selection = state
|
||
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||
.await
|
||
.expect("enter should not abort the picker");
|
||
|
||
assert!(selection.is_none());
|
||
assert_eq!(
|
||
state.inline_error,
|
||
Some(String::from(
|
||
"Failed to read session metadata from /tmp/missing.jsonl"
|
||
))
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn enter_on_pathless_thread_uses_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,
|
||
);
|
||
let thread_id = ThreadId::new();
|
||
let row = Row {
|
||
path: None,
|
||
preview: String::from("pathless thread"),
|
||
thread_id: Some(thread_id),
|
||
thread_name: None,
|
||
created_at: None,
|
||
updated_at: None,
|
||
cwd: None,
|
||
git_branch: None,
|
||
};
|
||
state.all_rows = vec![row.clone()];
|
||
state.filtered_rows = vec![row];
|
||
|
||
let selection = state
|
||
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||
.await
|
||
.expect("enter should not abort the picker");
|
||
|
||
match selection {
|
||
Some(SessionSelection::Resume(SessionTarget {
|
||
path: None,
|
||
thread_id: selected_thread_id,
|
||
history_mode: None,
|
||
})) => assert_eq!(selected_thread_id, thread_id),
|
||
other => panic!("unexpected selection: {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn app_server_row_keeps_pathless_threads() {
|
||
let thread_id = ThreadId::new();
|
||
let thread = Thread {
|
||
id: thread_id.to_string(),
|
||
extra: None,
|
||
session_id: thread_id.to_string(),
|
||
forked_from_id: None,
|
||
parent_thread_id: None,
|
||
preview: String::from("remote thread"),
|
||
ephemeral: false,
|
||
section: None,
|
||
section_entered_at: None,
|
||
project_id: None,
|
||
history_mode: Default::default(),
|
||
model_provider: String::from("openai"),
|
||
model: None,
|
||
reasoning_effort: None,
|
||
created_at: 1,
|
||
updated_at: 2,
|
||
recency_at: Some(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,
|
||
can_accept_direct_input: None,
|
||
thread_source: None,
|
||
agent_nickname: None,
|
||
agent_role: None,
|
||
git_info: None,
|
||
name: Some(String::from("Named thread")),
|
||
turns: Vec::new(),
|
||
};
|
||
|
||
let row = row_from_app_server_thread(thread).expect("row should be preserved");
|
||
|
||
assert_eq!(row.path, None);
|
||
assert_eq!(row.thread_id, Some(thread_id));
|
||
assert_eq!(row.thread_name, Some(String::from("Named thread")));
|
||
}
|
||
|
||
#[test]
|
||
fn thread_to_transcript_cells_renders_core_message_types() {
|
||
use crate::thread_transcript::thread_to_transcript_cells;
|
||
|
||
let thread_id = ThreadId::new();
|
||
let thread = Thread {
|
||
id: thread_id.to_string(),
|
||
extra: None,
|
||
session_id: thread_id.to_string(),
|
||
forked_from_id: None,
|
||
parent_thread_id: None,
|
||
preview: String::from("preview"),
|
||
ephemeral: false,
|
||
section: None,
|
||
section_entered_at: None,
|
||
project_id: None,
|
||
history_mode: Default::default(),
|
||
model_provider: String::from("openai"),
|
||
model: None,
|
||
reasoning_effort: None,
|
||
created_at: 1,
|
||
updated_at: 2,
|
||
recency_at: Some(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,
|
||
can_accept_direct_input: None,
|
||
thread_source: None,
|
||
agent_nickname: None,
|
||
agent_role: None,
|
||
git_info: None,
|
||
name: None,
|
||
turns: vec![codex_app_server_protocol::Turn {
|
||
id: String::from("turn-1"),
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
items: vec![
|
||
ThreadItem::UserMessage {
|
||
id: String::from("user-1"),
|
||
client_id: None,
|
||
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,
|
||
delivery: None,
|
||
questions: 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,
|
||
/*config*/ None,
|
||
)
|
||
.into_iter()
|
||
.flat_map(|cell| cell.transcript_lines(/*width*/ 80))
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.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 crate::thread_transcript::thread_to_transcript_cells;
|
||
|
||
let thread_id = ThreadId::new();
|
||
let thread = Thread {
|
||
id: thread_id.to_string(),
|
||
extra: None,
|
||
session_id: thread_id.to_string(),
|
||
forked_from_id: None,
|
||
parent_thread_id: None,
|
||
preview: String::from("preview"),
|
||
ephemeral: false,
|
||
section: None,
|
||
section_entered_at: None,
|
||
project_id: None,
|
||
history_mode: Default::default(),
|
||
model_provider: String::from("openai"),
|
||
model: None,
|
||
reasoning_effort: None,
|
||
created_at: 1,
|
||
updated_at: 2,
|
||
recency_at: Some(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,
|
||
can_accept_direct_input: None,
|
||
thread_source: None,
|
||
agent_nickname: None,
|
||
agent_role: None,
|
||
git_info: None,
|
||
name: None,
|
||
turns: vec![codex_app_server_protocol::Turn {
|
||
id: String::from("turn-1"),
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
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.clone(),
|
||
RawReasoningVisibility::Hidden,
|
||
/*config*/ None,
|
||
)
|
||
.into_iter()
|
||
.flat_map(|cell| cell.transcript_lines(/*width*/ 80))
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let visible = thread_to_transcript_cells(
|
||
thread,
|
||
RawReasoningVisibility::Visible,
|
||
/*config*/ None,
|
||
)
|
||
.into_iter()
|
||
.flat_map(|cell| cell.transcript_lines(/*width*/ 80))
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.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 crate::thread_transcript::thread_to_transcript_cells;
|
||
|
||
let thread_id = ThreadId::new();
|
||
let thread = Thread {
|
||
id: thread_id.to_string(),
|
||
extra: None,
|
||
session_id: thread_id.to_string(),
|
||
forked_from_id: None,
|
||
parent_thread_id: None,
|
||
preview: String::from("preview"),
|
||
ephemeral: false,
|
||
section: None,
|
||
section_entered_at: None,
|
||
project_id: None,
|
||
history_mode: Default::default(),
|
||
model_provider: String::from("openai"),
|
||
model: None,
|
||
reasoning_effort: None,
|
||
created_at: 1,
|
||
updated_at: 2,
|
||
recency_at: Some(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,
|
||
can_accept_direct_input: None,
|
||
thread_source: None,
|
||
agent_nickname: None,
|
||
agent_role: None,
|
||
git_info: None,
|
||
name: None,
|
||
turns: vec![codex_app_server_protocol::Turn {
|
||
id: String::from("turn-1"),
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
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,
|
||
/*config*/ None,
|
||
)
|
||
.into_iter()
|
||
.flat_map(|cell| cell.transcript_lines(/*width*/ 80))
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
|
||
assert!(rendered.contains("raw reasoning content"));
|
||
assert!(!rendered.contains("public summary"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
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,
|
||
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 = 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(1));
|
||
|
||
state
|
||
.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))
|
||
.await
|
||
.unwrap();
|
||
|
||
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<Mutex<Vec<PageLoadRequest>>> = 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.reset_pagination();
|
||
state.ingest_page(page(
|
||
vec![make_row(
|
||
"/tmp/start.jsonl",
|
||
"2025-01-01T00:00:00Z",
|
||
"alpha",
|
||
)],
|
||
Some("2025-01-02T00:00:00Z"),
|
||
/*num_scanned_files*/ 1,
|
||
/*reached_scan_cap*/ false,
|
||
));
|
||
recorded_requests.lock().unwrap().clear();
|
||
|
||
state.set_query("target".to_string());
|
||
let first_request = {
|
||
let guard = recorded_requests.lock().unwrap();
|
||
assert_eq!(guard.len(), 1);
|
||
guard[0].clone()
|
||
};
|
||
|
||
state
|
||
.handle_background_event(BackgroundEvent::Page {
|
||
request_token: first_request.request_token,
|
||
search_token: first_request.search_token,
|
||
page: Ok(page(
|
||
vec![make_row("/tmp/beta.jsonl", "2025-01-02T00:00:00Z", "beta")],
|
||
Some("2025-01-03T00:00:00Z"),
|
||
/*num_scanned_files*/ 5,
|
||
/*reached_scan_cap*/ false,
|
||
)),
|
||
})
|
||
.await
|
||
.unwrap();
|
||
|
||
let second_request = {
|
||
let guard = recorded_requests.lock().unwrap();
|
||
assert_eq!(guard.len(), 2);
|
||
guard[1].clone()
|
||
};
|
||
assert!(state.search_state.is_active());
|
||
assert!(state.filtered_rows.is_empty());
|
||
|
||
state
|
||
.handle_background_event(BackgroundEvent::Page {
|
||
request_token: second_request.request_token,
|
||
search_token: second_request.search_token,
|
||
page: Ok(page(
|
||
vec![make_row(
|
||
"/tmp/match.jsonl",
|
||
"2025-01-03T00:00:00Z",
|
||
"target log",
|
||
)],
|
||
Some("2025-01-04T00:00:00Z"),
|
||
/*num_scanned_files*/ 7,
|
||
/*reached_scan_cap*/ false,
|
||
)),
|
||
})
|
||
.await
|
||
.unwrap();
|
||
|
||
assert!(!state.filtered_rows.is_empty());
|
||
assert!(!state.search_state.is_active());
|
||
|
||
recorded_requests.lock().unwrap().clear();
|
||
state.set_query("missing".to_string());
|
||
let active_request = {
|
||
let guard = recorded_requests.lock().unwrap();
|
||
assert_eq!(guard.len(), 1);
|
||
guard[0].clone()
|
||
};
|
||
|
||
state
|
||
.handle_background_event(BackgroundEvent::Page {
|
||
request_token: second_request.request_token,
|
||
search_token: second_request.search_token,
|
||
page: Ok(page(
|
||
Vec::new(),
|
||
/*next_cursor*/ None,
|
||
/*num_scanned_files*/ 0,
|
||
/*reached_scan_cap*/ false,
|
||
)),
|
||
})
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(recorded_requests.lock().unwrap().len(), 1);
|
||
|
||
state
|
||
.handle_background_event(BackgroundEvent::Page {
|
||
request_token: active_request.request_token,
|
||
search_token: active_request.search_token,
|
||
page: Ok(page(
|
||
Vec::new(),
|
||
/*next_cursor*/ None,
|
||
/*num_scanned_files*/ 3,
|
||
/*reached_scan_cap*/ true,
|
||
)),
|
||
})
|
||
.await
|
||
.unwrap();
|
||
|
||
assert!(state.filtered_rows.is_empty());
|
||
assert!(!state.search_state.is_active());
|
||
assert!(state.pagination.reached_scan_cap);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn paste_appends_to_existing_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_paste(String::from("results"));
|
||
|
||
assert_eq!(state.query, "resize results");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn whitespace_only_paste_is_ignored() {
|
||
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_paste(String::from(" \n\t "));
|
||
|
||
assert_eq!(state.query, "resize");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn paste_uses_existing_search_loading_path() {
|
||
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = 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.reset_pagination();
|
||
state.ingest_page(page(
|
||
vec![make_row(
|
||
"/tmp/start.jsonl",
|
||
"2025-01-01T00:00:00Z",
|
||
"alpha",
|
||
)],
|
||
Some("2025-01-02T00:00:00Z"),
|
||
/*num_scanned_files*/ 1,
|
||
/*reached_scan_cap*/ false,
|
||
));
|
||
recorded_requests.lock().unwrap().clear();
|
||
|
||
state.handle_paste(String::from("target"));
|
||
|
||
let guard = recorded_requests.lock().unwrap();
|
||
assert_eq!(state.query, "target");
|
||
assert_eq!(guard.len(), 1);
|
||
assert!(guard[0].search_token.is_some());
|
||
}
|
||
|
||
#[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"))
|
||
);
|
||
}
|
||
}
|