diff --git a/CHANGELOG.md b/CHANGELOG.md index 41c35f38..6c4dcc53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **OpenAI-compatible custom model names are preserved.** Non-DeepSeek providers now pass explicit model names through instead of rewriting them to a DeepSeek default (#1714, #1740). +- **Wanjie Ark is a first-class provider.** `--provider wanjie-ark`, the TUI + provider picker, `deepseek auth`, doctor, and config files now target + Wanjie's OpenAI-compatible MaaS endpoint with pass-through model IDs and + Wanjie-specific env vars. - **DeepSeek reasoning replay works through OpenAI-compatible endpoints.** DeepSeek models selected under the generic `openai` provider now replay prior `reasoning_content` consistently and classify streamed reasoning the @@ -42,10 +46,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and canceled child agents now store the full child message transcript behind `transcript_handle`, so the parent can inspect details with `handle_read` instead of relying only on a lossy summary (#1738). +- **Forked saved sessions now keep visible lineage.** `deepseek fork` records + the parent session id and fork-time message count in additive metadata, and + session listings mark forked paths with their source id. This gives users a + bounded branchable-conversation workflow while the larger visual tree browser + stays scoped for a future release. - **Repeated shell wait rows collapse in the Tasks sidebar.** Multiple live `task_shell_wait` polls for the same background job now render as one row with an explicit collapsed-wait count, reducing the stuck-task appearance tracked for v0.8.40 (#1737). +- **Leaked mouse scroll reports no longer erase composer draft suffixes.** If + a terminal delivers raw SGR mouse bytes into the input stream, the sanitizer + now strips only the mouse report and adjacent coordinate fragments instead + of deleting legitimate draft text such as `commit -m` or numeric prompts + (#1778). +- **TUI runtime logs are separated per process and pruned on startup.** Each + session now writes `~/.deepseek/logs/tui-YYYY-MM-DD-PID.log`, and startup + removes stale TUI logs older than seven days by default. Set + `DEEPSEEK_LOG_RETENTION_DAYS` to a positive day count to adjust retention + (#1782, #1784). +- **The offline eval harness preserves quoted Windows shell payloads.** Its + `exec_shell` step now uses the same single-payload shape as the runtime shell + path, with raw `cmd /C` arguments on Windows so quoted commands remain intact + (#1779). +- **The Feishu/Lark bridge recovers better after restarts.** It now reattaches + to persisted active turns after the long-connection client starts, and text + chunking no longer splits emoji or other multi-code-unit characters. ### Thanks @@ -53,7 +79,8 @@ Thanks to **jayzhu ([@zlh124](https://github.com/zlh124))** for the WSL2 startup report and clipboard-init fix in #1772/#1773. Thanks to **Paulo Aboim Pinto ([@aboimpinto](https://github.com/aboimpinto))** for the Windows alt-screen logging report and fix in #1774/#1776, and for the Home/End -composer work in #1748/#1749. Thanks to **Zhongyue Lin +composer work in #1748/#1749, plus the per-process log filename follow-up in +#1782/#1783. Thanks to **Zhongyue Lin ([@LeoLin990405](https://github.com/LeoLin990405))** for the provider model passthrough, reasoning replay, thinking-only turn, and Windows quoting fixes in #1740, #1743, #1742, and #1744. Thanks to **Nightt diff --git a/README.ja-JP.md b/README.ja-JP.md index 5a2d94be..220e85e2 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -189,6 +189,10 @@ deepseek --provider nvidia-nim deepseek auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY" deepseek --provider atlascloud +# Wanjie Ark +deepseek auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY" +deepseek --provider wanjie-ark --model deepseek-reasoner + # OpenRouter deepseek auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY" deepseek --provider openrouter --model deepseek/deepseek-v4-pro @@ -216,7 +220,7 @@ ollama pull deepseek-coder:1.3b deepseek --provider ollama --model deepseek-coder:1.3b ``` -TUI 内では `/provider` でプロバイダーピッカー、`/model` でモデルピッカーを開けます。`/provider openrouter` や `/model ` で直接切り替え、`/models` で API から返るライブモデル一覧を確認できます。`/model` ピッカーは、利用可能な場合は現在のプロバイダーのライブモデルカタログを使い、ない場合はプロバイダー別の既定モデルにフォールバックします。 +TUI 内では `/provider` でプロバイダーピッカー、`/model` でローカルのモデル/思考モードピッカーを開けます。`/provider openrouter` や `/model ` で直接切り替え、`/models` で対応プロバイダーのライブモデル一覧を明示的に取得できます。 --- @@ -245,7 +249,7 @@ deepseek models # ライブ API モデル一覧 deepseek sessions # 保存済みセッション一覧 deepseek resume --last # 最新セッションを再開 deepseek resume # UUID 指定で特定セッションを再開 -deepseek fork # 任意のターンでセッションを fork +deepseek fork # 保存済みセッションを兄弟パスに fork deepseek serve --http # HTTP/SSE API サーバー deepseek serve --acp # Zed/カスタムエージェント向け ACP stdio アダプター deepseek run pr # PR を取得しレビュープロンプトに先行投入 @@ -298,13 +302,14 @@ deepseek update # バイナリ更新の確認 | `DEEPSEEK_HTTP_HEADERS` | 任意のモデルリクエストヘッダー | | `DEEPSEEK_MODEL` | デフォルトモデル | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | ストリームのアイドルタイムアウト秒数 | -| `DEEPSEEK_PROVIDER` | `deepseek`(デフォルト)、`nvidia-nim`、`openai`、`atlascloud`、`openrouter`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` | +| `DEEPSEEK_PROVIDER` | `deepseek`(デフォルト)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` | | `DEEPSEEK_PROFILE` | 設定プロファイル名 | | `DEEPSEEK_MEMORY` | `on` に設定するとユーザーメモリを有効化 | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | 信頼できるネットワークで非ローカル `http://` API ベース URL を許可 | -| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | プロバイダー認証 | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | プロバイダー認証 | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | 汎用 OpenAI 互換エンドポイントとモデル ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud エンドポイントとモデル上書き | +| `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark エンドポイントとモデル上書き | | `OPENROUTER_BASE_URL` | OpenRouter エンドポイント上書き | | `NOVITA_BASE_URL` | Novita エンドポイント上書き | | `FIREWORKS_BASE_URL` | Fireworks エンドポイント上書き | diff --git a/README.md b/README.md index a740cf7c..ef6a2d29 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ It is built around DeepSeek V4 (`deepseek-v4-pro` / `deepseek-v4-flash`), includ - **Prefix-cache stability tracking** — an optional `/statusline` footer chip surfaces how stable the cached prefix has been across recent turns so cost-busting edits are visible before they land - **Three modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved) - **Reasoning-effort tiers** — cycle through `off → high → max` with `Shift + Tab` -- **Session save/resume** — checkpoint and resume long-running sessions +- **Session save/resume/fork** — checkpoint long-running sessions and fork saved conversations into sibling paths with parent lineage shown in the picker - **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git` - **OS-level sandbox** — Seatbelt on macOS, Landlock on Linux, Job Objects on Windows; shell commands run with workspace-scoped filesystem access only - **Durable task queue** — background tasks can survive restarts @@ -320,7 +320,7 @@ deepseek models # list live API models deepseek sessions # list saved sessions deepseek resume --last # resume the most recent session in this workspace deepseek resume # resume a specific session by UUID -deepseek fork # fork a session at a chosen turn +deepseek fork # fork a saved session into a sibling path deepseek serve --http # HTTP/SSE API server deepseek serve --acp # ACP stdio adapter for Zed/custom agents deepseek run pr # fetch PR and pre-seed review prompt @@ -330,6 +330,19 @@ deepseek mcp-server # run dispatcher MCP stdio serv deepseek update # check for and apply binary updates ``` +### Branching Conversations + +Saved sessions are intentionally branchable. `deepseek fork ` copies +an existing saved session into a new sibling session, records the parent session +id in metadata, and opens that fork so you can explore an alternate direction +without polluting the original path. The session picker and `deepseek sessions` +mark forked sessions with their parent id. + +Inside the TUI, Esc-Esc backtrack can rewind the active transcript to a prior +user prompt and put that prompt back in the composer for editing. `/restore` +and `revert_turn` are separate workspace rollback tools: they restore files +from side-git snapshots but do not rewrite conversation history. + Docker images are published to GHCR for release builds: ```bash @@ -571,6 +584,10 @@ This project ships with help from a growing community of contributors: - **[mdrkrg](https://github.com/mdrkrg)** — first-run onboarding crash fix when the API key is missing (#1598) - **[Aitensa](https://github.com/Aitensa)** — CJK wrapping propagation for diff and pager output (#1622) - **[qiyan233](https://github.com/qiyan233)** — legacy DeepSeek CN provider alias compatibility (#1645) +- **[zlh124](https://github.com/zlh124)** — WSL2/headless startup report and clipboard-init fix (#1772, #1773) +- **[aboimpinto](https://github.com/aboimpinto)** — Windows alt-screen logging, Home/End composer, and runtime log follow-ups (#1774, #1776, #1748, #1749, #1782, #1783) +- **[LeoLin990405](https://github.com/LeoLin990405)** — provider model passthrough, reasoning replay, thinking-only turn, and Windows quoting fixes (#1740, #1743, #1742, #1744) +- **[nightt5879](https://github.com/nightt5879)** — Ctrl+C prompt restore fix (#1764) --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 1c22d4d9..89eb04f5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -240,6 +240,10 @@ deepseek --provider nvidia-nim deepseek auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY" deepseek --provider atlascloud +# Wanjie Ark +deepseek auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY" +deepseek --provider wanjie-ark --model deepseek-reasoner + # OpenRouter deepseek auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY" deepseek --provider openrouter --model deepseek/deepseek-v4-pro @@ -267,10 +271,9 @@ ollama pull deepseek-coder:1.3b deepseek --provider ollama --model deepseek-coder:1.3b ``` -在 TUI 内,`/provider` 打开提供方选择器,`/model` 打开模型选择器。 -`/provider openrouter` 和 `/model ` 可直接切换;`/models` 会列出 -API 返回的实时模型。`/model` 选择器会优先使用当前提供方的实时模型 -目录,不可用时再回退到 provider-aware 默认模型列表。 +在 TUI 内,`/provider` 打开提供方选择器,`/model` 打开本地模型/思考模式 +选择器。`/provider openrouter` 和 `/model ` 可直接切换;`/models` 会在 +当前提供方支持模型列表时显式请求并列出 API 返回的实时模型。 --- @@ -300,7 +303,7 @@ deepseek models # 列出可用 API 模型 deepseek sessions # 列出已保存会话 deepseek resume --last # 恢复最近会话 deepseek resume # 按 UUID 恢复指定会话 -deepseek fork # 在指定轮次分叉会话 +deepseek fork # 将已保存会话分叉为兄弟路径 deepseek serve --http # HTTP/SSE API 服务 deepseek serve --acp # Zed/自定义智能体的 ACP stdio 适配器 deepseek run pr # 获取 PR 并预填审查提示 @@ -386,13 +389,14 @@ DeepSeek 可作为自定义 Agent Client Protocol 服务器运行,供 Zed 等 | `DEEPSEEK_HTTP_HEADERS` | 可选模型请求头,例如 `X-Model-Provider-Id=your-model-provider` | | `DEEPSEEK_MODEL` | 默认模型 | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | 流式响应空闲超时秒数,默认 `300`,限制在 `1..=3600` | -| `DEEPSEEK_PROVIDER` | `deepseek`(默认)、`nvidia-nim`、`openai`、`openrouter`、`novita`、`atlascloud`、`fireworks`、`sglang`、`vllm`、`ollama` | +| `DEEPSEEK_PROVIDER` | `deepseek`(默认)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` | | `DEEPSEEK_PROFILE` | 配置 profile 名称 | | `DEEPSEEK_MEMORY` | 设为 `on` 启用用户记忆 | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | 在可信网络上允许非本机 `http://` API base URL | -| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `ATLASCLOUD_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | 提供商认证 | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | 提供商认证 | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | 通用 OpenAI 兼容端点和模型 ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud 端点和模型覆盖 | +| `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark 端点和模型覆盖 | | `OPENROUTER_BASE_URL` | OpenRouter 端点覆盖 | | `NOVITA_BASE_URL` | Novita 端点覆盖 | | `FIREWORKS_BASE_URL` | Fireworks 端点覆盖 | diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 41c35f38..6c4dcc53 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **OpenAI-compatible custom model names are preserved.** Non-DeepSeek providers now pass explicit model names through instead of rewriting them to a DeepSeek default (#1714, #1740). +- **Wanjie Ark is a first-class provider.** `--provider wanjie-ark`, the TUI + provider picker, `deepseek auth`, doctor, and config files now target + Wanjie's OpenAI-compatible MaaS endpoint with pass-through model IDs and + Wanjie-specific env vars. - **DeepSeek reasoning replay works through OpenAI-compatible endpoints.** DeepSeek models selected under the generic `openai` provider now replay prior `reasoning_content` consistently and classify streamed reasoning the @@ -42,10 +46,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and canceled child agents now store the full child message transcript behind `transcript_handle`, so the parent can inspect details with `handle_read` instead of relying only on a lossy summary (#1738). +- **Forked saved sessions now keep visible lineage.** `deepseek fork` records + the parent session id and fork-time message count in additive metadata, and + session listings mark forked paths with their source id. This gives users a + bounded branchable-conversation workflow while the larger visual tree browser + stays scoped for a future release. - **Repeated shell wait rows collapse in the Tasks sidebar.** Multiple live `task_shell_wait` polls for the same background job now render as one row with an explicit collapsed-wait count, reducing the stuck-task appearance tracked for v0.8.40 (#1737). +- **Leaked mouse scroll reports no longer erase composer draft suffixes.** If + a terminal delivers raw SGR mouse bytes into the input stream, the sanitizer + now strips only the mouse report and adjacent coordinate fragments instead + of deleting legitimate draft text such as `commit -m` or numeric prompts + (#1778). +- **TUI runtime logs are separated per process and pruned on startup.** Each + session now writes `~/.deepseek/logs/tui-YYYY-MM-DD-PID.log`, and startup + removes stale TUI logs older than seven days by default. Set + `DEEPSEEK_LOG_RETENTION_DAYS` to a positive day count to adjust retention + (#1782, #1784). +- **The offline eval harness preserves quoted Windows shell payloads.** Its + `exec_shell` step now uses the same single-payload shape as the runtime shell + path, with raw `cmd /C` arguments on Windows so quoted commands remain intact + (#1779). +- **The Feishu/Lark bridge recovers better after restarts.** It now reattaches + to persisted active turns after the long-connection client starts, and text + chunking no longer splits emoji or other multi-code-unit characters. ### Thanks @@ -53,7 +79,8 @@ Thanks to **jayzhu ([@zlh124](https://github.com/zlh124))** for the WSL2 startup report and clipboard-init fix in #1772/#1773. Thanks to **Paulo Aboim Pinto ([@aboimpinto](https://github.com/aboimpinto))** for the Windows alt-screen logging report and fix in #1774/#1776, and for the Home/End -composer work in #1748/#1749. Thanks to **Zhongyue Lin +composer work in #1748/#1749, plus the per-process log filename follow-up in +#1782/#1783. Thanks to **Zhongyue Lin ([@LeoLin990405](https://github.com/LeoLin990405))** for the provider model passthrough, reasoning replay, thinking-only turn, and Windows quoting fixes in #1740, #1743, #1742, and #1744. Thanks to **Nightt diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 186e9744..188b4d1f 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -291,6 +291,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/save [path]", description_id: MessageId::CmdSaveDescription, }, + CommandInfo { + name: "fork", + aliases: &["branch"], + usage: "/fork", + description_id: MessageId::CmdForkDescription, + }, CommandInfo { name: "sessions", aliases: &["resume"], @@ -570,6 +576,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { // Session commands "rename" | "gaiming" | "chongmingming" => rename::rename(app, arg), "save" => session::save(app, arg), + "fork" | "branch" => session::fork(app), "sessions" | "resume" => session::sessions(app, arg), "relay" | "batonpass" | "接力" => relay(app, arg), "load" | "jiazai" => session::load(app, arg), diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 6f3a4257..54d11132 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -3,7 +3,9 @@ use std::fmt::Write; use std::path::PathBuf; -use crate::session_manager::create_saved_session_with_mode; +use crate::session_manager::{ + create_saved_session_with_id_and_mode, create_saved_session_with_mode, +}; use crate::tui::app::{App, AppAction}; use crate::tui::history::{HistoryCell, history_cells_from_message}; use crate::tui::session_picker::SessionPickerView; @@ -58,6 +60,71 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { } } +/// Fork the active conversation into a new saved sibling session and switch to it. +pub fn fork(app: &mut App) -> CommandResult { + if app.api_messages.is_empty() { + return CommandResult::error("Nothing to fork. Send or load a message first."); + } + + let manager = match crate::session_manager::SessionManager::default_location() { + Ok(manager) => manager, + Err(err) => { + return CommandResult::error(format!("could not open sessions directory: {err}")); + } + }; + + let parent_id = app + .current_session_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let mut parent = create_saved_session_with_id_and_mode( + parent_id, + &app.api_messages, + &app.model, + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.label()), + ); + app.sync_cost_to_metadata(&mut parent.metadata); + parent.artifacts = app.session_artifacts.clone(); + + if let Err(err) = manager.save_session(&parent) { + return CommandResult::error(format!("Failed to save parent session: {err}")); + } + + let mut forked = create_saved_session_with_mode( + &app.api_messages, + &app.model, + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.label()), + ); + forked.metadata.copy_cost_from(&parent.metadata); + forked.metadata.mark_forked_from(&parent.metadata); + + if let Err(err) = manager.save_session(&forked) { + return CommandResult::error(format!("Failed to save forked session: {err}")); + } + + app.current_session_id = Some(forked.metadata.id.clone()); + let fork_id = forked.metadata.id.clone(); + let parent_label = crate::session_manager::truncate_id(&parent.metadata.id).to_string(); + let fork_label = crate::session_manager::truncate_id(&fork_id).to_string(); + + CommandResult::with_message_and_action( + format!("Forked session {parent_label} -> {fork_label}"), + AppAction::SyncSession { + session_id: Some(fork_id), + messages: app.api_messages.clone(), + system_prompt: app.system_prompt.clone(), + model: app.model.clone(), + workspace: app.workspace.clone(), + }, + ) +} + /// Load session from file pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { let load_path = if let Some(p) = path { @@ -359,6 +426,56 @@ mod tests { assert_eq!(saved.artifacts, app.session_artifacts); } + #[test] + fn fork_saves_parent_and_switches_to_child_session() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let home = tmpdir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let previous_home = std::env::var_os("HOME"); + // SAFETY: guarded by the process-wide test env mutex above. + unsafe { + std::env::set_var("HOME", &home); + } + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("parent-session".to_string()); + app.api_messages.push(crate::models::Message { + role: "user".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: "try another path".to_string(), + cache_control: None, + }], + }); + + let result = fork(&mut app); + + assert!(!result.is_error, "{:?}", result.message); + let new_id = app.current_session_id.clone().expect("fork session id"); + assert_ne!(new_id, "parent-session"); + assert!(result.message.as_deref().unwrap_or("").contains("Forked")); + assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); + + let manager = crate::session_manager::SessionManager::default_location().unwrap(); + let parent = manager + .load_session("parent-session") + .expect("parent saved"); + let child = manager.load_session(&new_id).expect("child saved"); + assert_eq!(parent.messages.len(), 1); + assert_eq!( + child.metadata.parent_session_id.as_deref(), + Some("parent-session") + ); + assert_eq!(child.metadata.forked_from_message_count, Some(1)); + // SAFETY: guarded by the process-wide test env mutex above. + unsafe { + if let Some(previous_home) = previous_home { + std::env::set_var("HOME", previous_home); + } else { + std::env::remove_var("HOME"); + } + } + } + #[test] fn test_save_with_default_path_uses_workspace() { let tmpdir = TempDir::new().unwrap(); diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index bcb7d881..d3c17774 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -292,6 +292,7 @@ pub enum MessageId { CmdReviewDescription, CmdRlmDescription, CmdSaveDescription, + CmdForkDescription, CmdSessionsDescription, CmdSettingsDescription, CmdSkillDescription, @@ -964,6 +965,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdReviewDescription => "Run a structured code review on a file, diff, or PR", MessageId::CmdRlmDescription => "Open a persistent RLM context: /rlm [0-3] ", MessageId::CmdSaveDescription => "Save session to file", + MessageId::CmdForkDescription => "Fork the active conversation into a sibling session", MessageId::CmdSessionsDescription => "Open session history picker", MessageId::CmdSettingsDescription => "Show persistent settings", MessageId::CmdSkillDescription => { @@ -1349,6 +1351,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdReviewDescription => "ファイル・diff・PR に対して構造化コードレビューを実行", MessageId::CmdRlmDescription => "永続 RLM コンテキストを開く: /rlm [0-3] ", MessageId::CmdSaveDescription => "セッションをファイルに保存", + MessageId::CmdForkDescription => "現在の会話を兄弟セッションに fork", MessageId::CmdSessionsDescription => "セッション履歴ピッカーを開く", MessageId::CmdSettingsDescription => "永続化された設定を表示", MessageId::CmdSkillDescription => { @@ -1689,6 +1692,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdReviewDescription => "对文件、diff 或 PR 进行结构化代码审查", MessageId::CmdRlmDescription => "打开持久 RLM 上下文:/rlm [0-3] ", MessageId::CmdSaveDescription => "将会话保存到文件", + MessageId::CmdForkDescription => "将当前对话分叉为兄弟会话", MessageId::CmdSessionsDescription => "打开会话历史选择器", MessageId::CmdSettingsDescription => "显示持久化设置", MessageId::CmdSkillDescription => "激活技能,或安装/更新/卸载/信任社区技能", @@ -2021,6 +2025,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "Abrir um contexto RLM persistente: /rlm [0-3] " } MessageId::CmdSaveDescription => "Salvar a sessão em arquivo", + MessageId::CmdForkDescription => "Bifurcar a conversa ativa para uma sessão irmã", MessageId::CmdSessionsDescription => "Abrir seletor de histórico de sessões", MessageId::CmdSettingsDescription => "Exibir as configurações persistidas", MessageId::CmdSkillDescription => { @@ -2407,6 +2412,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { "Turno del Recursive Language Model (RLM) — guarda el prompt en un REPL Python y deja que el modelo escriba el código que lo procesa; usa `llm_query()` / `sub_rlm()` para llamadas a sub-LLMs." } MessageId::CmdSaveDescription => "Guardar la sesión en archivo", + MessageId::CmdForkDescription => "Bifurcar la conversación activa a una sesión hermana", MessageId::CmdSessionsDescription => "Abrir el selector de sesiones", MessageId::CmdSettingsDescription => "Mostrar las configuraciones persistidas", MessageId::CmdSkillDescription => { diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 1bbd18e3..a261e02e 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3152,6 +3152,7 @@ fn fork_session(session_id: Option, last: bool, workspace: &Path) -> Res system_prompt.as_ref(), ); forked.metadata.copy_cost_from(&saved.metadata); + forked.metadata.mark_forked_from(&saved.metadata); manager.save_session(&forked)?; let source_title = saved.metadata.title.trim(); diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 66f66147..533e57f9 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -125,6 +125,13 @@ pub struct SessionMetadata { /// Accumulated cost data for persisted billing and high-water mark. #[serde(default)] pub cost: SessionCostSnapshot, + /// Source session id when this session was created with `deepseek fork`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_session_id: Option, + /// Source message count at fork time. This is intentionally coarse: + /// current saved sessions are linear JSON files, not per-entry trees. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub forked_from_message_count: Option, } /// Cost and high-water-mark fields persisted with each session. @@ -169,6 +176,12 @@ impl SessionMetadata { pub fn copy_cost_from(&mut self, other: &SessionMetadata) { self.cost = other.cost; } + + /// Record additive lineage metadata for a forked saved session. + pub fn mark_forked_from(&mut self, parent: &SessionMetadata) { + self.parent_session_id = Some(parent.id.clone()); + self.forked_from_message_count = Some(parent.message_count); + } } /// A saved session containing full conversation history @@ -702,6 +715,8 @@ pub fn create_saved_session_with_id_and_mode( workspace: workspace.to_path_buf(), mode: mode.map(str::to_string), cost: SessionCostSnapshot::default(), + parent_session_id: None, + forked_from_message_count: None, }, messages: capped_messages, system_prompt: merge_truncation_note( @@ -949,12 +964,18 @@ fn truncate_title(s: &str, max_len: usize) -> String { pub fn format_session_line(meta: &SessionMetadata) -> String { let age = format_age(&meta.updated_at); let truncated_title = truncate_title(extract_title(&meta.title), 40); + let fork_label = meta + .parent_session_id + .as_deref() + .map(|parent| format!(" | fork {}", truncate_id(parent))) + .unwrap_or_default(); format!( - "{} | {} | {} msgs | {}", + "{} | {} | {} msgs{} | {}", truncate_id(&meta.id), truncated_title, meta.message_count, + fork_label, age ) } @@ -1016,6 +1037,8 @@ mod tests { workspace: workspace.to_path_buf(), mode: None, cost: SessionCostSnapshot::default(), + parent_session_id: None, + forked_from_message_count: None, }, system_prompt: None, context_references: Vec::new(), @@ -1044,6 +1067,8 @@ mod tests { workspace: workspace.to_path_buf(), mode: Some("yolo".to_string()), cost: SessionCostSnapshot::default(), + parent_session_id: None, + forked_from_message_count: None, }, system_prompt: None, context_references: Vec::new(), @@ -1687,6 +1712,44 @@ mod tests { let session: SavedSession = serde_json::from_str(json).expect("legacy session loads"); assert!(session.artifacts.is_empty()); + assert!(session.metadata.parent_session_id.is_none()); + assert!(session.metadata.forked_from_message_count.is_none()); + } + + #[test] + fn fork_lineage_metadata_round_trips_and_formats() { + let tmp = tempdir().expect("tempdir"); + let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); + let parent = create_saved_session( + &[ + make_test_message("user", "try approach A"), + make_test_message("assistant", "A looks viable"), + ], + "deepseek-v4-pro", + Path::new("/tmp"), + 42, + None, + ); + let mut forked = create_saved_session( + &parent.messages, + &parent.metadata.model, + &parent.metadata.workspace, + parent.metadata.total_tokens, + None, + ); + forked.metadata.mark_forked_from(&parent.metadata); + + manager.save_session(&forked).expect("save fork"); + let loaded = manager + .load_session(&forked.metadata.id) + .expect("load fork"); + + assert_eq!( + loaded.metadata.parent_session_id.as_deref(), + Some(parent.metadata.id.as_str()) + ); + assert_eq!(loaded.metadata.forked_from_message_count, Some(2)); + assert!(format_session_line(&loaded.metadata).contains("fork ")); } #[test] diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 48d5d0b4..5c8f7978 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -624,11 +624,17 @@ fn format_session_line(session: &SessionMetadata) -> String { .as_deref() .unwrap_or("unknown") .to_ascii_lowercase(); + let fork_label = session + .parent_session_id + .as_deref() + .map(|parent| format!(" | fork {}", crate::session_manager::truncate_id(parent))) + .unwrap_or_default(); format!( - "{} | {} | {} msgs | {} | {}", + "{} | {} | {} msgs{} | {} | {}", crate::session_manager::truncate_id(&session.id), title, session.message_count, + fork_label, mode, updated ) @@ -864,6 +870,8 @@ mod tests { workspace: std::path::PathBuf::from("/tmp"), mode: Some("agent".to_string()), cost: crate::session_manager::SessionCostSnapshot::default(), + parent_session_id: None, + forked_from_message_count: None, } } @@ -1018,6 +1026,22 @@ mod tests { assert!(span.style.add_modifier.contains(Modifier::BOLD)); } + #[test] + fn build_list_lines_marks_fork_lineage() { + let mut forked = test_session(1, "forked path"); + forked.parent_session_id = Some("parent-session-abcdef".to_string()); + forked.forked_from_message_count = Some(3); + let lines = build_list_lines(&[forked], 0, 120, 0, 5, false, "", "recent", false, None); + + let rendered = lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::>() + .join("\n"); + assert!(rendered.contains("fork parent")); + } + #[test] fn build_list_lines_numbers_visible_rows_for_shortcuts() { let sessions = vec![ diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 21ddbf71..e5e1396b 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1280,6 +1280,8 @@ fn saved_session_with_messages(messages: Vec) -> SavedSession { workspace: PathBuf::from("/tmp/resume-recovery"), mode: Some("yolo".to_string()), cost: crate::session_manager::SessionCostSnapshot::default(), + parent_session_id: None, + forked_from_message_count: None, }, messages, system_prompt: None, diff --git a/docs/MODES.md b/docs/MODES.md index c8176f71..1e2d98e4 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -81,6 +81,7 @@ Run `deepseek --help` for the canonical list. Common flags: - `deepseek exec --output-format stream-json `: emit one JSON object per line for harnesses and backend wrappers - `deepseek exec --resume ` / `--session-id `: continue a saved session non-interactively - `deepseek exec --continue `: continue the most recent saved session for this workspace non-interactively +- `deepseek fork ` / `deepseek fork --last`: copy a saved session into a new sibling session; forked sessions retain additive parent-session metadata and show that lineage in session listings - `--model `: when using the `deepseek` facade, forward a DeepSeek model override to the TUI - `--workspace `: workspace root for file tools - `--yolo`: start in YOLO mode @@ -91,3 +92,18 @@ Run `deepseek --help` for the canonical list. Common flags: - `--profile `: select config profile - `--config `: config file path - `-v, --verbose`: verbose logging + +## Branching and Rollback + +DeepSeek-TUI has three related but intentionally separate recovery paths: + +- `deepseek fork ` creates a new saved session from an existing saved + conversation and records the source session id. This is the safe way to + explore a different answer path without overwriting the original session. +- Esc-Esc backtrack rewinds the live transcript to a previous user prompt and + restores that prompt into the composer for editing. +- `/restore` and the `revert_turn` tool restore workspace files from side-git + snapshots. They do not rewrite conversation history. + +A Pi-style in-file tree browser is a larger UI/data-model project. v0.8.40 +ships the bounded fork/backtrack primitives and explicit lineage metadata. diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index c4d0cdd2..cefc8588 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -154,6 +154,13 @@ clients that cannot set custom headers. - `POST /v1/threads/{id}/resume` - `POST /v1/threads/{id}/fork` +Thread forks are sibling runtime threads, not an in-place tree projection. +`thread.forked` events include `source_thread_id`; internal backtrack-aware +forks may also include `backtrack_depth_from_tail` and `dropped_turn_id`. +Thread list and summary responses remain flat in v0.8.40, so clients that need +a graph should reconstruct it from events instead of assuming list order is a +complete tree. + `archived_only=true` returns archived threads only (mutually overrides `include_archived`). Default behavior is unchanged: `include_archived=false` and `archived_only=false` returns active threads. Added in v0.8.10 (#563).