From a52a808de172cb76296f18caeac30e624d43fe71 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 27 Jul 2026 19:25:14 -0700 Subject: [PATCH] feat(cli): add session worktree toggle --- src/apps/cli/README.md | 8 + src/apps/cli/src/actions.rs | 26 +++ src/apps/cli/src/agent/runtime_client.rs | 256 +++++++++++++++++++---- src/apps/cli/src/chat_state.rs | 181 ++++++++++++++++ src/apps/cli/src/modes/chat.rs | 1 + src/apps/cli/src/modes/chat/commands.rs | 6 + src/apps/cli/src/modes/chat/run.rs | 28 ++- src/apps/cli/src/modes/chat/sessions.rs | 29 ++- src/apps/cli/src/modes/chat/worktree.rs | 212 +++++++++++++++++++ src/apps/cli/src/ui/chat/render.rs | 3 + src/apps/cli/src/ui/command_palette.rs | 2 + src/apps/cli/src/ui/startup.rs | 1 + 12 files changed, 696 insertions(+), 57 deletions(-) create mode 100644 src/apps/cli/src/modes/chat/worktree.rs diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 12ab842c1b..9d36ac15a1 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -80,6 +80,14 @@ only when the current invocation may approve tool requests. Non-interactive `exe `AskUserQuestion`; provide all required input in the initial prompt. The hidden legacy `--confirm` flag maps to the safe default and should not be used in new automation. +The interactive TUI supports per-session worktree isolation through `/worktree`. Run the command +without arguments to toggle it, or use `/worktree on`, `/worktree off`, and `/worktree status`. +The header shows the active branch and `Worktree: on|off`; detached managed worktrees use their base +commit as the branch label. The existing session lifecycle owner creates, persists, restores, and +releases the managed worktree. Isolation can only be changed before the session's first message, and +a released worktree with local or unpublished work is retained with its path reported in the chat. +This command is TUI-only and does not change the non-interactive `exec` contract. + ### Structured output | Format | stdout contract | diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index df9ca0cc50..fd4fb8246b 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -87,6 +87,7 @@ pub(crate) enum ActionHandler { History, Usage, ToggleAutoApprove, + ToggleWorktree, Exit, Login, Logout, @@ -546,6 +547,21 @@ static ACTION_SPECS: &[ActionSpec] = &[ shortcut_label: None, slash_on_startup: false, }, + ActionSpec { + id: "toggle_worktree", + name: "Worktree", + aliases: &["/worktree"], + description: "Toggle worktree isolation for the current session", + contexts: CHAT, + availability: ActionAvailability::Idle, + handler: ActionHandler::ToggleWorktree, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Session", false), + shortcut_label: None, + slash_on_startup: false, + }, ActionSpec { id: "exit", name: "Exit the app", @@ -1876,6 +1892,16 @@ mod tests { assert!(!action.available(ActionState::chat(true, false))); } + #[test] + fn worktree_is_chat_only_and_idle_only() { + assert!(action_for_alias("/worktree", ActionContext::Startup).is_none()); + + let action = action_for_alias("/worktree", ActionContext::Chat).unwrap(); + assert_eq!(action.handler, ActionHandler::ToggleWorktree); + assert!(action.available(ActionState::chat(false, false))); + assert!(!action.available(ActionState::chat(true, false))); + } + #[test] fn extension_management_uses_capability_entries_instead_of_external_commands() { let tools = action_for_alias("/tools", ActionContext::Chat).unwrap(); diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 97f9a32031..00d82a1fed 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -27,7 +27,10 @@ use bitfun_agent_runtime_ipc::{ RuntimeIpcStreamInvalidationReason, RuntimeSessionRestoreRequest, RuntimeUserAnswersRequest, }; use bitfun_events::{AgenticEvent, AgenticEventEnvelope}; -use bitfun_runtime_ports::{AgentSessionSummary, AgentSubmissionSource, DialogSubmissionPolicy}; +use bitfun_runtime_ports::{ + AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, + AgentSubmissionSource, DialogSubmissionPolicy, SessionExecutionTarget, +}; use crate::actions::SHARED_TUI_EMBEDDED_HANDOFF; use crate::runtime::approval::CliApprovalPolicy; @@ -113,12 +116,67 @@ fn session_mode_migration_notice( }) } +#[derive(Clone, Debug)] +struct CliWorkspacePaths { + project: Option, + execution: Option, + execution_target: Option, +} + +impl CliWorkspacePaths { + fn new(workspace_path: Option) -> Self { + Self { + project: workspace_path.clone(), + execution: workspace_path, + execution_target: None, + } + } + + fn execution(&self) -> PathBuf { + self.execution + .clone() + .or_else(|| self.project.clone()) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")) + } + + fn project(&self) -> PathBuf { + self.project + .clone() + .or_else(|| self.execution.clone()) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")) + } + + fn apply_binding(&mut self, binding: &AgentSessionWorkspaceBinding) { + let execution = PathBuf::from(&binding.workspace_path); + let project = binding + .project_workspace_path + .as_deref() + .filter(|path| !path.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| self.project()); + self.execution = Some(execution); + self.project = Some(project); + self.execution_target = binding.execution_target.clone(); + } + + fn reset_execution_to_project(&mut self) -> PathBuf { + let project = self.project(); + self.execution = Some(project.clone()); + self.execution_target = Some(SessionExecutionTarget::local( + project.to_string_lossy().to_string(), + )); + project + } +} + /// CLI-owned client for the portable Agent Runtime SDK. /// Stateless regarding agent_type; callers pass it per call. pub(crate) struct CliAgentRuntimeClient { backend: CliAgentRuntimeBackend, approval_policy: Arc>, - workspace_path: Arc>>, + workspace_paths: Arc>, /// Session ID — uses Mutex for interior mutability session_id: Arc>>, /// Current turn ID (for cancellation) @@ -141,7 +199,7 @@ impl CliAgentRuntimeClient { Self { backend: CliAgentRuntimeBackend::Embedded(runtime.agent_runtime().clone()), approval_policy: Arc::new(RwLock::new(runtime.approval_policy())), - workspace_path: Arc::new(RwLock::new(workspace_path)), + workspace_paths: Arc::new(RwLock::new(CliWorkspacePaths::new(workspace_path))), session_id: Arc::new(Mutex::new(None)), current_turn_id: Arc::new(Mutex::new(None)), shared_agent_events: None, @@ -168,7 +226,7 @@ impl CliAgentRuntimeClient { Self { backend: CliAgentRuntimeBackend::Shared(client), approval_policy: Arc::new(RwLock::new(CliApprovalPolicy::Ask)), - workspace_path: Arc::new(RwLock::new(workspace_path)), + workspace_paths: Arc::new(RwLock::new(CliWorkspacePaths::new(workspace_path))), session_id, current_turn_id: Arc::new(Mutex::new(None)), shared_agent_events: Some(shared_agent_events), @@ -278,20 +336,53 @@ impl CliAgentRuntimeClient { } pub(crate) fn workspace_path_buf(&self) -> PathBuf { - self.workspace_path + self.workspace_paths .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone() - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from(".")) + .execution() } pub(crate) fn workspace_path_string(&self) -> String { self.workspace_path_buf().to_string_lossy().to_string() } + pub(crate) fn project_workspace_path_buf(&self) -> PathBuf { + self.workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .project() + } + + pub(crate) fn project_workspace_path_string(&self) -> String { + self.project_workspace_path_buf() + .to_string_lossy() + .to_string() + } + + pub(crate) fn set_workspace_binding(&self, binding: &AgentSessionWorkspaceBinding) { + self.workspace_paths + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .apply_binding(binding); + } + + fn execution_target(&self) -> Option { + self.workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .execution_target + .clone() + } + + fn reset_execution_to_project_workspace(&self) -> PathBuf { + self.workspace_paths + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .reset_execution_to_project() + } + fn current_workspace_path(&self) -> PathBuf { - self.workspace_path_buf() + self.project_workspace_path_buf() } async fn list_sessions_in_workspace( @@ -328,24 +419,22 @@ impl CliAgentRuntimeClient { session_id: &str, ) -> Result<( AgentSessionSummary, - PathBuf, + AgentSessionWorkspaceBinding, Option, SessionTranscript, )> { tracing::info!("Restoring session: {}", session_id); - let effective_workspace = self.current_workspace_path(); - let sessions = self - .list_sessions_in_workspace(&effective_workspace) - .await?; + let project_workspace = self.current_workspace_path(); + let sessions = self.list_sessions_in_workspace(&project_workspace).await?; let previous_summary = - validated_session_summary(&sessions, session_id, &effective_workspace)?; + validated_session_summary(&sessions, session_id, &project_workspace)?; let (restored, transcript, shared_pending) = match &self.backend { CliAgentRuntimeBackend::Embedded(runtime) => { let restored = runtime .restore_session(AgentSessionRestoreRequest { - workspace_path: effective_workspace.to_string_lossy().to_string(), + workspace_path: project_workspace.to_string_lossy().to_string(), session_id: session_id.to_string(), include_internal: false, remote_connection_id: None, @@ -375,7 +464,7 @@ impl CliAgentRuntimeClient { CliAgentRuntimeBackend::Shared(client) => match client .request(RuntimeIpcOperation::RestoreSession { request: RuntimeSessionRestoreRequest { - workspace_path: effective_workspace.to_string_lossy().to_string(), + workspace_path: project_workspace.to_string_lossy().to_string(), session_id: session_id.to_string(), }, }) @@ -391,16 +480,13 @@ impl CliAgentRuntimeClient { }, }; + let binding = self + .resolve_session_workspace_binding(session_id, &project_workspace) + .await?; let mut session_id_guard = self.session_id.lock().await; let mut turn_id_guard = self.current_turn_id.lock().await; - let mut workspace_guard = self - .workspace_path - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *workspace_guard = Some(effective_workspace.clone()); *session_id_guard = Some(session_id.to_string()); *turn_id_guard = None; - drop(workspace_guard); drop(session_id_guard); drop(turn_id_guard); if let Some(requests) = shared_pending { @@ -417,13 +503,50 @@ impl CliAgentRuntimeClient { } let migration_notice = session_mode_migration_notice(&previous_summary, &restored); - Ok((restored, effective_workspace, migration_notice, transcript)) + Ok((restored, binding, migration_notice, transcript)) + } + + async fn resolve_session_workspace_binding( + &self, + session_id: &str, + fallback_project_workspace: &Path, + ) -> Result { + let fallback_project = fallback_project_workspace.to_string_lossy().to_string(); + let resolved = match &self.backend { + CliAgentRuntimeBackend::Embedded(runtime) => runtime + .resolve_session_workspace_binding(AgentSessionWorkspaceRequest { + session_id: session_id.to_string(), + }) + .await + .map_err(|error| anyhow::anyhow!(error.into_message()))?, + CliAgentRuntimeBackend::Shared(_) => None, + }; + let binding = resolved.unwrap_or_else(|| AgentSessionWorkspaceBinding { + workspace_id: None, + workspace_path: fallback_project.clone(), + project_workspace_path: Some(fallback_project.clone()), + execution_target: Some(SessionExecutionTarget::local(fallback_project)), + remote_connection_id: None, + remote_ssh_host: None, + }); + + self.set_workspace_binding(&binding); + Ok(binding) + } + + pub(crate) async fn session_workspace_binding( + &self, + session_id: &str, + ) -> Result { + let project_workspace = self.project_workspace_path_buf(); + self.resolve_session_workspace_binding(session_id, &project_workspace) + .await } pub(crate) async fn delete_session(&self, session_id: &str) -> Result<()> { self.embedded_runtime("deleting sessions")? .delete_session(AgentSessionDeleteRequest { - workspace_path: self.workspace_path_string(), + workspace_path: self.project_workspace_path_string(), session_id: session_id.to_string(), remote_connection_id: None, remote_ssh_host: None, @@ -462,7 +585,7 @@ impl CliAgentRuntimeClient { ) -> Result { self.embedded_runtime("forking sessions")? .fork_session(AgentSessionForkRequest { - workspace_path: self.workspace_path_string(), + workspace_path: self.project_workspace_path_string(), source_session_id: source_session_id.to_string(), remote_connection_id: None, remote_ssh_host: None, @@ -524,9 +647,10 @@ impl CliAgentRuntimeClient { let mut effective_agent_type = agent_type.to_string(); let workspace = self.workspace_path_buf(); + let project_workspace = self.project_workspace_path_buf(); if let Ok(sessions) = runtime .list_sessions(AgentSessionListRequest { - workspace_path: workspace.to_string_lossy().to_string(), + workspace_path: project_workspace.to_string_lossy().to_string(), remote_connection_id: None, remote_ssh_host: None, }) @@ -544,9 +668,9 @@ impl CliAgentRuntimeClient { AgentSessionCreateRequest { session_name, agent_type: effective_agent_type, - workspace_path: Some(self.workspace_path_string()), - project_workspace_path: None, - execution_target: None, + workspace_path: Some(workspace.to_string_lossy().to_string()), + project_workspace_path: Some(project_workspace.to_string_lossy().to_string()), + execution_target: self.execution_target(), workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -563,10 +687,10 @@ impl CliAgentRuntimeClient { async fn ensure_backend_session_alive(&self, session_id: &str, agent_type: &str) -> Result<()> { let runtime = self.embedded_runtime("recovering Embedded sessions")?; - let workspace = self.workspace_path_buf(); + let project_workspace = self.project_workspace_path_buf(); match runtime .restore_session(AgentSessionRestoreRequest { - workspace_path: workspace.to_string_lossy().to_string(), + workspace_path: project_workspace.to_string_lossy().to_string(), session_id: session_id.to_string(), include_internal: false, remote_connection_id: None, @@ -575,6 +699,8 @@ impl CliAgentRuntimeClient { .await { Ok(_) => { + self.resolve_session_workspace_binding(session_id, &project_workspace) + .await?; tracing::info!("Backend session restored: {}", session_id); Ok(()) } @@ -601,6 +727,8 @@ impl CliAgentRuntimeClient { ) -> Result { let runtime = self.embedded_runtime("creating sessions with fixed identifiers")?; let mut session_id_guard = self.session_id.lock().await; + let workspace_path = self.workspace_path_string(); + let project_workspace_path = self.project_workspace_path_string(); let session = runtime .create_session_with_id( @@ -608,9 +736,9 @@ impl CliAgentRuntimeClient { AgentSessionCreateRequest { session_name: Self::build_default_session_name(), agent_type: agent_type.to_string(), - workspace_path: Some(self.workspace_path_string()), - project_workspace_path: None, - execution_target: None, + workspace_path: Some(workspace_path), + project_workspace_path: Some(project_workspace_path), + execution_target: self.execution_target(), workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -790,12 +918,14 @@ impl CliAgentRuntimeClient { } pub(crate) async fn create_new_session(&self, agent_type: &str) -> Result { + let project_workspace = self.reset_execution_to_project_workspace(); + let project_workspace_path = project_workspace.to_string_lossy().to_string(); let request = AgentSessionCreateRequest { session_name: Self::build_default_session_name(), agent_type: agent_type.to_string(), - workspace_path: Some(self.workspace_path_string()), - project_workspace_path: None, - execution_target: None, + workspace_path: Some(project_workspace_path.clone()), + project_workspace_path: Some(project_workspace_path.clone()), + execution_target: Some(SessionExecutionTarget::local(project_workspace_path)), workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -1058,7 +1188,10 @@ mod recovery_tests { mod tests { use std::path::Path; - use bitfun_runtime_ports::AgentSessionSummary; + use bitfun_runtime_ports::{ + AgentSessionSummary, AgentSessionWorkspaceBinding, SessionExecutionTarget, + SessionExecutionTargetKind, WorktreeLifecycle, + }; use bitfun_agent_runtime::sdk::{ PermissionDelegationContext, PermissionRequest, PermissionRequestEvent, @@ -1072,6 +1205,7 @@ mod tests { use super::{ cli_approval_metadata, project_routed_permission_event, session_mode_migration_notice, shared_disconnect_message, shared_restore_error, validated_session_summary, + CliWorkspacePaths, }; use bitfun_agent_runtime_ipc::RuntimeIpcStreamInvalidationReason; @@ -1094,6 +1228,50 @@ mod tests { assert!(message.contains("default Embedded `bitfun chat`")); } + #[test] + fn workspace_paths_keep_project_and_execution_roots_separate() { + let mut paths = CliWorkspacePaths::new(Some("/project".into())); + let binding = AgentSessionWorkspaceBinding { + workspace_id: Some("workspace-1".to_string()), + workspace_path: "/managed-worktree".to_string(), + project_workspace_path: Some("/project".to_string()), + execution_target: Some(SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("worktree-1".to_string()), + root_path: "/managed-worktree".to_string(), + base_ref: Some("main".to_string()), + base_commit: Some("123456789abcdef".to_string()), + branch: None, + lifecycle: Some(WorktreeLifecycle::Managed), + }), + remote_connection_id: None, + remote_ssh_host: None, + }; + + paths.apply_binding(&binding); + + assert_eq!(paths.project(), Path::new("/project")); + assert_eq!(paths.execution(), Path::new("/managed-worktree")); + assert_eq!( + paths + .execution_target + .as_ref() + .and_then(|target| target.worktree_id.as_deref()), + Some("worktree-1") + ); + + assert_eq!( + paths.reset_execution_to_project(), + Path::new("/project").to_path_buf() + ); + assert_eq!(paths.execution(), Path::new("/project")); + assert!(paths + .execution_target + .as_ref() + .and_then(|target| target.worktree_id.as_ref()) + .is_none()); + } + #[test] fn cli_approval_metadata_keeps_auto_invocation_scoped() { let auto = cli_approval_metadata(CliApprovalPolicy::Auto); diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index a5c3f2eebb..7766143323 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -12,6 +12,7 @@ use bitfun_agent_runtime::sdk::{ }; use bitfun_agent_tools::effective_tool_invocation; use bitfun_events::ToolEventData; +use bitfun_runtime_ports::{AgentSessionWorkspaceBinding, SessionExecutionTarget}; use crate::ui::permission::PermissionPrompt; use crate::ui::question::QuestionPrompt; @@ -307,6 +308,13 @@ pub(crate) struct ChatState { pub agent_type: String, /// Workspace path pub workspace: Option, + /// Persisted session workspace binding, including managed worktree facts. + pub workspace_binding: Option, + /// Lightweight Git facts for the active execution workspace. + git_branch: Option, + is_git_repository: bool, + /// Whether this runtime exposes session worktree lifecycle controls. + worktree_control_available: bool, /// Current model display name (shown in shortcuts bar) pub current_model_name: String, /// Effective Auto mode for permission results that evaluate to Ask. @@ -347,11 +355,26 @@ impl ChatState { agent_type: String, workspace: Option, ) -> Self { + let workspace_binding = + workspace + .as_ref() + .map(|workspace_path| AgentSessionWorkspaceBinding { + workspace_id: None, + workspace_path: workspace_path.clone(), + project_workspace_path: Some(workspace_path.clone()), + execution_target: Some(SessionExecutionTarget::local(workspace_path.clone())), + remote_connection_id: None, + remote_ssh_host: None, + }); Self { core_session_id, session_name, agent_type, workspace, + workspace_binding, + git_branch: None, + is_git_repository: false, + worktree_control_available: true, current_model_name: String::new(), auto_approve_ask: false, messages: Vec::new(), @@ -366,6 +389,95 @@ impl ChatState { } } + pub(crate) fn apply_workspace_binding(&mut self, binding: AgentSessionWorkspaceBinding) { + self.workspace = Some(binding.workspace_path.clone()); + self.workspace_binding = Some(binding); + } + + pub(crate) fn project_workspace_path(&self) -> Option<&str> { + self.workspace_binding + .as_ref() + .and_then(|binding| binding.project_workspace_path.as_deref()) + .filter(|path| !path.trim().is_empty()) + .or(self.workspace.as_deref()) + } + + pub(crate) fn set_git_repository_status( + &mut self, + is_repository: bool, + branch: Option, + ) { + self.is_git_repository = is_repository; + self.git_branch = branch.filter(|value| !value.trim().is_empty()); + } + + pub(crate) fn is_worktree_enabled(&self) -> bool { + self.workspace_binding + .as_ref() + .and_then(|binding| binding.execution_target.as_ref()) + .and_then(|target| target.worktree_id.as_ref()) + .is_some() + } + + pub(crate) fn set_worktree_control_available(&mut self, available: bool) { + self.worktree_control_available = available; + } + + pub(crate) fn branch_label(&self) -> String { + let execution_target = self + .workspace_binding + .as_ref() + .and_then(|binding| binding.execution_target.as_ref()); + if let Some(branch) = execution_target + .and_then(|target| target.branch.as_deref()) + .map(str::trim) + .filter(|branch| !branch.is_empty()) + { + return branch.to_string(); + } + + let current_branch = self.git_branch.as_deref().map(str::trim); + if let Some(branch) = current_branch.filter(|branch| *branch != "HEAD") { + return branch.to_string(); + } + + if self.is_worktree_enabled() { + if let Some(commit) = execution_target + .and_then(|target| target.base_commit.as_deref()) + .map(str::trim) + .filter(|commit| !commit.is_empty()) + { + return format!("detached@{}", commit.chars().take(9).collect::()); + } + } + + if self.is_git_repository && current_branch == Some("HEAD") { + "detached".to_string() + } else { + "—".to_string() + } + } + + pub(crate) fn worktree_status_label(&self) -> &'static str { + if !self.worktree_control_available { + "unavailable" + } else if self.is_worktree_enabled() { + "on" + } else if self.is_git_repository { + "off" + } else { + "unavailable" + } + } + + pub(crate) fn workspace_context_label(&self) -> String { + format!( + "Branch: {} | Worktree: {}", + self.branch_label(), + self.worktree_status_label() + ) + } + pub(crate) fn enqueue_permission_request(&mut self, request: PermissionRequest) -> bool { let request_id = request.request_id.as_str(); if self @@ -1238,8 +1350,77 @@ mod tests { TranscriptToolCall, }; use bitfun_events::{ToolEventData, ToolEventIdentity}; + use bitfun_runtime_ports::{ + AgentSessionWorkspaceBinding, SessionExecutionTarget, SessionExecutionTargetKind, + WorktreeLifecycle, + }; use serde_json::json; + fn managed_worktree_binding( + branch: Option<&str>, + base_commit: Option<&str>, + ) -> AgentSessionWorkspaceBinding { + AgentSessionWorkspaceBinding { + workspace_id: Some("workspace-1".to_string()), + workspace_path: "/tmp/managed-worktree".to_string(), + project_workspace_path: Some("/tmp/project".to_string()), + execution_target: Some(SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("worktree-1".to_string()), + root_path: "/tmp/managed-worktree".to_string(), + base_ref: None, + base_commit: base_commit.map(str::to_string), + branch: branch.map(str::to_string), + lifecycle: Some(WorktreeLifecycle::Managed), + }), + remote_connection_id: None, + remote_ssh_host: None, + } + } + + #[test] + fn workspace_context_reports_local_repository_state() { + let mut state = ChatState::new( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("/tmp/project".to_string()), + ); + state.set_git_repository_status(true, Some("main".to_string())); + + assert!(!state.is_worktree_enabled()); + assert_eq!(state.branch_label(), "main"); + assert_eq!( + state.workspace_context_label(), + "Branch: main | Worktree: off" + ); + + state.set_worktree_control_available(false); + assert_eq!( + state.workspace_context_label(), + "Branch: main | Worktree: unavailable" + ); + } + + #[test] + fn workspace_context_prefers_managed_worktree_branch_or_detached_commit() { + let mut state = ChatState::new( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("/tmp/project".to_string()), + ); + state.apply_workspace_binding(managed_worktree_binding(Some("feature/test"), None)); + state.set_git_repository_status(true, Some("HEAD".to_string())); + + assert!(state.is_worktree_enabled()); + assert_eq!(state.branch_label(), "feature/test"); + assert_eq!(state.worktree_status_label(), "on"); + + state.apply_workspace_binding(managed_worktree_binding(None, Some("123456789abcdef"))); + assert_eq!(state.branch_label(), "detached@123456789"); + } + fn permission_request(request_id: &str, child_session_id: &str) -> PermissionRequest { PermissionRequest { request_id: request_id.to_string(), diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index a932139a4c..35ea12d9e7 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -298,6 +298,7 @@ include!("chat/account.rs"); include!("chat/run.rs"); include!("chat/input.rs"); include!("chat/commands.rs"); +include!("chat/worktree.rs"); include!("chat/selection.rs"); include!("chat/mcp.rs"); include!("chat/sessions.rs"); diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 8ac91f151c..60f05c759f 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -196,6 +196,9 @@ impl ChatMode { }; return self.handle_action_id(action_id, None, chat_view, chat_state, rt_handle); } + if command_name.eq_ignore_ascii_case("worktree") { + return self.handle_worktree_command(arguments, chat_view, chat_state, rt_handle); + } let builtin_alias = format!("/{command_name}"); let builtin_action = action_for_alias(&builtin_alias, ActionContext::Chat); if self.agent.is_shared() { @@ -717,6 +720,9 @@ impl ChatMode { } ActionHandler::Usage => self.show_usage_report(chat_view, chat_state, rt_handle), ActionHandler::ToggleAutoApprove => {} + ActionHandler::ToggleWorktree => { + return self.handle_worktree_command("", chat_view, chat_state, rt_handle); + } ActionHandler::Exit => { if chat_state.is_processing { self.cancel_active_turn(chat_view, rt_handle); diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 148ccbda0d..da60aa816b 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -53,18 +53,18 @@ impl ChatMode { tokio::task::block_in_place(|| { rt_handle.block_on(async { // Restore session in core (loads metadata, messages, managers) - let (summary, effective_workspace_path, migration_notice, transcript) = + let (summary, workspace_binding, migration_notice, transcript) = agent.restore_session_in_current_workspace(&rid).await?; - let effective_workspace = - Some(effective_workspace_path.to_string_lossy().to_string()); + let effective_workspace = Some(workspace_binding.workspace_path.clone()); - let state = ChatState::from_session_transcript( + let mut state = ChatState::from_session_transcript( rid.clone(), summary.session_name, summary.agent_type, effective_workspace, &transcript, ); + state.apply_workspace_binding(workspace_binding); tracing::info!( "Session restored: {}, {} messages loaded", @@ -77,19 +77,27 @@ impl ChatMode { })? } else { // Create new session - let session_id = tokio::task::block_in_place(|| { - rt_handle.block_on(self.agent.ensure_session(&self.agent_type)) + let agent = self.agent.clone(); + let agent_type = self.agent_type.clone(); + let (session_id, workspace_binding) = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let session_id = agent.ensure_session(&agent_type).await?; + let binding = agent.session_workspace_binding(&session_id).await?; + Ok::<_, anyhow::Error>((session_id, binding)) + }) })?; tracing::info!("Core session ready: {}", session_id); - let state = ChatState::new( + let mut state = ChatState::new( session_id.clone(), "CLI Session".to_string(), self.agent_type.clone(), - self.workspace.clone(), + Some(workspace_binding.workspace_path.clone()), ); + state.apply_workspace_binding(workspace_binding); (session_id, state, None) }; + chat_state.set_worktree_control_available(!self.agent.is_shared()); self.auto_approve_ask_override = None; self.agent .set_approval_policy(crate::runtime::approval::CliApprovalPolicy::Ask); @@ -98,6 +106,7 @@ impl ChatMode { // Keep ChatMode workspace in sync with the session's effective workspace self.agent_type = chat_state.agent_type.clone(); self.workspace = chat_state.workspace.clone(); + self.refresh_workspace_git_status(&mut chat_state, &rt_handle); let mut external_source_rx = None; if self.agent.is_shared() { @@ -627,6 +636,7 @@ impl ChatMode { } => { if chat_state.current_turn_id() == Some(turn_id.as_str()) { chat_state.handle_turn_completed(*total_rounds, *total_tools); + self.refresh_workspace_git_status(&mut chat_state, &rt_handle); chat_view.invalidate_lines_cache(); chat_view.set_status(None); needs_redraw = true; @@ -643,6 +653,7 @@ impl ChatMode { AgenticEvent::DialogTurnFailed { turn_id, error, .. } => { if chat_state.current_turn_id() == Some(turn_id.as_str()) { chat_state.handle_turn_failed(error); + self.refresh_workspace_git_status(&mut chat_state, &rt_handle); chat_view.invalidate_lines_cache(); chat_view.set_status(Some(format!("Error: {}", error))); needs_redraw = true; @@ -660,6 +671,7 @@ impl ChatMode { let active_turn_id = chat_state.current_turn_id(); if active_turn_id.is_none() || active_turn_id == Some(turn_id.as_str()) { chat_state.handle_turn_cancelled(); + self.refresh_workspace_git_status(&mut chat_state, &rt_handle); chat_view.invalidate_lines_cache(); chat_view.set_status(Some("Cancelled".to_string())); needs_redraw = true; diff --git a/src/apps/cli/src/modes/chat/sessions.rs b/src/apps/cli/src/modes/chat/sessions.rs index e0c920e861..f2d88dfa19 100644 --- a/src/apps/cli/src/modes/chat/sessions.rs +++ b/src/apps/cli/src/modes/chat/sessions.rs @@ -14,19 +14,19 @@ impl ChatMode { let (new_state, restored_agent_type, migration_notice) = tokio::task::block_in_place(|| { rt_handle.block_on(async { - let (session_summary, effective_workspace_path, migration_notice, transcript) = + let (session_summary, workspace_binding, migration_notice, transcript) = agent.restore_session_in_current_workspace(&sid).await?; let restored_agent_type = session_summary.agent_type.clone(); - let effective_workspace = - Some(effective_workspace_path.to_string_lossy().to_string()); + let effective_workspace = Some(workspace_binding.workspace_path.clone()); - let state = ChatState::from_session_transcript( + let mut state = ChatState::from_session_transcript( sid.clone(), session_summary.session_name, restored_agent_type.clone(), effective_workspace, &transcript, ); + state.apply_workspace_binding(workspace_binding); Ok::<_, anyhow::Error>((state, restored_agent_type, migration_notice)) }) @@ -35,8 +35,10 @@ impl ChatMode { // Update session state *session_id = new_session_id.to_string(); *chat_state = new_state; + chat_state.set_worktree_control_available(!self.agent.is_shared()); self.agent_type = restored_agent_type; self.workspace = chat_state.workspace.clone(); + self.refresh_workspace_git_status(chat_state, rt_handle); self.auto_approve_ask_override = None; chat_state.auto_approve_ask = self.auto_approve_ask_default; self.agent @@ -66,22 +68,28 @@ impl ChatMode { ) -> Result<()> { let agent = self.agent.clone(); let agent_type = self.agent_type.clone(); - let workspace = self.workspace.clone(); - let new_session_id = tokio::task::block_in_place(|| { - rt_handle.block_on(agent.create_new_session(&agent_type)) + let (new_session_id, workspace_binding) = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let session_id = agent.create_new_session(&agent_type).await?; + let binding = agent.session_workspace_binding(&session_id).await?; + Ok::<_, anyhow::Error>((session_id, binding)) + }) })?; - let new_state = ChatState::new( + let mut new_state = ChatState::new( new_session_id.clone(), "CLI Session".to_string(), agent_type, - workspace, + Some(workspace_binding.workspace_path.clone()), ); + new_state.apply_workspace_binding(workspace_binding); *session_id = new_session_id; *chat_state = new_state; + chat_state.set_worktree_control_available(!self.agent.is_shared()); self.workspace = chat_state.workspace.clone(); + self.refresh_workspace_git_status(chat_state, rt_handle); self.auto_approve_ask_override = None; chat_state.auto_approve_ask = self.auto_approve_ask_default; self.agent @@ -149,6 +157,7 @@ impl ChatMode { ) { let agent = self.agent.clone(); let current_session_id = chat_state.core_session_id.clone(); + let project_workspace = Some(agent.project_workspace_path_string()); let sessions = tokio::task::block_in_place(|| rt_handle.block_on(agent.list_sessions())); let sessions = match sessions { @@ -186,7 +195,7 @@ impl ChatMode { session_id: s.session_id, session_name: s.session_name, last_activity, - workspace: self.workspace.clone(), + workspace: project_workspace.clone(), } }) .collect(); diff --git a/src/apps/cli/src/modes/chat/worktree.rs b/src/apps/cli/src/modes/chat/worktree.rs new file mode 100644 index 0000000000..5fcb5604e5 --- /dev/null +++ b/src/apps/cli/src/modes/chat/worktree.rs @@ -0,0 +1,212 @@ +use bitfun_core::service::git::GitService; +use bitfun_core::service::worktree::{WorktreeService, WorktreeSessionBindingRequest}; +use bitfun_runtime_ports::AgentSessionWorkspaceBinding; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorktreeCommand { + Toggle, + Set(bool), + Status, +} + +fn parse_worktree_command(arguments: &str) -> std::result::Result { + match arguments.trim().to_ascii_lowercase().as_str() { + "" | "toggle" => Ok(WorktreeCommand::Toggle), + "on" | "enable" => Ok(WorktreeCommand::Set(true)), + "off" | "disable" => Ok(WorktreeCommand::Set(false)), + "status" => Ok(WorktreeCommand::Status), + _ => Err("Usage: /worktree [on|off|status|toggle]".to_string()), + } +} + +impl ChatMode { + fn refresh_workspace_git_status( + &self, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let Some(workspace_path) = chat_state.workspace.clone() else { + chat_state.set_git_repository_status(false, None); + return; + }; + + let repository = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let repository = GitService::resolve_worktree_repository(&workspace_path).await?; + GitService::get_repository_basic(repository.query_path).await + }) + }); + match repository { + Ok(repository) => { + chat_state.set_git_repository_status(true, Some(repository.current_branch)); + } + Err(error) => { + chat_state.set_git_repository_status(false, None); + tracing::debug!( + "Git repository status is unavailable for workspace {}: {}", + workspace_path, + error + ); + } + } + } + + fn worktree_status_message(chat_state: &ChatState) -> String { + let workspace = chat_state.workspace.as_deref().unwrap_or("unavailable"); + let project_workspace = chat_state.project_workspace_path().unwrap_or("unavailable"); + format!( + "Worktree: {}\nBranch: {}\nWorkspace: {}\nProject workspace: {}", + chat_state.worktree_status_label(), + chat_state.branch_label(), + workspace, + project_workspace + ) + } + + fn handle_worktree_command( + &mut self, + arguments: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + let action = action_by_id("toggle_worktree", ActionContext::Chat) + .expect("Worktree action must remain registered"); + let state = ActionState::chat(chat_state.is_processing, false); + if !action.available(state) { + chat_view.set_status(Some(action.unavailable_message(state))); + return Ok(None); + } + if self.agent.is_shared() { + let message = format!( + "Worktree isolation is unavailable in Shared TUI preview. {SHARED_TUI_EMBEDDED_HANDOFF}." + ); + chat_view.set_status(Some(message.clone())); + chat_state.add_system_message(message); + return Ok(None); + } + + let command = match parse_worktree_command(arguments) { + Ok(command) => command, + Err(usage) => { + chat_view.set_status(Some(usage.clone())); + chat_state.add_system_message(usage); + return Ok(None); + } + }; + + self.refresh_workspace_git_status(chat_state, rt_handle); + if command == WorktreeCommand::Status { + let message = Self::worktree_status_message(chat_state); + chat_view.set_status(Some(chat_state.workspace_context_label())); + chat_state.add_system_message(message); + return Ok(None); + } + + let enabled = match command { + WorktreeCommand::Toggle => !chat_state.is_worktree_enabled(), + WorktreeCommand::Set(enabled) => enabled, + WorktreeCommand::Status => unreachable!("status returned above"), + }; + chat_view.set_status(Some(if enabled { + "Enabling worktree isolation...".to_string() + } else { + "Disabling worktree isolation...".to_string() + })); + + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(WorktreeService::bind_session( + WorktreeSessionBindingRequest { + request_id: uuid::Uuid::new_v4().to_string(), + session_id: chat_state.core_session_id.clone(), + enabled, + }, + )) + }); + + let result = match result { + Ok(result) => result, + Err(error) => { + let message = format!( + "Worktree isolation could not be changed ({}): {}", + error.code.as_str(), + error.message + ); + tracing::warn!("{}", message); + chat_view.set_status(Some(message.clone())); + chat_state.add_system_message(message); + return Ok(None); + } + }; + + let previous_binding = chat_state.workspace_binding.as_ref(); + let binding = AgentSessionWorkspaceBinding { + workspace_id: result.workspace_id, + workspace_path: result.workspace_path, + project_workspace_path: Some(result.project_workspace_path), + execution_target: Some(result.execution_target), + remote_connection_id: previous_binding + .and_then(|binding| binding.remote_connection_id.clone()), + remote_ssh_host: previous_binding.and_then(|binding| binding.remote_ssh_host.clone()), + }; + self.agent.set_workspace_binding(&binding); + chat_state.apply_workspace_binding(binding); + self.workspace = chat_state.workspace.clone(); + self.refresh_workspace_git_status(chat_state, rt_handle); + + let status = if enabled { + "Worktree isolation enabled".to_string() + } else { + "Worktree isolation disabled".to_string() + }; + chat_view.set_status(Some(format!( + "{} ({})", + status, + chat_state.workspace_context_label() + ))); + chat_state.add_system_message(format!( + "{}.\n{}", + status, + Self::worktree_status_message(chat_state) + )); + if let Some(path) = result.retained_worktree_path { + chat_state.add_system_message(format!( + "The released worktree was kept because it may contain local or unpublished work: {}", + path + )); + } + + Ok(None) + } +} + +#[cfg(test)] +mod worktree_tests { + use super::{parse_worktree_command, WorktreeCommand}; + + #[test] + fn worktree_command_defaults_to_toggle() { + assert_eq!(parse_worktree_command("").unwrap(), WorktreeCommand::Toggle); + assert_eq!( + parse_worktree_command("toggle").unwrap(), + WorktreeCommand::Toggle + ); + } + + #[test] + fn worktree_command_accepts_explicit_states_and_status() { + assert_eq!( + parse_worktree_command("on").unwrap(), + WorktreeCommand::Set(true) + ); + assert_eq!( + parse_worktree_command("disable").unwrap(), + WorktreeCommand::Set(false) + ); + assert_eq!( + parse_worktree_command("STATUS").unwrap(), + WorktreeCommand::Status + ); + assert!(parse_worktree_command("create").is_err()); + } +} diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs index 0807c4eddd..c4768b2430 100644 --- a/src/apps/cli/src/ui/chat/render.rs +++ b/src/apps/cli/src/ui/chat/render.rs @@ -152,6 +152,7 @@ impl ChatView { .as_ref() .map(|w| format!("Workspace: {}", w)) .unwrap_or_else(|| "No workspace".to_string()); + let repository_context = chat_state.workspace_context_label(); let header = Block::default() .borders(Borders::ALL) @@ -167,6 +168,8 @@ impl ChatView { Span::raw(" "), Span::styled(&agent_info, self.theme.style(StyleKind::Primary)), Span::raw(" "), + Span::styled(repository_context, self.theme.style(StyleKind::Primary)), + Span::raw(" "), Span::styled(&workspace, self.theme.style(StyleKind::Muted)), ])]; diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index f7e5ae972d..8c1004324a 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -44,6 +44,7 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "sessions", "usage", "toggle_auto_approve", + "toggle_worktree", "skills", "select_model", "add_model", @@ -767,6 +768,7 @@ mod tests { assert!(ids.iter().any(|id| id == "switch_agent")); assert!(!ids.iter().any(|id| id == "new_session")); assert!(!ids.iter().any(|id| id == "toggle_auto_approve")); + assert!(!ids.iter().any(|id| id == "toggle_worktree")); assert!(ids.iter().any(|id| id == "help")); } diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 3038998cec..bc9e64686b 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1074,6 +1074,7 @@ impl StartupPage { | ActionHandler::ExternalHooks | ActionHandler::History | ActionHandler::ToggleAutoApprove + | ActionHandler::ToggleWorktree | ActionHandler::Interrupt | ActionHandler::ToggleFocusedTool | ActionHandler::PreviousTool