mirror of
https://github.com/openai/codex.git
synced 2026-04-08 16:41:39 +03:00
Compare commits
4 Commits
dev/window
...
codex/undo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6945c400fb | ||
|
|
8ac526ab3e | ||
|
|
385827b1f9 | ||
|
|
d441b8a0d7 |
@@ -45,13 +45,16 @@ use crate::pager_overlay::Overlay;
|
||||
use crate::read_session_model;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::resume_picker::PreferredSession;
|
||||
use crate::resume_picker::SessionSelection;
|
||||
use crate::resume_picker::SessionTarget;
|
||||
#[cfg(test)]
|
||||
use crate::test_support::PathBufExt;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::update_action::UpdateAction;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use chrono::Utc;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
use codex_app_server_client::TypedRequestError;
|
||||
@@ -327,6 +330,29 @@ fn session_summary(
|
||||
})
|
||||
}
|
||||
|
||||
fn session_transition_history_lines(
|
||||
summary: Option<SessionSummary>,
|
||||
include_resume_picker_hint: bool,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
if let Some(summary) = summary {
|
||||
lines.push(summary.usage_line.into());
|
||||
if let Some(command) = summary.resume_command {
|
||||
let spans = vec!["To continue this session, run ".into(), command.cyan()];
|
||||
lines.push(spans.into());
|
||||
}
|
||||
}
|
||||
if include_resume_picker_hint {
|
||||
let spans = vec![
|
||||
"Previous session available via ".into(),
|
||||
"/resume".magenta(),
|
||||
".".into(),
|
||||
];
|
||||
lines.push(spans.into());
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec<SkillErrorInfo> {
|
||||
response
|
||||
.skills
|
||||
@@ -1003,6 +1029,7 @@ pub(crate) struct App {
|
||||
primary_session_configured: Option<ThreadSessionState>,
|
||||
pending_primary_events: VecDeque<ThreadBufferedEvent>,
|
||||
pending_app_server_requests: PendingAppServerRequests,
|
||||
last_displaced_session: Option<PreferredSession>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -3266,6 +3293,24 @@ impl App {
|
||||
self.sync_active_agent_label();
|
||||
}
|
||||
|
||||
fn displaced_session_for_resume(&self) -> Option<PreferredSession> {
|
||||
let thread_id = self.chat_widget.thread_id()?;
|
||||
let thread_name = self.chat_widget.thread_name();
|
||||
let path = self
|
||||
.chat_widget
|
||||
.rollout_path()
|
||||
.filter(|path| !path.as_os_str().is_empty());
|
||||
Some(PreferredSession {
|
||||
target: SessionTarget { path, thread_id },
|
||||
preview: thread_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| String::from("Previous session")),
|
||||
thread_name,
|
||||
cwd: Some(self.config.cwd.to_path_buf()),
|
||||
updated_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn start_fresh_session_with_summary_hint(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
@@ -3300,12 +3345,11 @@ impl App {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to attach to fresh app-server thread: {err}"
|
||||
));
|
||||
} else if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
|
||||
if let Some(command) = summary.resume_command {
|
||||
let spans = vec!["To continue this session, run ".into(), command.cyan()];
|
||||
lines.push(spans.into());
|
||||
}
|
||||
} else if summary.is_some() || self.last_displaced_session.is_some() {
|
||||
let lines = session_transition_history_lines(
|
||||
summary,
|
||||
self.last_displaced_session.is_some(),
|
||||
);
|
||||
self.chat_widget.add_plain_history_lines(lines);
|
||||
}
|
||||
}
|
||||
@@ -3794,6 +3838,7 @@ impl App {
|
||||
primary_session_configured: None,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
last_displaced_session: None,
|
||||
};
|
||||
if let Some(started) = initial_started_thread {
|
||||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||||
@@ -4015,10 +4060,12 @@ impl App {
|
||||
) -> Result<AppRunControl> {
|
||||
match event {
|
||||
AppEvent::NewSession => {
|
||||
self.last_displaced_session = self.displaced_session_for_resume();
|
||||
self.start_fresh_session_with_summary_hint(tui, app_server)
|
||||
.await;
|
||||
}
|
||||
AppEvent::ClearUi => {
|
||||
self.last_displaced_session = self.displaced_session_for_resume();
|
||||
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
|
||||
self.reset_app_ui_state_after_clear();
|
||||
|
||||
@@ -4052,6 +4099,7 @@ impl App {
|
||||
/*show_all*/ false,
|
||||
/*include_non_interactive*/ false,
|
||||
picker_app_server,
|
||||
self.last_displaced_session.clone(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -4113,16 +4161,10 @@ impl App {
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
vec![summary.usage_line.clone().into()];
|
||||
if let Some(command) = summary.resume_command {
|
||||
let spans = vec![
|
||||
"To continue this session, run ".into(),
|
||||
command.cyan(),
|
||||
];
|
||||
lines.push(spans.into());
|
||||
}
|
||||
self.last_displaced_session = None;
|
||||
let lines =
|
||||
session_transition_history_lines(summary, false);
|
||||
if !lines.is_empty() {
|
||||
self.chat_widget.add_plain_history_lines(lines);
|
||||
}
|
||||
}
|
||||
@@ -4173,16 +4215,9 @@ impl App {
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
vec![summary.usage_line.clone().into()];
|
||||
if let Some(command) = summary.resume_command {
|
||||
let spans = vec![
|
||||
"To continue this session, run ".into(),
|
||||
command.cyan(),
|
||||
];
|
||||
lines.push(spans.into());
|
||||
}
|
||||
self.last_displaced_session = None;
|
||||
let lines = session_transition_history_lines(summary, false);
|
||||
if !lines.is_empty() {
|
||||
self.chat_widget.add_plain_history_lines(lines);
|
||||
}
|
||||
}
|
||||
@@ -9105,6 +9140,7 @@ guardian_approval = true
|
||||
primary_session_configured: None,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
last_displaced_session: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9159,6 +9195,7 @@ guardian_approval = true
|
||||
primary_session_configured: None,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
last_displaced_session: None,
|
||||
},
|
||||
rx,
|
||||
op_rx,
|
||||
@@ -10947,4 +10984,72 @@ guardian_approval = true
|
||||
Some("codex resume my-session".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_transition_history_lines_include_resume_picker_hint() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 2,
|
||||
total_tokens: 12,
|
||||
..Default::default()
|
||||
};
|
||||
let thread_id =
|
||||
ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").expect("thread id");
|
||||
let summary =
|
||||
session_summary(usage, Some(thread_id), Some("my-session".to_string())).expect("line");
|
||||
let rendered = session_transition_history_lines(
|
||||
Some(summary),
|
||||
/*include_resume_picker_hint*/ true,
|
||||
)
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
assert_snapshot!(
|
||||
"session_transition_history_lines_with_resume_picker_hint",
|
||||
rendered
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn displaced_session_for_resume_captures_current_thread_metadata() {
|
||||
let mut app = make_test_app().await;
|
||||
let thread_id =
|
||||
ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").expect("thread id");
|
||||
let rollout_path =
|
||||
PathBuf::from("/tmp/sessions/123e4567-e89b-12d3-a456-426614174000.jsonl");
|
||||
app.config.cwd = PathBuf::from("/tmp/project").abs();
|
||||
app.chat_widget.handle_thread_session(ThreadSessionState {
|
||||
thread_id,
|
||||
forked_from_id: None,
|
||||
thread_name: Some(String::from("saved-session")),
|
||||
model: String::from("gpt-test"),
|
||||
model_provider_id: String::from("test-provider"),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_path.clone()),
|
||||
});
|
||||
|
||||
let displaced = app
|
||||
.displaced_session_for_resume()
|
||||
.expect("current thread should be captured");
|
||||
assert_eq!(displaced.target.thread_id, thread_id);
|
||||
assert_eq!(displaced.target.path, Some(rollout_path));
|
||||
assert_eq!(displaced.thread_name, Some(String::from("saved-session")));
|
||||
assert_eq!(displaced.preview, String::from("saved-session"));
|
||||
assert_eq!(displaced.cwd, Some(PathBuf::from("/tmp/project")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,6 +1258,7 @@ async fn run_ratatui_app(
|
||||
cli.resume_show_all,
|
||||
cli.resume_include_non_interactive,
|
||||
app_server,
|
||||
/*preferred_session*/ None,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
|
||||
@@ -69,6 +69,15 @@ pub enum SessionSelection {
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PreferredSession {
|
||||
pub(crate) target: SessionTarget,
|
||||
pub(crate) preview: String,
|
||||
pub(crate) thread_name: Option<String>,
|
||||
pub(crate) cwd: Option<PathBuf>,
|
||||
pub(crate) updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum SessionPickerAction {
|
||||
Resume,
|
||||
@@ -116,6 +125,13 @@ enum ProviderFilter {
|
||||
|
||||
type PageLoader = Arc<dyn Fn(PageLoadRequest) + Send + Sync>;
|
||||
|
||||
struct SessionPickerOptions {
|
||||
show_all: bool,
|
||||
action: SessionPickerAction,
|
||||
is_remote: bool,
|
||||
preferred_session: Option<PreferredSession>,
|
||||
}
|
||||
|
||||
enum BackgroundEvent {
|
||||
PageLoaded {
|
||||
request_token: usize,
|
||||
@@ -138,6 +154,17 @@ struct PickerPage {
|
||||
reached_scan_cap: bool,
|
||||
}
|
||||
|
||||
struct PickerStateInit {
|
||||
codex_home: PathBuf,
|
||||
requester: FrameRequester,
|
||||
page_loader: PageLoader,
|
||||
provider_filter: ProviderFilter,
|
||||
show_all: bool,
|
||||
filter_cwd: Option<PathBuf>,
|
||||
action: SessionPickerAction,
|
||||
preferred_session: Option<PreferredSession>,
|
||||
}
|
||||
|
||||
/// Interactive session picker that lists recorded rollout files with simple
|
||||
/// search and pagination.
|
||||
///
|
||||
@@ -169,6 +196,7 @@ pub async fn run_resume_picker_with_app_server(
|
||||
show_all: bool,
|
||||
include_non_interactive: bool,
|
||||
app_server: AppServerSession,
|
||||
preferred_session: Option<PreferredSession>,
|
||||
) -> Result<SessionSelection> {
|
||||
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
|
||||
let is_remote = app_server.is_remote();
|
||||
@@ -180,11 +208,14 @@ pub async fn run_resume_picker_with_app_server(
|
||||
run_session_picker_with_loader(
|
||||
tui,
|
||||
config,
|
||||
show_all,
|
||||
SessionPickerAction::Resume,
|
||||
is_remote,
|
||||
spawn_app_server_page_loader(app_server, cwd_filter, include_non_interactive, bg_tx),
|
||||
bg_rx,
|
||||
SessionPickerOptions {
|
||||
show_all,
|
||||
action: SessionPickerAction::Resume,
|
||||
is_remote,
|
||||
preferred_session,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -205,13 +236,16 @@ pub async fn run_fork_picker_with_app_server(
|
||||
run_session_picker_with_loader(
|
||||
tui,
|
||||
config,
|
||||
show_all,
|
||||
SessionPickerAction::Fork,
|
||||
is_remote,
|
||||
spawn_app_server_page_loader(
|
||||
app_server, cwd_filter, /*include_non_interactive*/ false, bg_tx,
|
||||
),
|
||||
bg_rx,
|
||||
SessionPickerOptions {
|
||||
show_all,
|
||||
action: SessionPickerAction::Fork,
|
||||
is_remote,
|
||||
preferred_session: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -227,11 +261,14 @@ async fn run_session_picker(
|
||||
run_session_picker_with_loader(
|
||||
tui,
|
||||
config,
|
||||
show_all,
|
||||
action,
|
||||
/*is_remote*/ false,
|
||||
spawn_rollout_page_loader(config, bg_tx),
|
||||
bg_rx,
|
||||
SessionPickerOptions {
|
||||
show_all,
|
||||
action,
|
||||
is_remote: false,
|
||||
preferred_session: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -239,20 +276,18 @@ async fn run_session_picker(
|
||||
async fn run_session_picker_with_loader(
|
||||
tui: &mut Tui,
|
||||
config: &Config,
|
||||
show_all: bool,
|
||||
action: SessionPickerAction,
|
||||
is_remote: bool,
|
||||
page_loader: PageLoader,
|
||||
bg_rx: mpsc::UnboundedReceiver<BackgroundEvent>,
|
||||
options: SessionPickerOptions,
|
||||
) -> Result<SessionSelection> {
|
||||
let alt = AltScreenGuard::enter(tui);
|
||||
let provider_filter = if is_remote {
|
||||
let provider_filter = if options.is_remote {
|
||||
ProviderFilter::Any
|
||||
} else {
|
||||
ProviderFilter::MatchDefault(config.model_provider_id.to_string())
|
||||
};
|
||||
let codex_home = config.codex_home.as_path();
|
||||
let filter_cwd = if show_all || is_remote {
|
||||
let filter_cwd = if options.show_all || options.is_remote {
|
||||
// Remote sessions live in the server's filesystem namespace, so the client
|
||||
// process cwd is not a meaningful row filter. If the user provided an
|
||||
// explicit remote --cd, filtering is handled server-side in thread/list.
|
||||
@@ -261,15 +296,16 @@ async fn run_session_picker_with_loader(
|
||||
std::env::current_dir().ok()
|
||||
};
|
||||
|
||||
let mut state = PickerState::new(
|
||||
codex_home.to_path_buf(),
|
||||
alt.tui.frame_requester(),
|
||||
let mut state = PickerState::new(PickerStateInit {
|
||||
codex_home: codex_home.to_path_buf(),
|
||||
requester: alt.tui.frame_requester(),
|
||||
page_loader,
|
||||
provider_filter,
|
||||
show_all,
|
||||
show_all: options.show_all,
|
||||
filter_cwd,
|
||||
action,
|
||||
);
|
||||
action: options.action,
|
||||
preferred_session: options.preferred_session,
|
||||
});
|
||||
state.start_initial_load();
|
||||
state.request_frame();
|
||||
|
||||
@@ -444,6 +480,7 @@ struct PickerState {
|
||||
sort_key: ThreadSortKey,
|
||||
thread_name_cache: HashMap<ThreadId, Option<String>>,
|
||||
inline_error: Option<String>,
|
||||
preferred_row: Option<Row>,
|
||||
}
|
||||
|
||||
struct PaginationState {
|
||||
@@ -537,6 +574,7 @@ struct Row {
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
cwd: Option<PathBuf>,
|
||||
git_branch: Option<String>,
|
||||
is_preferred: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
@@ -571,15 +609,17 @@ impl Row {
|
||||
}
|
||||
|
||||
impl PickerState {
|
||||
fn new(
|
||||
codex_home: PathBuf,
|
||||
requester: FrameRequester,
|
||||
page_loader: PageLoader,
|
||||
provider_filter: ProviderFilter,
|
||||
show_all: bool,
|
||||
filter_cwd: Option<PathBuf>,
|
||||
action: SessionPickerAction,
|
||||
) -> Self {
|
||||
fn new(init: PickerStateInit) -> Self {
|
||||
let PickerStateInit {
|
||||
codex_home,
|
||||
requester,
|
||||
page_loader,
|
||||
provider_filter,
|
||||
show_all,
|
||||
filter_cwd,
|
||||
action,
|
||||
preferred_session,
|
||||
} = init;
|
||||
Self {
|
||||
codex_home,
|
||||
requester,
|
||||
@@ -608,6 +648,17 @@ impl PickerState {
|
||||
sort_key: ThreadSortKey::UpdatedAt,
|
||||
thread_name_cache: HashMap::new(),
|
||||
inline_error: None,
|
||||
preferred_row: preferred_session.map(|session| Row {
|
||||
path: session.target.path,
|
||||
preview: session.preview,
|
||||
thread_id: Some(session.target.thread_id),
|
||||
thread_name: session.thread_name,
|
||||
created_at: None,
|
||||
updated_at: Some(session.updated_at),
|
||||
cwd: session.cwd,
|
||||
git_branch: None,
|
||||
is_preferred: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,6 +773,7 @@ impl PickerState {
|
||||
self.filtered_rows.clear();
|
||||
self.seen_rows.clear();
|
||||
self.selected = 0;
|
||||
self.seed_preferred_row();
|
||||
|
||||
let search_token = if self.query.is_empty() {
|
||||
self.search_state = SearchState::Idle;
|
||||
@@ -737,7 +789,7 @@ impl PickerState {
|
||||
request_token,
|
||||
search_token,
|
||||
});
|
||||
self.request_frame();
|
||||
self.apply_filter();
|
||||
|
||||
(self.page_loader)(PageLoadRequest {
|
||||
cursor: None,
|
||||
@@ -780,6 +832,16 @@ impl PickerState {
|
||||
self.pagination.loading = LoadingState::Idle;
|
||||
}
|
||||
|
||||
fn seed_preferred_row(&mut self) {
|
||||
let Some(row) = self.preferred_row.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Some(seen_key) = row.seen_key() {
|
||||
self.seen_rows.insert(seen_key);
|
||||
}
|
||||
self.all_rows.push(row);
|
||||
}
|
||||
|
||||
fn ingest_page(&mut self, page: PickerPage) {
|
||||
if let Some(cursor) = page.next_cursor.clone() {
|
||||
self.pagination.next_cursor = Some(cursor);
|
||||
@@ -874,6 +936,9 @@ impl PickerState {
|
||||
}
|
||||
|
||||
fn row_matches_filter(&self, row: &Row) -> bool {
|
||||
if row.is_preferred {
|
||||
return true;
|
||||
}
|
||||
if self.show_all {
|
||||
return true;
|
||||
}
|
||||
@@ -1091,6 +1156,7 @@ fn head_to_row(item: &ThreadItem) -> Row {
|
||||
updated_at,
|
||||
cwd: item.cwd.clone(),
|
||||
git_branch: item.git_branch.clone(),
|
||||
is_preferred: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1118,6 +1184,7 @@ fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
cwd: Some(thread.cwd),
|
||||
git_branch: thread.git_info.and_then(|git_info| git_info.branch),
|
||||
is_preferred: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1689,6 +1756,27 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn test_picker_state(
|
||||
codex_home: PathBuf,
|
||||
loader: PageLoader,
|
||||
provider_filter: ProviderFilter,
|
||||
show_all: bool,
|
||||
filter_cwd: Option<PathBuf>,
|
||||
action: SessionPickerAction,
|
||||
preferred_session: Option<PreferredSession>,
|
||||
) -> PickerState {
|
||||
PickerState::new(PickerStateInit {
|
||||
codex_home,
|
||||
requester: FrameRequester::test_dummy(),
|
||||
page_loader: loader,
|
||||
provider_filter,
|
||||
show_all,
|
||||
filter_cwd,
|
||||
action,
|
||||
preferred_session,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn set_rollout_mtime(path: &Path, updated_at: DateTime<Utc>) {
|
||||
let times = FileTimes::new().set_modified(updated_at.into());
|
||||
@@ -1857,6 +1945,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
};
|
||||
|
||||
assert_eq!(row.display_preview(), "My session");
|
||||
@@ -1899,14 +1988,14 @@ mod tests {
|
||||
#[test]
|
||||
fn remote_picker_does_not_filter_rows_by_local_cwd() {
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let state = PickerState::new(
|
||||
let state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::Any,
|
||||
/*show_all*/ false,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
let row = Row {
|
||||
path: None,
|
||||
@@ -1917,6 +2006,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: Some(PathBuf::from("/srv/remote-project")),
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
};
|
||||
|
||||
assert!(state.row_matches_filter(&row));
|
||||
@@ -1930,14 +2020,14 @@ mod tests {
|
||||
use ratatui::layout::Layout;
|
||||
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let mut state = PickerState::new(
|
||||
let mut state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
|
||||
let now = Utc::now();
|
||||
@@ -1951,6 +2041,7 @@ mod tests {
|
||||
updated_at: Some(now - Duration::seconds(42)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
},
|
||||
Row {
|
||||
path: Some(PathBuf::from("/tmp/b.jsonl")),
|
||||
@@ -1961,6 +2052,7 @@ mod tests {
|
||||
updated_at: Some(now - Duration::minutes(35)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
},
|
||||
Row {
|
||||
path: Some(PathBuf::from("/tmp/c.jsonl")),
|
||||
@@ -1971,6 +2063,7 @@ mod tests {
|
||||
updated_at: Some(now - Duration::hours(2)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
},
|
||||
];
|
||||
state.all_rows = rows.clone();
|
||||
@@ -2009,14 +2102,14 @@ mod tests {
|
||||
use crate::test_backend::VT100Backend;
|
||||
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let mut state = PickerState::new(
|
||||
let mut state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
state.inline_error = Some(String::from(
|
||||
"Failed to read session metadata from /tmp/missing.jsonl",
|
||||
@@ -2245,14 +2338,14 @@ mod tests {
|
||||
std::fs::write(&session_index_path, out).expect("write session index");
|
||||
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let mut state = PickerState::new(
|
||||
let mut state = test_picker_state(
|
||||
tempdir.path().to_path_buf(),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
|
||||
let now = Utc::now();
|
||||
@@ -2266,6 +2359,7 @@ mod tests {
|
||||
updated_at: Some(now - Duration::days(2)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
},
|
||||
Row {
|
||||
path: Some(PathBuf::from("/tmp/b.jsonl")),
|
||||
@@ -2276,6 +2370,7 @@ mod tests {
|
||||
updated_at: Some(now - Duration::days(3)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
},
|
||||
];
|
||||
state.all_rows = rows.clone();
|
||||
@@ -2327,14 +2422,14 @@ mod tests {
|
||||
.expect("write session index");
|
||||
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let mut state = PickerState::new(
|
||||
let mut state = test_picker_state(
|
||||
tempdir.path().to_path_buf(),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
|
||||
state.all_rows = vec![Row {
|
||||
@@ -2346,6 +2441,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
}];
|
||||
state.filtered_rows = state.all_rows.clone();
|
||||
|
||||
@@ -2362,16 +2458,149 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pageless_scrolling_deduplicates_and_keeps_order() {
|
||||
fn preferred_session_is_pinned_and_deduped() {
|
||||
let thread_id =
|
||||
ThreadId::from_string("11111111-1111-1111-1111-111111111111").expect("thread id");
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let mut state = PickerState::new(
|
||||
let now = Utc::now();
|
||||
let mut state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
Some(PreferredSession {
|
||||
target: SessionTarget {
|
||||
path: Some(PathBuf::from("/tmp/recent.jsonl")),
|
||||
thread_id,
|
||||
},
|
||||
preview: String::from("Previous session"),
|
||||
thread_name: Some(String::from("Recovered thread")),
|
||||
cwd: Some(PathBuf::from("/tmp/project")),
|
||||
updated_at: now,
|
||||
}),
|
||||
);
|
||||
|
||||
state.start_initial_load();
|
||||
state.ingest_page(PickerPage {
|
||||
rows: vec![
|
||||
Row {
|
||||
path: Some(PathBuf::from("/tmp/recent.jsonl")),
|
||||
preview: String::from("duplicate"),
|
||||
thread_id: Some(thread_id),
|
||||
thread_name: Some(String::from("Duplicate row")),
|
||||
created_at: None,
|
||||
updated_at: Some(now - Duration::minutes(1)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
},
|
||||
Row {
|
||||
path: Some(PathBuf::from("/tmp/older.jsonl")),
|
||||
preview: String::from("Older session"),
|
||||
thread_id: Some(ThreadId::new()),
|
||||
thread_name: None,
|
||||
created_at: None,
|
||||
updated_at: Some(now - Duration::hours(1)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
},
|
||||
],
|
||||
next_cursor: None,
|
||||
num_scanned_files: 2,
|
||||
reached_scan_cap: false,
|
||||
});
|
||||
|
||||
assert_eq!(state.filtered_rows.len(), 2);
|
||||
assert_eq!(state.selected, 0);
|
||||
assert_eq!(state.filtered_rows[0].display_preview(), "Recovered thread");
|
||||
assert!(state.filtered_rows[0].is_preferred);
|
||||
assert_eq!(state.filtered_rows[1].display_preview(), "Older session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_session_snapshot() {
|
||||
use crate::custom_terminal::Terminal;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let now = Utc::now();
|
||||
let mut state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
Some(PreferredSession {
|
||||
target: SessionTarget {
|
||||
path: Some(PathBuf::from("/tmp/recent.jsonl")),
|
||||
thread_id: ThreadId::new(),
|
||||
},
|
||||
preview: String::from("Previous session"),
|
||||
thread_name: Some(String::from("Recovered thread")),
|
||||
cwd: Some(PathBuf::from("/tmp/project")),
|
||||
updated_at: now,
|
||||
}),
|
||||
);
|
||||
state.start_initial_load();
|
||||
state.ingest_page(PickerPage {
|
||||
rows: vec![Row {
|
||||
path: Some(PathBuf::from("/tmp/older.jsonl")),
|
||||
preview: String::from("Older session"),
|
||||
thread_id: Some(ThreadId::new()),
|
||||
thread_name: None,
|
||||
created_at: None,
|
||||
updated_at: Some(now - Duration::hours(1)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
}],
|
||||
next_cursor: None,
|
||||
num_scanned_files: 1,
|
||||
reached_scan_cap: false,
|
||||
});
|
||||
state.pagination.loading = LoadingState::Idle;
|
||||
state.relative_time_reference = Some(now);
|
||||
state.view_rows = Some(2);
|
||||
state.update_view_rows(/*rows*/ 2);
|
||||
|
||||
let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all, now);
|
||||
let width: u16 = 80;
|
||||
let height: u16 = 4;
|
||||
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();
|
||||
let segments =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area);
|
||||
render_column_headers(&mut frame, segments[0], &metrics, state.sort_key);
|
||||
render_list(&mut frame, segments[1], &state, &metrics);
|
||||
}
|
||||
terminal.flush().expect("flush");
|
||||
|
||||
let snapshot = terminal.backend().to_string();
|
||||
assert_snapshot!("resume_picker_preferred_session", snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pageless_scrolling_deduplicates_and_keeps_order() {
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let mut state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
|
||||
state.reset_pagination();
|
||||
@@ -2433,14 +2662,14 @@ mod tests {
|
||||
request_sink.lock().unwrap().push(req);
|
||||
});
|
||||
|
||||
let mut state = PickerState::new(
|
||||
let mut state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
state.reset_pagination();
|
||||
state.ingest_page(page(
|
||||
@@ -2514,14 +2743,14 @@ mod tests {
|
||||
request_sink.lock().unwrap().push(req);
|
||||
});
|
||||
|
||||
let mut state = PickerState::new(
|
||||
let mut state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
|
||||
state.start_initial_load();
|
||||
@@ -2544,14 +2773,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn page_navigation_uses_view_rows() {
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let mut state = PickerState::new(
|
||||
let mut state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
|
||||
let mut items = Vec::new();
|
||||
@@ -2592,14 +2821,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn enter_on_row_without_resolvable_thread_id_shows_inline_error() {
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let mut state = PickerState::new(
|
||||
let mut state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
|
||||
let row = Row {
|
||||
@@ -2611,6 +2840,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
};
|
||||
state.all_rows = vec![row.clone()];
|
||||
state.filtered_rows = vec![row];
|
||||
@@ -2632,14 +2862,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn enter_on_pathless_thread_uses_thread_id() {
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let mut state = PickerState::new(
|
||||
let mut state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
let thread_id = ThreadId::new();
|
||||
let row = Row {
|
||||
@@ -2651,6 +2881,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
is_preferred: false,
|
||||
};
|
||||
state.all_rows = vec![row.clone()];
|
||||
state.filtered_rows = vec![row];
|
||||
@@ -2702,14 +2933,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn up_at_bottom_does_not_scroll_when_visible() {
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let mut state = PickerState::new(
|
||||
let mut state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
|
||||
let mut items = Vec::new();
|
||||
@@ -2750,14 +2981,14 @@ mod tests {
|
||||
request_sink.lock().unwrap().push(req);
|
||||
});
|
||||
|
||||
let mut state = PickerState::new(
|
||||
let mut state = test_picker_state(
|
||||
PathBuf::from("/tmp"),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
/*preferred_session*/ None,
|
||||
);
|
||||
state.reset_pagination();
|
||||
state.ingest_page(page(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/app.rs
|
||||
expression: rendered
|
||||
---
|
||||
Token usage: total=12 input=10 output=2
|
||||
To continue this session, run codex resume my-session
|
||||
Previous session available via /resume.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: snapshot
|
||||
---
|
||||
Created Updated Branch CWD Conversation
|
||||
> - 0 seconds ago - tmp/project Recovered thread
|
||||
- 1 hour ago - - Older session
|
||||
Reference in New Issue
Block a user