feat(tui/core): show Popular commands on start; track and clean worktrees

- Insert Popular commands prelude immediately to avoid waiting for MCP
- Avoid duplicate prelude when SessionConfigured arrives; show model change
- Set upstream for new worktree branches; include status in background message
- Improve  checkout flow and worktree hinting
- Record created worktrees in per-process session file
- Clean up session worktrees on TUI exit (post-restore)
This commit is contained in:
James Peter
2025-09-08 22:04:54 +10:00
parent ea70b8d795
commit 2908be4501
5 changed files with 276 additions and 129 deletions
+22
View File
@@ -1,6 +1,7 @@
use chrono::Utc;
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tokio::fs::OpenOptions;
/// Sanitize a string to be used as a single git refname component.
///
@@ -90,6 +91,7 @@ pub async fn setup_worktree(git_root: &Path, branch_id: &str) -> Result<(PathBuf
// If the worktree directory already exists, re-use it to avoid the cost
// of removing and re-adding a worktree. This makes repeated agent runs
// start much faster.
record_worktree_in_session(git_root, &worktree_path).await;
return Ok((worktree_path, effective_branch));
}
@@ -127,6 +129,7 @@ pub async fn setup_worktree(git_root: &Path, branch_id: &str) -> Result<(PathBuf
let retry_err = String::from_utf8_lossy(&retry.stderr);
return Err(format!("Failed to create worktree: {}", retry_err));
}
record_worktree_in_session(git_root, &worktree_path).await;
} else {
return Err(format!("Failed to create worktree: {}", stderr));
}
@@ -134,9 +137,28 @@ pub async fn setup_worktree(git_root: &Path, branch_id: &str) -> Result<(PathBuf
// Skip remote alias setup for speed; we don't need it during agent runs.
// Record created worktree for this process; best-effort.
record_worktree_in_session(git_root, &worktree_path).await;
Ok((worktree_path, effective_branch))
}
/// Append the created worktree to a per-process session file so the TUI can
/// clean it up on exit without touching worktrees from other processes.
async fn record_worktree_in_session(git_root: &Path, worktree_path: &Path) {
let pid = std::process::id();
let mut base = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
// Global session registry: ~/.code/working/_session
base = base.join(".code").join("working").join("_session");
if let Err(_e) = tokio::fs::create_dir_all(&base).await { return; }
let file = base.join(format!("pid-{}.txt", pid));
// Store git_root and worktree_path separated by a tab; one entry per line.
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&file).await {
let line = format!("{}\t{}\n", git_root.display(), worktree_path.display());
let _ = tokio::io::AsyncWriteExt::write_all(&mut f, line.as_bytes()).await;
}
}
/// Ensure a remote named `origin` exists. If it's missing, choose a likely
/// writable remote (prefer `fork`, then `upstream-push`, then the first push
/// URL we find) and alias it as `origin`. Finally, set the remote HEAD so
+1
View File
@@ -1285,6 +1285,7 @@ impl App<'_> {
AppState::Onboarding { .. } => {}
}
}
}
fn should_show_onboarding(
+141 -73
View File
@@ -1541,9 +1541,15 @@ impl ChatWidget<'_> {
scroll_history_hint_shown: false,
};
// Insert the welcome cell as top-of-first-request so future model output
// appears below it.
// appears below it. Also insert the Popular commands immediately so users
// don't wait for MCP initialization to finish.
let mut w = new_widget;
w.history_push_top_next_req(history_cell::new_animated_welcome()); // tag: prelude
let connecting_mcp = !w.config.mcp_servers.is_empty();
w.history_push_top_next_req(history_cell::new_popular_commands_notice(connecting_mcp)); // tag: prelude
// Mark welcome as shown to avoid duplicating the Popular commands section
// when SessionConfigured arrives shortly after.
w.welcome_shown = true;
w
}
@@ -2701,16 +2707,20 @@ impl ChatWidget<'_> {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
// Record session information at the top of the conversation.
// Only show welcome message on first SessionConfigured event
// If we already showed the startup prelude (Popular commands),
// avoid inserting a duplicate. Still surface a notice if the
// model actually changed from the requested one.
let is_first = !self.welcome_shown;
if is_first {
self.welcome_shown = true;
if is_first || self.config.model != event.model {
if is_first {
self.welcome_shown = true;
}
self.history_push_top_next_req(history_cell::new_session_info(
&self.config,
event,
is_first,
)); // tag: prelude
}
self.history_push_top_next_req(history_cell::new_session_info(
&self.config,
event,
is_first,
)); // tag: prelude
if let Some(user_message) = self.initial_user_message.take() {
// If the user provided an initial message, add it to the
@@ -7771,6 +7781,7 @@ impl ChatWidget<'_> {
self.request_redraw();
tokio::spawn(async move {
use tokio::process::Command;
// Resolve git root
let git_root = match codex_core::git_worktree::get_git_root_from(&cwd).await {
Ok(p) => p,
@@ -7838,21 +7849,67 @@ impl ChatWidget<'_> {
}
};
// Attempt to set upstream for the new branch to match the source branch's upstream,
// falling back to origin/<default> when available. Also ensure origin/HEAD is set.
let mut upstream_msg: Option<String> = None;
// Discover source branch upstream like 'origin/main'
let src_upstream = Command::new("git")
.current_dir(&git_root)
.args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
.output()
.await
.ok()
.filter(|o| o.status.success())
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
if s.is_empty() { None } else { Some(s) }
});
// Ensure origin/HEAD points at the remote default, if origin exists.
let _ = Command::new("git")
.current_dir(&git_root)
.args(["remote", "set-head", "origin", "-a"])
.output()
.await;
// Compute fallback remote default
let fallback_remote = codex_core::git_worktree::detect_default_branch(&git_root)
.await
.map(|d| format!("origin/{}", d));
let target_upstream = src_upstream.clone().or(fallback_remote);
if let Some(up) = target_upstream {
let set = Command::new("git")
.current_dir(&worktree)
.args(["branch", "--set-upstream-to", up.as_str(), used_branch.as_str()])
.output()
.await;
if let Ok(o) = set {
if o.status.success() {
upstream_msg = Some(format!("Set upstream for '{}' to {}", used_branch, up));
} else {
let e = String::from_utf8_lossy(&o.stderr).trim().to_string();
if !e.is_empty() {
upstream_msg = Some(format!("Upstream not set ({}).", e));
}
}
}
}
// Build clean multi-line output as a BackgroundEvent (not streaming Answer)
let msg = if let Some(task_text) = task_opt {
format!(
"• Created worktree '{used}'\n Path: {path}\n Copied {copied} changed files\n Task: {task}\n Switching and starting task...",
"• Created worktree '{used}'\n Path: {path}\n Copied {copied} changed files\n {up}\n Task: {task}\n Switching and starting task...",
used = used_branch,
path = worktree.display(),
copied = copied,
up = upstream_msg.clone().unwrap_or_else(|| "".to_string()),
task = task_text
)
} else {
format!(
"• Created worktree '{used}'\n Path: {path}\n Copied {copied} changed files\n Switched to branch. Type your task when ready.",
"• Created worktree '{used}'\n Path: {path}\n Copied {copied} changed files\n {up}\n Switched to branch. Type your task when ready.",
used = used_branch,
path = worktree.display(),
copied = copied
copied = copied,
up = upstream_msg.unwrap_or_else(|| "".to_string())
)
};
{
@@ -7887,7 +7944,6 @@ impl ChatWidget<'_> {
tokio::spawn(async move {
use tokio::process::Command;
use std::path::PathBuf;
// Resolve repo root and current branch name
let git_root = match codex_core::git_worktree::get_git_root_from(&work_cwd).await {
Ok(p) => p,
@@ -7989,97 +8045,98 @@ impl ChatWidget<'_> {
};
// Merge branch into default from the main repo root
// 1) If already on default branch in the main repo, skip checkout
let on_default_branch = {
let cur = Command::new("git")
.current_dir(&git_root)
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.await;
matches!(cur, Ok(ref o) if o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == default_branch)
// Skip checkout if already on the default branch
let on_default = match Command::new("git")
.current_dir(&git_root)
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.await
{
Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout).trim() == default_branch
}
_ => false,
};
if !on_default_branch {
// 2) Ensure local default branch exists; if missing, fetch and create a tracking branch
let has_local_default = {
let out = Command::new("git")
.current_dir(&git_root)
.args(["show-ref", "--verify", "--quiet", &format!("refs/heads/{}", default_branch)])
.output()
.await;
matches!(out, Ok(ref o) if o.status.success())
if !on_default {
// Ensure local default branch exists; if missing, try to fetch and create a tracking branch
let has_local = match Command::new("git")
.current_dir(&git_root)
.args([
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{}", default_branch),
])
.output()
.await
{
Ok(o) => o.status.success(),
_ => false,
};
if !has_local_default {
if !has_local {
let _ = Command::new("git")
.current_dir(&git_root)
.args(["fetch", "origin", &default_branch])
.output()
.await;
let track = Command::new("git")
let _ = Command::new("git")
.current_dir(&git_root)
.args(["branch", "--track", &default_branch, &format!("origin/{}", default_branch)])
.args([
"branch",
"--track",
&default_branch,
&format!("origin/{}", default_branch),
])
.output()
.await;
if !matches!(track, Ok(ref o) if o.status.success()) {
// Fallback: create from remote without --track (or if remote is missing)
let switch = Command::new("git")
.current_dir(&git_root)
.args(["switch", "-c", &default_branch, &format!("origin/{}", default_branch)])
.output()
.await;
if !matches!(switch, Ok(ref o) if o.status.success()) {
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
let detail = track
.ok()
.and_then(|o| String::from_utf8(o.stderr).ok())
.filter(|s| !s.trim().is_empty())
.or_else(|| switch.ok().and_then(|o| String::from_utf8(o.stderr).ok()))
.unwrap_or_else(|| "unknown error".to_string());
let _ = tx.send(AppEvent::CodexEvent(Event { id: uuid::Uuid::new_v4().to_string(), event_seq: 0, msg: EventMsg::BackgroundEvent(BackgroundEventEvent { message: format!("`/branch finalize` — default branch '{}' missing locally and could not be created from remote: {}", default_branch, detail.trim()) }), order: None }));
return;
}
}
}
// 3) Try to checkout default branch
// Try to checkout the default branch and surface errors
let co = Command::new("git")
.current_dir(&git_root)
.args(["checkout", &default_branch])
.output()
.await;
if !matches!(co, Ok(ref o) if o.status.success()) {
// Enhance message with stderr/stdout and a worktree hint if the branch
// is already checked out elsewhere.
let (stderr_s, stdout_s) = co
.ok()
.map(|o| (String::from_utf8_lossy(&o.stderr).to_string(), String::from_utf8_lossy(&o.stdout).to_string()))
.map(|o| {
(
String::from_utf8_lossy(&o.stderr).trim().to_string(),
String::from_utf8_lossy(&o.stdout).trim().to_string(),
)
})
.unwrap_or_else(|| (String::new(), String::new()));
// Attempt to detect where the default branch is checked out
let mut worktree_hint: Option<String> = None;
let wt = Command::new("git")
// If the branch is checked out in another worktree, hint its path
let mut hint: Option<String> = None;
if let Ok(wt) = Command::new("git")
.current_dir(&git_root)
.args(["worktree", "list", "--porcelain"])
.output()
.await;
if let Ok(o) = wt {
if o.status.success() {
let s = String::from_utf8_lossy(&o.stdout);
.await
{
if wt.status.success() {
let s = String::from_utf8_lossy(&wt.stdout);
let mut cur_path: Option<String> = None;
let mut cur_branch: Option<String> = None;
for line in s.lines() {
if let Some(rest) = line.strip_prefix("worktree ") {
cur_path = Some(rest.trim().to_string());
cur_branch = None;
} else if let Some(rest) = line.strip_prefix("branch ") {
continue;
}
if let Some(rest) = line.strip_prefix("branch ") {
cur_branch = Some(rest.trim().to_string());
}
if let (Some(p), Some(b)) = (&cur_path, &cur_branch) {
if b.trim() == format!("refs/heads/{}", default_branch) && PathBuf::from(p) != git_root {
worktree_hint = Some(p.clone());
break;
if b == &format!("refs/heads/{}", default_branch) {
// Avoid hinting the main repo root
if std::path::Path::new(p) != git_root.as_path() {
hint = Some(p.clone());
break;
}
}
}
}
@@ -8089,10 +8146,21 @@ impl ChatWidget<'_> {
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
let mut msg = format!("`/branch finalize` — failed to checkout default branch '{}'", default_branch);
let detail = if !stderr_s.trim().is_empty() { stderr_s.trim().to_string() } else { stdout_s.trim().to_string() };
if !detail.is_empty() { msg = format!("{}: {}", msg, detail); }
if let Some(path) = worktree_hint { msg = format!("{} (checked out in worktree: {})", msg, path); }
let mut msg = format!(
"`/branch finalize` — failed to checkout default branch '{}'",
default_branch
);
let detail = if !stderr_s.is_empty() {
stderr_s
} else {
stdout_s
};
if !detail.is_empty() {
msg = format!("{}: {}", msg, detail);
}
if let Some(p) = hint {
msg = format!("{} (checked out in worktree: {})", msg, p);
}
let _ = tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
+76 -56
View File
@@ -4283,62 +4283,10 @@ pub(crate) fn new_session_info(
} = event;
if is_first_event {
let lines: Vec<Line<'static>> = vec![
Line::from("notice".dim()),
Line::styled(
"Popular commands:",
Style::default().fg(crate::colors::text_bright()),
),
Line::from(vec![
Span::styled("/chrome", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Chrome.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]),
Line::from(vec![
Span::styled(
"/browser <url>",
Style::default().fg(crate::colors::primary()),
),
Span::from(" - "),
Span::from(SlashCommand::Browser.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]),
Line::from(vec![
Span::styled("/plan", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Plan.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]),
Line::from(vec![
Span::styled("/solve", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Solve.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]),
Line::from(vec![
Span::styled("/code", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Code.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]),
Line::from(vec![
Span::styled("/reasoning", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Reasoning.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]),
Line::from(vec![
Span::styled("/resume", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Resume.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]),
];
PlainHistoryCell {
lines,
kind: HistoryCellType::Notice,
}
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("notice".dim()));
lines.extend(popular_commands_lines());
PlainHistoryCell { lines, kind: HistoryCellType::Notice }
} else if config.model == model {
PlainHistoryCell {
lines: Vec::new(),
@@ -4360,6 +4308,78 @@ pub(crate) fn new_session_info(
}
}
/// Build the common lines for the "Popular commands" section (without the leading
/// "notice" marker). Shared between the initial session info and the startup prelude.
fn popular_commands_lines() -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::styled(
"Popular commands:",
Style::default().fg(crate::colors::text_bright()),
));
lines.push(Line::from(vec![
Span::styled("/chrome", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Chrome.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled(
"/browser <url>",
Style::default().fg(crate::colors::primary()),
),
Span::from(" - "),
Span::from(SlashCommand::Browser.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled("/plan", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Plan.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled("/solve", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Solve.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled("/code", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Code.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled("/reasoning", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Reasoning.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled("/resume", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Resume.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines
}
/// Create a notice cell that shows the "Popular commands" immediately.
/// If `connecting_mcp` is true, include a dim status line to inform users
/// that external MCP servers are being connected in the background.
pub(crate) fn new_popular_commands_notice(connecting_mcp: bool) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("notice".dim()));
lines.extend(popular_commands_lines());
if connecting_mcp {
lines.push(Line::from(Span::styled(
"Connecting MCP servers…",
Style::default().fg(crate::colors::text_dim()),
)));
}
PlainHistoryCell { lines, kind: HistoryCellType::Notice }
}
pub(crate) fn new_user_prompt(message: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("user"));
+36
View File
@@ -368,6 +368,9 @@ fn run_ratatui_app(
let timing_summary = app.perf_summary();
restore();
// After restoring the terminal, clean up any worktrees created by this process.
cleanup_session_worktrees_and_print();
// Mark the end of the recorded session.
session_log::log_session_end();
if let Some(summary) = timing_summary {
@@ -395,6 +398,39 @@ fn print_timing_summary(summary: &str) {
eprintln!("\n== Timing Summary ==\n{}", summary);
}
#[allow(clippy::print_stdout, clippy::print_stderr)]
fn cleanup_session_worktrees_and_print() {
use std::process::Command;
let pid = std::process::id();
let home = match std::env::var_os("HOME") { Some(h) => std::path::PathBuf::from(h), None => return };
let session_dir = home.join(".code").join("working").join("_session");
let file = session_dir.join(format!("pid-{}.txt", pid));
let Ok(data) = std::fs::read_to_string(&file) else { return };
let mut entries: Vec<(std::path::PathBuf, std::path::PathBuf)> = Vec::new();
for line in data.lines() {
if line.trim().is_empty() { continue; }
if let Some((root_s, path_s)) = line.split_once('\t') {
entries.push((std::path::PathBuf::from(root_s), std::path::PathBuf::from(path_s)));
}
}
// Deduplicate paths in case of retries
use std::collections::HashSet;
let mut seen = HashSet::new();
entries.retain(|(_, p)| seen.insert(p.clone()));
if entries.is_empty() { let _ = std::fs::remove_file(&file); return; }
eprintln!("Cleaning remaining worktrees ({}).", entries.len());
for (git_root, worktree) in entries {
let wt_str = match worktree.to_str() { Some(s) => s, None => continue };
let _ = Command::new("git")
.current_dir(&git_root)
.args(["worktree", "remove", wt_str, "--force"])
.output();
let _ = std::fs::remove_dir_all(&worktree);
}
let _ = std::fs::remove_file(&file);
}
/// Minimal login status indicator for onboarding flow.
#[derive(Debug, Clone, Copy)]
pub enum LoginStatus {