Merge remote-tracking branch 'origin/pr/1065' into work/v0.8.34

# Conflicts:
#	crates/tui/src/commands/mod.rs
#	crates/tui/src/tui/app.rs
#	crates/tui/src/tui/ui.rs
This commit is contained in:
Hunter Bown
2026-05-13 00:06:56 -05:00
8 changed files with 346 additions and 27 deletions
+102
View File
@@ -1,6 +1,7 @@
//! Core commands: help, clear, exit, model
use std::fmt::Write;
use std::path::PathBuf;
use crate::config::{COMMON_DEEPSEEK_MODELS, normalize_model_name};
use crate::localization::{MessageId, tr};
@@ -182,6 +183,50 @@ pub fn profile_switch(_app: &mut App, arg: Option<&str>) -> CommandResult {
)
}
pub fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult {
let Some(raw_path) = arg.map(str::trim).filter(|path| !path.is_empty()) else {
return CommandResult::message(format!("Current workspace: {}", app.workspace.display()));
};
let expanded = match expand_workspace_path(raw_path) {
Ok(path) => path,
Err(message) => return CommandResult::error(message),
};
let candidate = if expanded.is_absolute() {
expanded
} else {
app.workspace.join(expanded)
};
if !candidate.exists() {
return CommandResult::error(format!("Workspace does not exist: {}", candidate.display()));
}
if !candidate.is_dir() {
return CommandResult::error(format!(
"Workspace is not a directory: {}",
candidate.display()
));
}
let workspace = candidate.canonicalize().unwrap_or(candidate);
CommandResult::with_message_and_action(
format!("Switching workspace to {}...", workspace.display()),
AppAction::SwitchWorkspace { workspace },
)
}
fn expand_workspace_path(path: &str) -> Result<PathBuf, String> {
if path == "~" {
return dirs::home_dir().ok_or_else(|| "Could not resolve home directory".to_string());
}
if let Some(rest) = path.strip_prefix("~/") {
let home =
dirs::home_dir().ok_or_else(|| "Could not resolve home directory".to_string())?;
return Ok(home.join(rest));
}
Ok(PathBuf::from(path))
}
/// Show `DeepSeek` dashboard and docs links
pub fn deepseek_links(app: &mut App) -> CommandResult {
let locale = app.ui_locale;
@@ -331,6 +376,7 @@ mod tests {
use crate::tui::history::HistoryCell;
use std::path::PathBuf;
use std::time::Instant;
use tempfile::tempdir;
fn create_test_app() -> App {
let options = TuiOptions {
@@ -506,6 +552,62 @@ mod tests {
assert!(matches!(result.action, Some(AppAction::Quit)));
}
#[test]
fn workspace_without_arg_shows_current_workspace() {
let mut app = create_test_app();
let result = workspace_switch(&mut app, None);
let msg = result.message.expect("workspace should be shown");
assert!(msg.contains("Current workspace:"));
assert!(msg.contains("/tmp/test-workspace"));
assert!(result.action.is_none());
}
#[test]
fn workspace_existing_absolute_dir_returns_switch_action() {
let mut app = create_test_app();
let dir = tempdir().expect("temp dir");
let result = workspace_switch(&mut app, Some(dir.path().to_str().unwrap()));
assert!(matches!(
result.action,
Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap()
));
}
#[test]
fn workspace_relative_dir_resolves_from_current_workspace() {
let root = tempdir().expect("temp dir");
let child = root.path().join("child");
std::fs::create_dir(&child).expect("child dir");
let mut app = create_test_app();
app.workspace = root.path().to_path_buf();
let result = workspace_switch(&mut app, Some("child"));
assert!(matches!(
result.action,
Some(AppAction::SwitchWorkspace { workspace }) if workspace == child.canonicalize().unwrap()
));
}
#[test]
fn workspace_rejects_missing_path() {
let mut app = create_test_app();
let result = workspace_switch(&mut app, Some("definitely-missing"));
assert!(result.is_error);
assert!(result.message.unwrap().contains("does not exist"));
}
#[test]
fn workspace_rejects_file_path() {
let root = tempdir().expect("temp dir");
let file = root.path().join("file.txt");
std::fs::write(&file, "not a directory").expect("test file");
let mut app = create_test_app();
let result = workspace_switch(&mut app, Some(file.to_str().unwrap()));
assert!(result.is_error);
assert!(result.message.unwrap().contains("not a directory"));
}
#[test]
fn test_model_change_updates_state() {
let mut app = create_test_app();
+19
View File
@@ -230,6 +230,12 @@ pub const COMMANDS: &[CommandInfo] = &[
usage: "/home",
description_id: MessageId::CmdHomeDescription,
},
CommandInfo {
name: "workspace",
aliases: &["cwd"],
usage: "/workspace [path]",
description_id: MessageId::CmdWorkspaceDescription,
},
CommandInfo {
name: "note",
aliases: &[],
@@ -552,6 +558,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"links" | "dashboard" | "api" | "lianjie" => core::deepseek_links(app),
"feedback" => feedback::feedback(app, arg),
"home" | "stats" | "overview" | "zhuye" | "shouye" => core::home_dashboard(app),
"workspace" | "cwd" => core::workspace_switch(app, arg),
"note" => note::note(app, arg),
"memory" => memory::memory(app, arg),
"attach" | "image" | "media" | "fujian" => attachment::attach(app, arg),
@@ -1057,6 +1064,7 @@ mod tests {
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::MutexGuard;
use tempfile::tempdir;
fn create_test_app() -> App {
let options = TuiOptions {
@@ -1291,6 +1299,17 @@ mod tests {
}
}
#[test]
fn execute_workspace_alias_switches_workspace() {
let dir = tempdir().expect("temp dir");
let mut app = create_test_app();
let result = execute(&format!("/cwd {}", dir.path().display()), &mut app);
assert!(matches!(
result.action,
Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap()
));
}
#[test]
fn removed_set_and_deepseek_commands_show_migration_hints() {
let mut app = create_test_app();
+7
View File
@@ -313,6 +313,7 @@ pub enum MessageId {
CmdTrustDescription,
CmdLspDescription,
CmdShareDescription,
CmdWorkspaceDescription,
CmdUndoDescription,
CmdVerboseDescription,
CmdCacheAdvice,
@@ -543,6 +544,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdTrustDescription,
MessageId::CmdLspDescription,
MessageId::CmdShareDescription,
MessageId::CmdWorkspaceDescription,
MessageId::CmdUndoDescription,
MessageId::CmdVerboseDescription,
MessageId::CmdCacheAdvice,
@@ -995,6 +997,7 @@ fn english(id: MessageId) -> &'static str {
MessageId::CmdTrustDescription => {
"Manage workspace trust and per-path allowlist (`/trust add <path>`, `/trust list`, `/trust on|off`)"
}
MessageId::CmdWorkspaceDescription => "Show or switch the current workspace",
MessageId::CmdUndoDescription => "Remove last message pair",
MessageId::CmdVerboseDescription => "Toggle full live thinking in the transcript",
MessageId::CmdCacheAdvice => {
@@ -1375,6 +1378,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::CmdTrustDescription => {
"ワークスペースの信頼設定とパス別許可リストを管理(`/trust add <path>`、`/trust list`、`/trust on|off`"
}
MessageId::CmdWorkspaceDescription => "現在のワークスペースを表示または切り替え",
MessageId::CmdUndoDescription => "最後のメッセージ対を削除",
MessageId::CmdVerboseDescription => "ライブ思考表示の詳細モードを切り替え",
MessageId::CmdCacheAdvice => {
@@ -1708,6 +1712,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::CmdTrustDescription => {
"管理工作区信任与按路径的白名单(`/trust add <path>`、`/trust list`、`/trust on|off`"
}
MessageId::CmdWorkspaceDescription => "显示或切换当前工作空间",
MessageId::CmdUndoDescription => "移除最后一组消息对",
MessageId::CmdVerboseDescription => "切换实时思考内容的完整显示",
MessageId::CmdCacheAdvice => {
@@ -2049,6 +2054,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::CmdTrustDescription => {
"Gerenciar a confiança do workspace e a allowlist por caminho (`/trust add <path>`, `/trust list`, `/trust on|off`)"
}
MessageId::CmdWorkspaceDescription => "Mostrar ou trocar o workspace atual",
MessageId::CmdUndoDescription => "Remover o último par de mensagens",
MessageId::CmdVerboseDescription => "Alternar pensamento ao vivo completo no transcript",
MessageId::CmdCacheAdvice => {
@@ -2434,6 +2440,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
MessageId::CmdTrustDescription => {
"Gestionar la confianza del workspace y la lista de paths permitidos (`/trust add <ruta>`, `/trust list`, `/trust on|off`)"
}
MessageId::CmdWorkspaceDescription => "Mostrar o cambiar el workspace actual",
MessageId::CmdUndoDescription => "Eliminar el último par de mensajes",
MessageId::CmdVerboseDescription => {
"Alternar pensamiento en vivo completo en la transcripción"
+34 -3
View File
@@ -701,6 +701,7 @@ pub type SharedTaskManager = Arc<TaskManager>;
pub struct TaskManager {
cfg: TaskManagerConfig,
default_workspace: Mutex<PathBuf>,
executor: Arc<dyn TaskExecutor>,
tasks_dir: PathBuf,
artifacts_dir: PathBuf,
@@ -766,8 +767,10 @@ impl TaskManager {
let (tasks, queue) = load_state(&tasks_dir, &queue_path)?;
let cancel_token = CancellationToken::new();
let default_workspace = cfg.default_workspace.clone();
let manager = Arc::new(Self {
cfg,
default_workspace: Mutex::new(default_workspace),
executor,
tasks_dir,
artifacts_dir,
@@ -810,6 +813,15 @@ impl TaskManager {
self.cancel_token.is_cancelled()
}
pub async fn set_default_workspace(&self, workspace: PathBuf) {
let mut default_workspace = self.default_workspace.lock().await;
*default_workspace = workspace;
}
pub async fn default_workspace(&self) -> PathBuf {
self.default_workspace.lock().await.clone()
}
/// Enqueue a new task.
pub async fn add_task(&self, req: NewTaskRequest) -> Result<TaskRecord> {
let prompt = req.prompt.trim().to_string();
@@ -822,9 +834,10 @@ impl TaskManager {
id: format!("task_{}", &Uuid::new_v4().to_string()[..8]),
prompt,
model: req.model.unwrap_or_else(|| self.cfg.default_model.clone()),
workspace: req
.workspace
.unwrap_or_else(|| self.cfg.default_workspace.clone()),
workspace: match req.workspace {
Some(workspace) => workspace,
None => self.default_workspace().await,
},
mode: req.mode.unwrap_or_else(|| self.cfg.default_mode.clone()),
allow_shell: req.allow_shell.unwrap_or(self.cfg.allow_shell),
trust_mode: req.trust_mode.unwrap_or(self.cfg.trust_mode),
@@ -1765,6 +1778,24 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn default_workspace_updates_for_future_tasks() -> Result<()> {
let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4()));
let new_workspace =
std::env::temp_dir().join(format!("deepseek-workspace-{}", Uuid::new_v4()));
let manager =
TaskManager::start_with_executor(test_config(root), Arc::new(MockExecutor)).await?;
manager.set_default_workspace(new_workspace.clone()).await;
let task = manager
.add_task(NewTaskRequest::from_prompt("test workspace default"))
.await?;
assert_eq!(manager.default_workspace().await, new_workspace);
assert_eq!(task.workspace, new_workspace);
Ok(())
}
#[tokio::test]
async fn record_tool_metadata_updates_explicit_task() -> Result<()> {
let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4()));
+6 -1
View File
@@ -12,7 +12,7 @@ use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -603,6 +603,11 @@ impl ShellManager {
self.sandbox_manager.is_available()
}
#[allow(dead_code)]
pub fn default_workspace(&self) -> &Path {
&self.default_workspace
}
/// Execute a shell command with the configured sandbox policy.
#[allow(dead_code)]
pub fn execute(
+30 -15
View File
@@ -57,6 +57,31 @@ pub enum OnboardingState {
None,
}
pub(crate) fn resolve_skills_dir(
workspace: &Path,
global_skills_dir: &Path,
config: &Config,
) -> PathBuf {
let agents_skills_dir = workspace.join(".agents").join("skills");
if agents_skills_dir.exists() {
return agents_skills_dir;
}
let local_skills_dir = workspace.join("skills");
if local_skills_dir.exists() {
return local_skills_dir;
}
if config.skills_dir.is_none()
&& let Some(global_agents) = crate::skills::agents_global_skills_dir()
&& global_agents.exists()
{
return global_agents;
}
global_skills_dir.to_path_buf()
}
fn initial_onboarding_state(
skip_onboarding: bool,
was_onboarded: bool,
@@ -1361,21 +1386,7 @@ impl App {
// Initialize plan state
let plan_state = new_shared_plan_state();
let agents_skills_dir = workspace.join(".agents").join("skills");
let local_skills_dir = workspace.join("skills");
let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
let skills_dir = if agents_skills_dir.exists() {
agents_skills_dir
} else if local_skills_dir.exists() {
local_skills_dir
} else if config.skills_dir.is_none()
&& let Some(global_agents) = agents_global_skills_dir
&& global_agents.exists()
{
global_agents
} else {
global_skills_dir
};
let skills_dir = resolve_skills_dir(&workspace, &global_skills_dir, config);
let cached_skills = Self::discover_cached_skills(&workspace);
let input_history = crate::composer_history::load_history();
@@ -4062,6 +4073,10 @@ pub enum AppAction {
/// Profile name to load.
profile: String,
},
/// Switch the workspace used by tools, hooks, tasks, and session metadata.
SwitchWorkspace {
workspace: PathBuf,
},
/// Export and share the current session as a web URL.
ShareSession {
history_len: usize,
+75 -5
View File
@@ -44,7 +44,7 @@ use crate::core::coherence::CoherenceState;
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
use crate::core::events::Event as EngineEvent;
use crate::core::ops::Op;
use crate::hooks::HookEvent;
use crate::hooks::{HookEvent, HookExecutor};
use crate::llm_client::LlmClient;
use crate::models::{
ContentBlock, Message, MessageRequest, SystemPrompt, Usage, context_window_for_model,
@@ -345,7 +345,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
match load_result {
Ok(Some(saved)) => {
let recovered = apply_loaded_session(&mut app, &saved);
let recovered = apply_loaded_session(&mut app, config, &saved);
if !recovered {
app.status_message = Some(format!(
"Resumed session: {}",
@@ -5462,6 +5462,9 @@ async fn apply_command_result(
AppAction::Mcp(action) => {
handle_mcp_ui_action(app, config, action).await;
}
AppAction::SwitchWorkspace { workspace } => {
switch_workspace(app, engine_handle, task_manager, config, workspace).await;
}
AppAction::SwitchProfile { profile } => {
app.config_profile = Some(profile.clone());
match Config::load(app.config_path.clone(), Some(&profile)) {
@@ -5567,6 +5570,72 @@ fn open_external_url(url: &str) -> Result<()> {
Ok(())
}
fn apply_workspace_runtime_state(app: &mut App, config: &Config, workspace: PathBuf) {
app.workspace = workspace.clone();
app.hooks = HookExecutor::new(config.hooks_config(), workspace.clone());
app.skills_dir = crate::tui::app::resolve_skills_dir(&workspace, &config.skills_dir(), config);
app.refresh_skill_cache();
app.workspace_context = None;
if let Ok(mut cell) = app.workspace_context_cell.lock() {
*cell = None;
}
app.workspace_context_refreshed_at = None;
app.file_tree = None;
let shell_manager = crate::tools::shell::new_shared_shell_manager(workspace);
app.runtime_services.shell_manager = Some(shell_manager);
app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
}
async fn sync_runtime_workspace_state(task_manager: &SharedTaskManager, workspace: PathBuf) {
task_manager.set_default_workspace(workspace).await;
}
async fn switch_workspace(
app: &mut App,
engine_handle: &mut EngineHandle,
task_manager: &SharedTaskManager,
config: &Config,
workspace: PathBuf,
) {
if app.is_loading {
app.status_message =
Some("Cannot switch workspace while a request is running.".to_string());
app.add_message(HistoryCell::System {
content: "Cannot switch workspace while a request is running.".to_string(),
});
return;
}
if app.workspace == workspace {
app.status_message = Some(format!("Workspace unchanged: {}", workspace.display()));
return;
}
apply_workspace_runtime_state(app, config, workspace.clone());
sync_runtime_workspace_state(task_manager, workspace.clone()).await;
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
*engine_handle = spawn_engine(engine_config, config);
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
session_id: app.current_session_id.clone(),
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
model: app.model.clone(),
workspace: workspace.clone(),
})
.await;
}
app.add_message(HistoryCell::System {
content: format!("Switched workspace to {}", workspace.display()),
});
app.status_message = Some(format!("Workspace: {}", workspace.display()));
}
async fn handle_mcp_ui_action(
app: &mut App,
config: &Config,
@@ -6537,7 +6606,8 @@ async fn handle_view_events(
match manager.load_session(&session_id) {
Ok(session) => {
let recovered = apply_loaded_session(app, &session);
let recovered = apply_loaded_session(app, config, &session);
sync_runtime_workspace_state(task_manager, app.workspace.clone()).await;
let _ = engine_handle
.send(Op::SyncSession {
session_id: app.current_session_id.clone(),
@@ -6882,7 +6952,7 @@ async fn apply_provider_picker_api_key(
switch_provider(app, engine_handle, config, provider, None).await;
}
fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool {
fn apply_loaded_session(app: &mut App, config: &Config, session: &SavedSession) -> bool {
let (messages, recovered_draft) = recover_interrupted_user_tail(&session.messages);
app.api_messages = messages;
app.clear_history();
@@ -6928,7 +6998,7 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool {
app.viewport.transcript_selection.clear();
app.model.clone_from(&session.metadata.model);
app.update_model_compaction_budget();
app.workspace.clone_from(&session.metadata.workspace);
apply_workspace_runtime_state(app, config, session.metadata.workspace.clone());
app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
app.session.total_conversation_tokens = app.session.total_tokens;
app.session.session_cost = session.metadata.cost.session_cost_usd;
+73 -3
View File
@@ -1198,7 +1198,7 @@ fn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {
"finish the Qthresh proof bundle",
)]);
let recovered = apply_loaded_session(&mut app, &session);
let recovered = apply_loaded_session(&mut app, &Config::default(), &session);
assert!(recovered);
assert!(app.api_messages.is_empty());
@@ -1249,7 +1249,7 @@ fn apply_loaded_session_resets_unpersisted_telemetry() {
let mut session = saved_session_with_messages(vec![text_message("assistant", "ready")]);
session.metadata.total_tokens = 500;
let recovered = apply_loaded_session(&mut app, &session);
let recovered = apply_loaded_session(&mut app, &Config::default(), &session);
assert!(!recovered);
assert_eq!(app.session.total_tokens, 500);
@@ -1269,6 +1269,76 @@ fn apply_loaded_session_resets_unpersisted_telemetry() {
assert!(app.session.turn_cache_history.is_empty());
}
#[tokio::test]
async fn apply_loaded_session_resets_workspace_runtime_state() {
let mut app = create_test_app();
let config = Config::default();
let old_shell_manager = app
.runtime_services
.shell_manager
.as_ref()
.expect("shell manager")
.clone();
let old_context_cell = app.workspace_context_cell.clone();
app.workspace_context = Some("old workspace context".to_string());
if let Ok(mut cell) = old_context_cell.lock() {
*cell = Some("old workspace context".to_string());
}
app.workspace_context_refreshed_at = Some(Instant::now());
app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(
PathBuf::from(".").as_path(),
));
let mut session = saved_session_with_messages(vec![text_message("assistant", "ready")]);
session.metadata.workspace = TempDir::new().expect("temp dir").path().to_path_buf();
let recovered = apply_loaded_session(&mut app, &config, &session);
assert!(!recovered);
assert_eq!(app.workspace, session.metadata.workspace);
assert!(app.workspace_context.is_none());
assert!(app.workspace_context_refreshed_at.is_none());
assert!(app.file_tree.is_none());
assert!(old_context_cell.lock().expect("context cell").is_none());
let new_shell_manager = app
.runtime_services
.shell_manager
.as_ref()
.expect("shell manager")
.clone();
assert!(!std::sync::Arc::ptr_eq(
&old_shell_manager,
&new_shell_manager
));
assert_eq!(
new_shell_manager
.lock()
.expect("shell manager")
.default_workspace(),
session.metadata.workspace.as_path()
);
assert!(app.runtime_services.hook_executor.is_some());
}
#[test]
fn apply_loaded_session_updates_current_workspace_display() {
let mut app = create_test_app();
let config = Config::default();
let workspace = TempDir::new().expect("temp dir");
let mut session = saved_session_with_messages(vec![text_message("assistant", "ready")]);
session.metadata.workspace = workspace.path().to_path_buf();
let recovered = apply_loaded_session(&mut app, &config, &session);
let result = commands::execute("/workspace", &mut app);
assert!(!recovered);
assert_eq!(
result.message,
Some(format!("Current workspace: {}", workspace.path().display()))
);
assert!(result.action.is_none());
}
#[tokio::test]
async fn drain_web_config_events_applies_draft_without_closing_session() {
let mut app = create_test_app();
@@ -3510,7 +3580,7 @@ fn apply_loaded_session_restores_artifact_registry() {
storage_path: PathBuf::from("/tmp/tool_outputs/call-big.txt"),
});
let recovered = apply_loaded_session(&mut app, &session);
let recovered = apply_loaded_session(&mut app, &Config::default(), &session);
assert!(!recovered);
assert_eq!(app.session_artifacts, session.artifacts);