mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
fix(tui): keep worktree switching responsive
This commit is contained in:
@@ -205,7 +205,21 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::SwitchToWorktree { target } => {
|
||||
self.switch_to_worktree_target(tui, app_server, target)
|
||||
self.begin_switch_to_worktree_target(tui, target);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::SwitchToWorktreeAfterLoading { target } => {
|
||||
self.switch_to_worktree_target_after_loading(tui, app_server, target)
|
||||
.await;
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::WorktreeSessionReady {
|
||||
info,
|
||||
config,
|
||||
forked,
|
||||
result,
|
||||
} => {
|
||||
self.on_worktree_session_ready(tui, app_server, info, config, forked, result)
|
||||
.await;
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
//! App-layer handlers for the worktree TUI flow.
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_worktree::DirtyPolicy;
|
||||
use codex_worktree::WorktreeInfo;
|
||||
use codex_worktree::WorktreeListQuery;
|
||||
use codex_worktree::WorktreeRemoveRequest;
|
||||
use codex_worktree::WorktreeRequest;
|
||||
use codex_worktree::WorktreeSource;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
const WORKTREE_SWITCH_RENDER_DELAY: Duration = Duration::from_millis(20);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WorktreeSwitchMode {
|
||||
StartFresh,
|
||||
Fork(ThreadId),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WorktreeSessionTransition {
|
||||
Forked,
|
||||
Started,
|
||||
}
|
||||
|
||||
impl WorktreeSessionTransition {
|
||||
fn message_prefix(self) -> &'static str {
|
||||
match self {
|
||||
WorktreeSessionTransition::Forked => "Forked into",
|
||||
WorktreeSessionTransition::Started => "Started session in",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn open_worktree_picker(&mut self, tui: &mut tui::Tui) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
@@ -97,7 +123,7 @@ impl App {
|
||||
.branch
|
||||
.clone()
|
||||
.unwrap_or_else(|| resolution.info.name.clone());
|
||||
self.show_worktree_switching_view(tui, target).await;
|
||||
self.show_worktree_switching_view(tui, target);
|
||||
self.switch_to_worktree_info(tui, app_server, resolution.info)
|
||||
.await;
|
||||
for warning in warnings {
|
||||
@@ -105,7 +131,18 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn switch_to_worktree_target(
|
||||
pub(super) fn begin_switch_to_worktree_target(&mut self, tui: &mut tui::Tui, target: String) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.show_worktree_switching_view(tui, target.clone());
|
||||
self.defer_switch_to_worktree_target(target);
|
||||
}
|
||||
|
||||
pub(super) async fn switch_to_worktree_target_after_loading(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
@@ -117,7 +154,6 @@ impl App {
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.show_worktree_switching_view(tui, target.clone()).await;
|
||||
let entries = match self.list_current_repo_worktrees() {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
@@ -264,59 +300,142 @@ impl App {
|
||||
};
|
||||
self.apply_runtime_policy_overrides(&mut config);
|
||||
|
||||
if let Some(thread_id) = self.chat_widget.thread_id() {
|
||||
match app_server.fork_thread(config.clone(), thread_id).await {
|
||||
Ok(forked) => {
|
||||
self.shutdown_current_thread(app_server).await;
|
||||
self.install_worktree_config(tui, config);
|
||||
if let Err(err) = self
|
||||
.replace_chat_widget_with_app_server_thread(
|
||||
tui, app_server, forked, /*initial_user_message*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.show_worktree_error(
|
||||
"Failed to attach to worktree thread.".to_string(),
|
||||
err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let mode = self.worktree_switch_mode().await;
|
||||
self.spawn_worktree_session_request(app_server, info, config, mode);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
pub(super) async fn on_worktree_session_ready(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
info: WorktreeInfo,
|
||||
config: Config,
|
||||
forked: bool,
|
||||
result: Result<AppServerStartedThread, String>,
|
||||
) {
|
||||
match result {
|
||||
Ok(started) => {
|
||||
self.shutdown_current_thread(app_server).await;
|
||||
self.install_worktree_config(tui, config);
|
||||
if let Err(err) = self
|
||||
.replace_chat_widget_with_app_server_thread(
|
||||
tui, app_server, started, /*initial_user_message*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.show_worktree_error(
|
||||
"Failed to fork current session into worktree.".to_string(),
|
||||
"Failed to attach to worktree thread.".to_string(),
|
||||
err.to_string(),
|
||||
);
|
||||
} else {
|
||||
let transition = if forked {
|
||||
WorktreeSessionTransition::Forked
|
||||
} else {
|
||||
WorktreeSessionTransition::Started
|
||||
};
|
||||
self.add_worktree_session_message(&info, transition);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.shutdown_current_thread(app_server).await;
|
||||
self.install_worktree_config(tui, config.clone());
|
||||
match app_server.start_thread(&config).await {
|
||||
Ok(started) => {
|
||||
if let Err(err) = self
|
||||
.replace_chat_widget_with_app_server_thread(
|
||||
tui, app_server, started, /*initial_user_message*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.show_worktree_error(
|
||||
"Failed to attach to worktree thread.".to_string(),
|
||||
err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.show_worktree_error(
|
||||
"Failed to start session in worktree.".to_string(),
|
||||
err.to_string(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
let summary = if forked {
|
||||
"Failed to fork current session into worktree."
|
||||
} else {
|
||||
"Failed to start session in worktree."
|
||||
};
|
||||
self.show_worktree_error(summary.to_string(), err);
|
||||
}
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
async fn show_worktree_switching_view(&mut self, tui: &mut tui::Tui, target: String) {
|
||||
fn spawn_worktree_session_request(
|
||||
&self,
|
||||
app_server: &AppServerSession,
|
||||
info: WorktreeInfo,
|
||||
config: Config,
|
||||
mode: WorktreeSwitchMode,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf);
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let forked = matches!(mode, WorktreeSwitchMode::Fork(_));
|
||||
let result = match mode {
|
||||
WorktreeSwitchMode::Fork(thread_id) => {
|
||||
crate::app_server_session::fork_thread_with_request_handle(
|
||||
request_handle,
|
||||
config.clone(),
|
||||
thread_id,
|
||||
remote_cwd_override,
|
||||
)
|
||||
.await
|
||||
}
|
||||
WorktreeSwitchMode::StartFresh => {
|
||||
crate::app_server_session::start_thread_with_request_handle(
|
||||
request_handle,
|
||||
config.clone(),
|
||||
remote_cwd_override,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|err| err.to_string());
|
||||
app_event_tx.send(AppEvent::WorktreeSessionReady {
|
||||
info,
|
||||
config,
|
||||
forked,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn add_worktree_session_message(
|
||||
&mut self,
|
||||
info: &WorktreeInfo,
|
||||
transition: WorktreeSessionTransition,
|
||||
) {
|
||||
let (message, hint) = worktree_session_message(info, transition);
|
||||
self.chat_widget.add_info_message(message, Some(hint));
|
||||
}
|
||||
|
||||
async fn worktree_switch_mode(&self) -> WorktreeSwitchMode {
|
||||
let Some(thread_id) = self.current_displayed_thread_id() else {
|
||||
return WorktreeSwitchMode::StartFresh;
|
||||
};
|
||||
|
||||
if self
|
||||
.session_for_thread(thread_id)
|
||||
.await
|
||||
.as_ref()
|
||||
.is_some_and(Self::session_has_materialized_rollout)
|
||||
{
|
||||
WorktreeSwitchMode::Fork(thread_id)
|
||||
} else {
|
||||
WorktreeSwitchMode::StartFresh
|
||||
}
|
||||
}
|
||||
|
||||
async fn session_for_thread(&self, thread_id: ThreadId) -> Option<ThreadSessionState> {
|
||||
if self.primary_thread_id == Some(thread_id)
|
||||
&& let Some(session) = self.primary_session_configured.clone()
|
||||
{
|
||||
return Some(session);
|
||||
}
|
||||
|
||||
let channel = self.thread_event_channels.get(&thread_id)?;
|
||||
let store = channel.store.lock().await;
|
||||
store.session.clone()
|
||||
}
|
||||
|
||||
fn session_has_materialized_rollout(session: &ThreadSessionState) -> bool {
|
||||
session
|
||||
.rollout_path
|
||||
.as_ref()
|
||||
.is_some_and(|rollout_path| rollout_path.exists())
|
||||
}
|
||||
|
||||
fn show_worktree_switching_view(&mut self, tui: &mut tui::Tui, target: String) {
|
||||
self.chat_widget
|
||||
.show_selection_view(crate::worktree::switching_params(
|
||||
target,
|
||||
@@ -324,7 +443,14 @@ impl App {
|
||||
self.config.animations,
|
||||
));
|
||||
tui.frame_requester().schedule_frame();
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
fn defer_switch_to_worktree_target(&self, target: String) {
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(WORKTREE_SWITCH_RENDER_DELAY).await;
|
||||
app_event_tx.send(AppEvent::SwitchToWorktreeAfterLoading { target });
|
||||
});
|
||||
}
|
||||
|
||||
fn replace_worktree_view(&mut self, params: crate::bottom_pane::SelectionViewParams) -> bool {
|
||||
@@ -350,3 +476,210 @@ impl App {
|
||||
.update_search_dir(self.config.cwd.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
fn worktree_session_message(
|
||||
info: &WorktreeInfo,
|
||||
transition: WorktreeSessionTransition,
|
||||
) -> (String, String) {
|
||||
let worktree_name = info.branch.as_deref().unwrap_or(info.name.as_str());
|
||||
let state = if info.dirty.is_dirty() {
|
||||
"dirty"
|
||||
} else {
|
||||
"clean"
|
||||
};
|
||||
let source = crate::worktree::source_label(info.source);
|
||||
(
|
||||
format!(
|
||||
"{} {source} worktree {worktree_name} · {state} · {}",
|
||||
transition.message_prefix(),
|
||||
info.repo_name
|
||||
),
|
||||
info.workspace_cwd.display().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_worktree::DirtyState;
|
||||
use codex_worktree::WorktreeLocation;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktree_switch_mode_starts_fresh_without_current_thread() {
|
||||
let app = crate::app::test_support::make_test_app().await;
|
||||
|
||||
assert_eq!(
|
||||
app.worktree_switch_mode().await,
|
||||
WorktreeSwitchMode::StartFresh
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktree_switch_mode_starts_fresh_for_unmaterialized_primary_rollout() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let thread_id = ThreadId::new();
|
||||
let missing_rollout_path = temp_dir.path().join("missing-rollout.jsonl");
|
||||
let session = test_thread_session(
|
||||
thread_id,
|
||||
temp_dir.path().join("repo"),
|
||||
missing_rollout_path,
|
||||
);
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
app.primary_thread_id = Some(thread_id);
|
||||
app.active_thread_id = Some(thread_id);
|
||||
app.primary_session_configured = Some(session);
|
||||
|
||||
assert_eq!(
|
||||
app.worktree_switch_mode().await,
|
||||
WorktreeSwitchMode::StartFresh
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktree_switch_mode_forks_materialized_primary_rollout() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let thread_id = ThreadId::new();
|
||||
let rollout_path = temp_dir.path().join("rollout.jsonl");
|
||||
std::fs::write(&rollout_path, "{}\\n").expect("write rollout");
|
||||
let session = test_thread_session(thread_id, temp_dir.path().join("repo"), rollout_path);
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
app.primary_thread_id = Some(thread_id);
|
||||
app.active_thread_id = Some(thread_id);
|
||||
app.primary_session_configured = Some(session);
|
||||
|
||||
assert_eq!(
|
||||
app.worktree_switch_mode().await,
|
||||
WorktreeSwitchMode::Fork(thread_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktree_switch_mode_uses_active_non_primary_thread_session() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let primary_thread_id = ThreadId::new();
|
||||
let active_thread_id = ThreadId::new();
|
||||
let active_rollout_path = temp_dir.path().join("active-rollout.jsonl");
|
||||
std::fs::write(&active_rollout_path, "{}\\n").expect("write rollout");
|
||||
let active_session = test_thread_session(
|
||||
active_thread_id,
|
||||
temp_dir.path().join("active"),
|
||||
active_rollout_path,
|
||||
);
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
app.primary_thread_id = Some(primary_thread_id);
|
||||
app.active_thread_id = Some(active_thread_id);
|
||||
app.primary_session_configured = Some(test_thread_session(
|
||||
primary_thread_id,
|
||||
temp_dir.path().join("primary"),
|
||||
temp_dir.path().join("missing-primary-rollout.jsonl"),
|
||||
));
|
||||
app.thread_event_channels.insert(
|
||||
active_thread_id,
|
||||
ThreadEventChannel::new_with_session(
|
||||
THREAD_EVENT_CHANNEL_CAPACITY,
|
||||
active_session,
|
||||
Vec::new(),
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
app.worktree_switch_mode().await,
|
||||
WorktreeSwitchMode::Fork(active_thread_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_session_message_describes_forked_workspace() {
|
||||
let info = test_worktree_info(
|
||||
WorktreeSource::Cli,
|
||||
Some("fcoury/demo".to_string()),
|
||||
/*dirty*/ false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
worktree_session_message(&info, WorktreeSessionTransition::Forked),
|
||||
(
|
||||
"Forked into cli worktree fcoury/demo · clean · codex".to_string(),
|
||||
"/repo/codex.fcoury-demo".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_session_message_describes_started_dirty_workspace() {
|
||||
let info = test_worktree_info(
|
||||
WorktreeSource::App,
|
||||
/*branch*/ None,
|
||||
/*dirty*/ true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
worktree_session_message(&info, WorktreeSessionTransition::Started),
|
||||
(
|
||||
"Started session in app worktree app-worktree · dirty · codex".to_string(),
|
||||
"/repo/codex.fcoury-demo".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn test_thread_session(
|
||||
thread_id: ThreadId,
|
||||
cwd: PathBuf,
|
||||
rollout_path: PathBuf,
|
||||
) -> ThreadSessionState {
|
||||
ThreadSessionState {
|
||||
thread_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: AbsolutePathBuf::try_from(cwd).expect("absolute cwd"),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: None,
|
||||
message_history: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_path),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_worktree_info(
|
||||
source: WorktreeSource,
|
||||
branch: Option<String>,
|
||||
dirty: bool,
|
||||
) -> WorktreeInfo {
|
||||
let path = PathBuf::from("/repo/codex.fcoury-demo");
|
||||
WorktreeInfo {
|
||||
id: "repo-id".to_string(),
|
||||
name: "app-worktree".to_string(),
|
||||
slug: "fcoury-demo".to_string(),
|
||||
source,
|
||||
location: WorktreeLocation::Sibling,
|
||||
repo_name: "codex".to_string(),
|
||||
repo_root: path.clone(),
|
||||
common_git_dir: PathBuf::from("/repo/codex/.git"),
|
||||
worktree_git_root: path.clone(),
|
||||
workspace_cwd: path,
|
||||
original_relative_cwd: PathBuf::new(),
|
||||
branch,
|
||||
head: Some("abcdef".to_string()),
|
||||
owner_thread_id: None,
|
||||
metadata_path: PathBuf::from("/repo/codex/.git/codex-worktree.json"),
|
||||
dirty: DirtyState {
|
||||
has_staged_changes: false,
|
||||
has_unstaged_changes: dirty,
|
||||
has_untracked_files: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,12 @@ use codex_utils_approval_presets::ApprovalPreset;
|
||||
use codex_worktree::DirtyPolicy;
|
||||
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_server_session::AppServerStartedThread;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::StatusLineItem;
|
||||
use crate::bottom_pane::TerminalTitleItem;
|
||||
use crate::chatwidget::UserMessage;
|
||||
use crate::legacy_core::config::Config;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_features::Feature;
|
||||
@@ -213,6 +215,19 @@ pub(crate) enum AppEvent {
|
||||
target: String,
|
||||
},
|
||||
|
||||
/// Continue switching into an existing worktree after the loading view has rendered.
|
||||
SwitchToWorktreeAfterLoading {
|
||||
target: String,
|
||||
},
|
||||
|
||||
/// Result of starting or forking a session in a worktree.
|
||||
WorktreeSessionReady {
|
||||
info: codex_worktree::WorktreeInfo,
|
||||
config: Config,
|
||||
forked: bool,
|
||||
result: Result<AppServerStartedThread, String>,
|
||||
},
|
||||
|
||||
/// Show the filesystem path for an existing worktree.
|
||||
ShowWorktreePath {
|
||||
target: String,
|
||||
|
||||
@@ -119,6 +119,7 @@ use color_eyre::eyre::WrapErr;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
|
||||
color_eyre::eyre::eyre!("{context}: {err}")
|
||||
@@ -166,6 +167,7 @@ impl ThreadParamsMode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AppServerStartedThread {
|
||||
pub(crate) session: ThreadSessionState,
|
||||
pub(crate) turns: Vec<Turn>,
|
||||
@@ -969,6 +971,101 @@ impl AppServerSession {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn start_thread_with_request_handle(
|
||||
request_handle: AppServerRequestHandle,
|
||||
config: Config,
|
||||
remote_cwd_override: Option<PathBuf>,
|
||||
) -> Result<AppServerStartedThread> {
|
||||
let thread_params_mode = thread_params_mode_from_request_handle(&request_handle);
|
||||
let response: ThreadStartResponse = request_handle
|
||||
.request_typed(ClientRequest::ThreadStart {
|
||||
request_id: worktree_request_id("worktree-thread-start"),
|
||||
params: thread_start_params_from_config(
|
||||
&config,
|
||||
thread_params_mode,
|
||||
remote_cwd_override.as_deref(),
|
||||
/*session_start_source*/ None,
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|err| bootstrap_request_error("thread/start failed during TUI bootstrap", err))?;
|
||||
started_thread_from_start_response(response, &config, thread_params_mode).await
|
||||
}
|
||||
|
||||
pub(crate) async fn fork_thread_with_request_handle(
|
||||
request_handle: AppServerRequestHandle,
|
||||
config: Config,
|
||||
thread_id: ThreadId,
|
||||
remote_cwd_override: Option<PathBuf>,
|
||||
) -> Result<AppServerStartedThread> {
|
||||
let thread_params_mode = thread_params_mode_from_request_handle(&request_handle);
|
||||
let response: ThreadForkResponse = request_handle
|
||||
.request_typed(ClientRequest::ThreadFork {
|
||||
request_id: worktree_request_id("worktree-thread-fork"),
|
||||
params: thread_fork_params_from_config(
|
||||
config.clone(),
|
||||
thread_id,
|
||||
thread_params_mode,
|
||||
remote_cwd_override.as_deref(),
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|err| bootstrap_request_error("thread/fork failed during TUI bootstrap", err))?;
|
||||
let fork_parent_title = fork_parent_title_from_request_handle(
|
||||
&request_handle,
|
||||
response.thread.forked_from_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let mut started =
|
||||
started_thread_from_fork_response(response, &config, thread_params_mode).await?;
|
||||
started.session.fork_parent_title = fork_parent_title;
|
||||
Ok(started)
|
||||
}
|
||||
|
||||
fn worktree_request_id(prefix: &str) -> RequestId {
|
||||
RequestId::String(format!("{prefix}-{}", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
fn thread_params_mode_from_request_handle(
|
||||
request_handle: &AppServerRequestHandle,
|
||||
) -> ThreadParamsMode {
|
||||
match request_handle {
|
||||
AppServerRequestHandle::InProcess(_) => ThreadParamsMode::Embedded,
|
||||
AppServerRequestHandle::Remote(_) => ThreadParamsMode::Remote,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fork_parent_title_from_request_handle(
|
||||
request_handle: &AppServerRequestHandle,
|
||||
forked_from_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let forked_from_id = forked_from_id?;
|
||||
let forked_from_id = match ThreadId::from_string(forked_from_id) {
|
||||
Ok(thread_id) => thread_id,
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to parse fork parent thread id from app server: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match request_handle
|
||||
.request_typed::<ThreadReadResponse>(ClientRequest::ThreadRead {
|
||||
request_id: worktree_request_id("worktree-thread-read"),
|
||||
params: ThreadReadParams {
|
||||
thread_id: forked_from_id.to_string(),
|
||||
include_turns: false,
|
||||
},
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(thread) => thread.thread.name,
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to read fork parent metadata from app server: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_realtime_start_params(
|
||||
thread_id: ThreadId,
|
||||
transport: Option<ThreadRealtimeStartTransport>,
|
||||
|
||||
@@ -6,19 +6,14 @@
|
||||
use super::*;
|
||||
use crate::bottom_pane::status_line_from_segments;
|
||||
use crate::branch_summary;
|
||||
use crate::motion::ACTIVITY_SPINNER_INTERVAL;
|
||||
use crate::motion::activity_spinner_frame_at;
|
||||
use crate::status::format_tokens_compact;
|
||||
|
||||
/// Items shown in the terminal title when the user has not configured a
|
||||
/// custom selection. Intentionally minimal: activity indicator + project name.
|
||||
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["activity", "project-name"];
|
||||
|
||||
/// Braille-pattern dot-spinner frames for the terminal title animation.
|
||||
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
|
||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
/// Time between spinner frame advances in the terminal title.
|
||||
pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Time between action-required blink phases in the terminal title.
|
||||
const TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
@@ -362,7 +357,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
self.should_animate_terminal_title_spinner_with_selections(selections)
|
||||
.then_some(TERMINAL_TITLE_SPINNER_INTERVAL)
|
||||
.then_some(ACTIVITY_SPINNER_INTERVAL)
|
||||
}
|
||||
|
||||
pub(super) fn request_status_line_branch_refresh(&mut self) {
|
||||
@@ -816,14 +811,7 @@ impl ChatWidget {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(self.terminal_title_spinner_frame_at(now).to_string())
|
||||
}
|
||||
|
||||
fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str {
|
||||
let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin);
|
||||
let frame_index =
|
||||
(elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize;
|
||||
TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()]
|
||||
Some(activity_spinner_frame_at(self.terminal_title_animation_origin, now).to_string())
|
||||
}
|
||||
|
||||
fn terminal_title_uses_activity(&self) -> bool {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Callers choose an explicit reduced-motion fallback here instead of reaching
|
||||
//! directly for time-varying spinner or shimmer helpers.
|
||||
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::style::Stylize;
|
||||
@@ -10,6 +11,10 @@ use ratatui::text::Span;
|
||||
|
||||
use crate::shimmer::shimmer_spans;
|
||||
|
||||
const ACTIVITY_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
pub(crate) const ACTIVITY_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum MotionMode {
|
||||
Animated,
|
||||
@@ -59,6 +64,12 @@ pub(crate) fn shimmer_text(text: &str, motion_mode: MotionMode) -> Vec<Span<'sta
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn activity_spinner_frame_at(origin: Instant, now: Instant) -> &'static str {
|
||||
let elapsed = now.saturating_duration_since(origin);
|
||||
let frame_index = (elapsed.as_millis() / ACTIVITY_SPINNER_INTERVAL.as_millis()) as usize;
|
||||
ACTIVITY_SPINNER_FRAMES[frame_index % ACTIVITY_SPINNER_FRAMES.len()]
|
||||
}
|
||||
|
||||
fn animated_activity_indicator(start_time: Option<Instant>) -> Span<'static> {
|
||||
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
|
||||
if supports_color::on_cached(supports_color::Stream::Stdout)
|
||||
@@ -117,6 +128,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_spinner_frame_advances_and_wraps() {
|
||||
let origin = Instant::now();
|
||||
|
||||
assert_eq!(activity_spinner_frame_at(origin, origin), "⠋");
|
||||
assert_eq!(
|
||||
activity_spinner_frame_at(origin, origin + ACTIVITY_SPINNER_INTERVAL),
|
||||
"⠙"
|
||||
);
|
||||
assert_eq!(
|
||||
activity_spinner_frame_at(
|
||||
origin,
|
||||
origin + ACTIVITY_SPINNER_INTERVAL * ACTIVITY_SPINNER_FRAMES.len() as u32,
|
||||
),
|
||||
"⠋"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_primitives_are_only_used_by_motion_module() {
|
||||
let direct_spinner = regex_lite::Regex::new(r"(^|[^A-Za-z0-9_])spinner\s*\(").unwrap();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_worktree::DirtyPolicy;
|
||||
@@ -19,9 +18,8 @@ use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionRowDisplay;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::motion::MotionMode;
|
||||
use crate::motion::ReducedMotionIndicator;
|
||||
use crate::motion::activity_indicator;
|
||||
use crate::motion::ACTIVITY_SPINNER_INTERVAL;
|
||||
use crate::motion::activity_spinner_frame_at;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::tui::FrameRequester;
|
||||
@@ -29,7 +27,6 @@ use crate::tui::FrameRequester;
|
||||
const WORKTREE_USAGE: &str =
|
||||
"Usage: /worktree [list|new <branch>|switch <branch>|path <branch>|remove <branch>]";
|
||||
pub(crate) const WORKTREE_SELECTION_VIEW_ID: &str = "worktree-selection";
|
||||
const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
struct WorktreeLoadingHeader {
|
||||
started_at: Instant,
|
||||
@@ -62,19 +59,17 @@ impl Renderable for WorktreeLoadingHeader {
|
||||
return;
|
||||
}
|
||||
|
||||
let motion_mode = MotionMode::from_animations_enabled(self.animations_enabled);
|
||||
if self.animations_enabled {
|
||||
self.frame_requester
|
||||
.schedule_frame_in(LOADING_ANIMATION_INTERVAL);
|
||||
.schedule_frame_in(ACTIVITY_SPINNER_INTERVAL);
|
||||
}
|
||||
|
||||
let mut loading_spans = Vec::new();
|
||||
if let Some(indicator) = activity_indicator(
|
||||
Some(self.started_at),
|
||||
motion_mode,
|
||||
ReducedMotionIndicator::StaticBullet,
|
||||
) {
|
||||
loading_spans.push(indicator);
|
||||
if self.animations_enabled {
|
||||
loading_spans.push(activity_spinner_frame_at(self.started_at, Instant::now()).into());
|
||||
loading_spans.push(" ".into());
|
||||
} else {
|
||||
loading_spans.push("•".dim());
|
||||
loading_spans.push(" ".into());
|
||||
}
|
||||
loading_spans.push(self.status.clone().dim());
|
||||
|
||||
Reference in New Issue
Block a user