mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui): redesign session picker (#20065)
## Why The resume/fork picker is becoming the main way users recover previous work, but the old fixed table made sessions hard to scan once thread names, branches, working directories, and timestamps all mattered. This redesign makes the picker denser by default, easier to search, and safer to inspect before resuming or forking. <table> <tr> <td> <img width="1660" height="1103" alt="CleanShot 2026-05-03 at 12 34 10" src="https://github.com/user-attachments/assets/313ede1d-1da4-4863-acd2-56b3e27e9703" /> </td> <td> <img width="1662" height="1100" alt="CleanShot 2026-05-03 at 12 34 15" src="https://github.com/user-attachments/assets/cfde7d5c-bab0-4994-a807-254e53f344ea" /> </td> </tr> <tr> <td> <img width="1664" height="1107" alt="CleanShot 2026-05-03 at 12 39 22" src="https://github.com/user-attachments/assets/e1ee58ca-4dc5-4a35-ae0f-47562da3974c" /> </td> <td> <img width="1662" height="1100" alt="CleanShot 2026-05-03 at 12 35 09" src="https://github.com/user-attachments/assets/9c888072-eedf-4f45-985c-0c14df28bcc7" /> </td> </tr> </table> ## What Changed - Replaces the old session table with responsive session rows that prioritize the session name or preview, then show timestamp, cwd, and branch metadata. - Makes dense view the default while keeping comfortable view available through `Ctrl+O`. - Persists the picker view preference in `[tui].session_picker_view`, including active profile-scoped config. - Adds sort/filter controls for updated time, created time, cwd, and all sessions. - Expands search matching across session name, preview, thread id, branch, and cwd. - Makes `Esc` safer in search mode: it clears an active query before starting a new session. - Adds lazy transcript inspection: - `Space` expands recent transcript context inline. - `Ctrl+T` opens a transcript overlay. - raw reasoning visibility follows `show_raw_agent_reasoning`. - Keeps remote cwd filtering server-side for remote app-server sessions so local path normalization does not incorrectly hide remote results. - Updates snapshots and config schema for the new picker states and config option. ## How to Test 1. Start Codex in a repo with several saved sessions. 2. Press `Ctrl+R` / resume picker entry point. 3. Confirm the picker opens in dense mode and shows session name or preview, timestamp, cwd, and branch metadata. 4. Press `Ctrl+O` and confirm it switches between dense and comfortable views. 5. Restart Codex and confirm the selected view persists. 6. Type a query that matches a branch, cwd, thread id, or session name; confirm matching sessions appear. 7. Press `Esc` while the query is non-empty and confirm it clears search instead of starting a new session. 8. Select a session and press `Space`; confirm recent transcript context expands inline. 9. Press `Ctrl+T`; confirm the transcript overlay opens and respects raw-reasoning visibility settings. Targeted tests: - `cargo test -p codex-tui resume_picker --no-fail-fast` - `cargo test -p codex-core runtime_config_resolves_session_picker_view_default_and_override` - `cargo test -p codex-core profile_tui_rejects_unsupported_settings` - `cargo check -p codex-thread-manager-sample` - `cargo insta pending-snapshots`
This commit is contained in:
committed by
GitHub
Unverified
parent
52fbbe7cdd
commit
3b2ebb368e
@@ -77,7 +77,7 @@ impl App {
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
};
|
||||
match crate::resume_picker::run_resume_picker_with_app_server(
|
||||
match crate::resume_picker::run_resume_picker_from_existing_session_with_app_server(
|
||||
tui,
|
||||
&self.config,
|
||||
/*show_all*/ false,
|
||||
@@ -97,9 +97,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
SessionSelection::Exit
|
||||
| SessionSelection::StartFresh
|
||||
| SessionSelection::Fork(_) => {}
|
||||
SessionSelection::Exit | SessionSelection::StartFresh => {
|
||||
self.refresh_in_memory_config_from_disk_best_effort(
|
||||
"closing the session picker",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
SessionSelection::Fork(_) => {}
|
||||
}
|
||||
|
||||
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
|
||||
|
||||
@@ -1430,6 +1430,11 @@ async fn run_ratatui_app(
|
||||
None => None,
|
||||
};
|
||||
|
||||
let picker_cancelled_without_selection = matches!(
|
||||
session_selection,
|
||||
resume_picker::SessionSelection::StartFresh
|
||||
) && (cli.resume_picker || cli.fork_picker);
|
||||
|
||||
let mut config = match &session_selection {
|
||||
resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => {
|
||||
load_config_or_exit_with_fallback_cwd(
|
||||
@@ -1440,6 +1445,14 @@ async fn run_ratatui_app(
|
||||
)
|
||||
.await
|
||||
}
|
||||
resume_picker::SessionSelection::StartFresh if picker_cancelled_without_selection => {
|
||||
load_config_or_exit(
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
cloud_requirements.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => config,
|
||||
};
|
||||
|
||||
|
||||
+4524
-567
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::history_cell::AgentMarkdownCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::history_cell::ReasoningSummaryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use codex_app_server_protocol::Thread;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use ratatui::style::Stylize as _;
|
||||
use ratatui::text::Line;
|
||||
|
||||
pub(crate) type TranscriptCells = Vec<Arc<dyn HistoryCell>>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum RawReasoningVisibility {
|
||||
Hidden,
|
||||
Visible,
|
||||
}
|
||||
|
||||
pub(crate) async fn load_session_transcript(
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
raw_reasoning_visibility: RawReasoningVisibility,
|
||||
) -> std::io::Result<TranscriptCells> {
|
||||
let thread = app_server
|
||||
.thread_read(thread_id, /*include_turns*/ true)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
Ok(thread_to_transcript_cells(
|
||||
&thread,
|
||||
raw_reasoning_visibility,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn thread_to_transcript_cells(
|
||||
thread: &Thread,
|
||||
raw_reasoning_visibility: RawReasoningVisibility,
|
||||
) -> TranscriptCells {
|
||||
let cwd = thread.cwd.as_path();
|
||||
let mut cells: TranscriptCells = Vec::new();
|
||||
for item in thread.turns.iter().flat_map(|turn| turn.items.iter()) {
|
||||
match item {
|
||||
ThreadItem::UserMessage { id, content } => {
|
||||
let item = UserMessageItem {
|
||||
id: id.clone(),
|
||||
content: content
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(codex_app_server_protocol::UserInput::into_core)
|
||||
.collect(),
|
||||
};
|
||||
cells.push(Arc::new(UserHistoryCell {
|
||||
message: item.message(),
|
||||
text_elements: item.text_elements(),
|
||||
local_image_paths: item.local_image_paths(),
|
||||
remote_image_urls: item.image_urls(),
|
||||
}));
|
||||
}
|
||||
ThreadItem::AgentMessage { text, .. } => {
|
||||
if !text.trim().is_empty() {
|
||||
cells.push(Arc::new(AgentMarkdownCell::new(text.clone(), cwd)));
|
||||
}
|
||||
}
|
||||
ThreadItem::Plan { text, .. } => {
|
||||
if !text.trim().is_empty() {
|
||||
cells.push(Arc::new(crate::history_cell::new_proposed_plan(
|
||||
text.clone(),
|
||||
cwd,
|
||||
)));
|
||||
}
|
||||
}
|
||||
ThreadItem::Reasoning {
|
||||
summary, content, ..
|
||||
} => {
|
||||
let text = if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible)
|
||||
&& !content.is_empty()
|
||||
{
|
||||
content.join("\n\n")
|
||||
} else {
|
||||
summary.join("\n\n")
|
||||
};
|
||||
if !text.trim().is_empty() {
|
||||
cells.push(Arc::new(ReasoningSummaryCell::new(
|
||||
"Reasoning".to_string(),
|
||||
text,
|
||||
cwd,
|
||||
/*transcript_only*/ false,
|
||||
)));
|
||||
}
|
||||
}
|
||||
other => {
|
||||
if let Some(cell) = fallback_transcript_cell(other) {
|
||||
cells.push(Arc::new(cell));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cells.is_empty() {
|
||||
cells.push(Arc::new(PlainHistoryCell::new(vec![
|
||||
"No transcript content available".italic().dim().into(),
|
||||
])));
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
|
||||
let lines = match item {
|
||||
ThreadItem::HookPrompt { fragments, .. } => fragments
|
||||
.iter()
|
||||
.map(|fragment| {
|
||||
vec![
|
||||
"hook prompt: ".dim(),
|
||||
fragment.text.trim().to_string().into(),
|
||||
]
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
ThreadItem::CommandExecution {
|
||||
command,
|
||||
status,
|
||||
aggregated_output,
|
||||
exit_code,
|
||||
..
|
||||
} => {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
vec![vec!["$ ".dim(), command.clone().into()].into()];
|
||||
lines.push(
|
||||
format!(
|
||||
"status: {status:?}{}",
|
||||
exit_code
|
||||
.map(|code| format!(" · exit {code}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
.dim()
|
||||
.into(),
|
||||
);
|
||||
if let Some(output) = aggregated_output.as_deref()
|
||||
&& !output.trim().is_empty()
|
||||
{
|
||||
lines.extend(
|
||||
output
|
||||
.lines()
|
||||
.map(|line| vec![" ".dim(), line.trim_end().to_string().dim()].into()),
|
||||
);
|
||||
}
|
||||
lines
|
||||
}
|
||||
ThreadItem::FileChange {
|
||||
changes, status, ..
|
||||
} => vec![
|
||||
format!("file changes: {status:?} · {} changes", changes.len())
|
||||
.dim()
|
||||
.into(),
|
||||
],
|
||||
ThreadItem::McpToolCall {
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
..
|
||||
} => vec![
|
||||
format!("mcp tool: {server}/{tool} · {status:?}")
|
||||
.dim()
|
||||
.into(),
|
||||
],
|
||||
ThreadItem::DynamicToolCall {
|
||||
namespace,
|
||||
tool,
|
||||
status,
|
||||
..
|
||||
} => {
|
||||
let name = namespace
|
||||
.as_ref()
|
||||
.map(|namespace| format!("{namespace}/{tool}"))
|
||||
.unwrap_or_else(|| tool.clone());
|
||||
vec![format!("tool: {name} · {status:?}").dim().into()]
|
||||
}
|
||||
ThreadItem::CollabAgentToolCall { tool, status, .. } => {
|
||||
vec![format!("agent tool: {tool:?} · {status:?}").dim().into()]
|
||||
}
|
||||
ThreadItem::WebSearch { query, .. } => {
|
||||
vec![vec!["web search: ".dim(), query.clone().into()].into()]
|
||||
}
|
||||
ThreadItem::ImageView { path, .. } => {
|
||||
vec![format!("image: {}", path.as_path().display()).dim().into()]
|
||||
}
|
||||
ThreadItem::ImageGeneration {
|
||||
status, saved_path, ..
|
||||
} => {
|
||||
let saved = saved_path
|
||||
.as_ref()
|
||||
.map(|path| format!(" · {}", path.as_path().display()))
|
||||
.unwrap_or_default();
|
||||
vec![format!("image generation: {status}{saved}").dim().into()]
|
||||
}
|
||||
ThreadItem::EnteredReviewMode { review, .. } => {
|
||||
vec![vec!["review started: ".dim(), review.clone().into()].into()]
|
||||
}
|
||||
ThreadItem::ExitedReviewMode { review, .. } => {
|
||||
vec![vec!["review finished: ".dim(), review.clone().into()].into()]
|
||||
}
|
||||
ThreadItem::ContextCompaction { .. } => {
|
||||
vec!["context compacted".dim().into()]
|
||||
}
|
||||
ThreadItem::UserMessage { .. }
|
||||
| ThreadItem::AgentMessage { .. }
|
||||
| ThreadItem::Plan { .. }
|
||||
| ThreadItem::Reasoning { .. } => return None,
|
||||
};
|
||||
(!lines.is_empty()).then(|| PlainHistoryCell::new(lines))
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: "render_dense_row_snapshot(true, None, 120,)"
|
||||
---
|
||||
❯ 15m ago Propose session picker redesign with enough title text to exercise truncation
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: "render_dense_row_snapshot(true, None, 100,)"
|
||||
---
|
||||
❯ 15m ago Propose session picker redesign with enough title text to exercise truncation
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: "render_dense_row_snapshot(true, None, 48,)"
|
||||
---
|
||||
❯ 15m ago Propose session picker redesig...
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: "render_dense_row_snapshot(false,\nSome(PathBuf::from(\"/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs\")),\n100,)"
|
||||
---
|
||||
❯ 15m ago Propose session picker redesign with enough title text to exercise truncation
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: "render_dense_row_snapshot(true, None, 48,)"
|
||||
---
|
||||
❯ 15m ago Propose session picker redesig...
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: terminal.backend().to_string()
|
||||
---
|
||||
15m ago First dense row
|
||||
❯ 15m ago Second dense row
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: rendered
|
||||
---
|
||||
⌄ Investigate picker expansion
|
||||
│ Session: 019dabc1-0ef5-7431-b81c-03037f51f62c
|
||||
│ Created: 1 hour ago · 2026-04-28 16:30:00
|
||||
│ Updated: 15 minutes ago · 2026-04-28 17:45:00
|
||||
│ Directory: /tmp/codex
|
||||
│ Branch: fcoury/session-picker
|
||||
│
|
||||
│ Conversation:
|
||||
│ Show me the recent transcript
|
||||
└ Here are the last few lines.
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: "footer_snapshot(&state, 96, 20)"
|
||||
---
|
||||
───────────────────────────────────────────────────────────────────────────────── 0 / 0 · 100% ─
|
||||
enter resume esc clear ctrl+c quit tab focus ←/→ option
|
||||
ctrl+o comfy ctrl+t preview ctrl+e exp ↑/↓ browse
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: "footer_snapshot(&state, 220, 20)"
|
||||
---
|
||||
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 0 / 0 · 100% ─
|
||||
enter resume esc start new ctrl+c quit tab focus sort/filter ←/→ change option
|
||||
ctrl+o dense view ctrl+t transcript ctrl+e expand ↑/↓ browse
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: terminal.backend().to_string()
|
||||
---
|
||||
↑ more
|
||||
❯ item-2
|
||||
10m ago ⌁ no cwd no branch
|
||||
|
||||
item-3
|
||||
↓ more
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: terminal.backend().to_string()
|
||||
---
|
||||
❯ Investigate picker expansion
|
||||
15m ago ⌁ /tmp/codex
|
||||
fcoury/session-picker
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: terminal.backend().to_string()
|
||||
---
|
||||
Type to search Filter: [Cwd] All Sort: [Updated] Created
|
||||
+8
-4
@@ -2,7 +2,11 @@
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: snapshot
|
||||
---
|
||||
Created Updated Branch CWD Conversation
|
||||
16 minutes ago 42 seconds ago - - Fix resume picker timestamps
|
||||
> 1 hour ago 35 minutes ago - - Investigate lazy pagination cap
|
||||
2 hours ago 2 hours ago - - Explain the codebase
|
||||
Fix resume picker timestamps
|
||||
42s ago ⌁ no cwd no branch
|
||||
|
||||
❯ Investigate lazy pagination cap
|
||||
35m ago ⌁ no cwd no branch
|
||||
|
||||
Explain the codebase
|
||||
2h ago ⌁ no cwd no branch
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: snapshot
|
||||
---
|
||||
❯ Find pending threads and emails
|
||||
- ⌁ no cwd no branch
|
||||
|
||||
Plan raw scrollback mod Loading transcript…
|
||||
- ⌁ no cwd branch
|
||||
Reference in New Issue
Block a user