feat(session): fork conversations inside the TUI

This commit is contained in:
Hunter Bown
2026-05-21 00:24:52 +08:00
parent d065241c93
commit 2c642ec375
14 changed files with 341 additions and 18 deletions
+28 -1
View File
@@ -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
+9 -4
View File
@@ -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 <id>` で直接切り替え、`/models` API から返るライブモデル一覧を確認できます。`/model` ピッカーは、利用可能な場合は現在のプロバイダーのライブモデルカタログを使い、ない場合はプロバイダー別の既定モデルにフォールバックします。
TUI 内では `/provider` でプロバイダーピッカー、`/model`ローカルのモデル/思考モードピッカーを開けます。`/provider openrouter``/model <id>` で直接切り替え、`/models`対応プロバイダーのライブモデル一覧を明示的に取得できます。
---
@@ -245,7 +249,7 @@ deepseek models # ライブ API モデル一覧
deepseek sessions # 保存済みセッション一覧
deepseek resume --last # 最新セッションを再開
deepseek resume <SESSION_ID> # UUID 指定で特定セッションを再開
deepseek fork <SESSION_ID> # 任意のターンでセッションを fork
deepseek fork <SESSION_ID> # 保存済みセッションを兄弟パスに fork
deepseek serve --http # HTTP/SSE API サーバー
deepseek serve --acp # Zed/カスタムエージェント向け ACP stdio アダプター
deepseek run pr <N> # 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 エンドポイント上書き |
+19 -2
View File
@@ -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 <SESSION_ID> # resume a specific session by UUID
deepseek fork <SESSION_ID> # fork a session at a chosen turn
deepseek fork <SESSION_ID> # 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 <N> # 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 <SESSION_ID>` 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)
---
+11 -7
View File
@@ -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 <id>` 可直接切换;`/models`列出
API 返回的实时模型。`/model` 选择器会优先使用当前提供方的实时模型
目录,不可用时再回退到 provider-aware 默认模型列表。
在 TUI 内,`/provider` 打开提供方选择器,`/model` 打开本地模型/思考模式
选择器。`/provider openrouter``/model <id>` 可直接切换;`/models`
当前提供方支持模型列表时显式请求并列出 API 返回的实时模型。
---
@@ -300,7 +303,7 @@ deepseek models # 列出可用 API 模型
deepseek sessions # 列出已保存会话
deepseek resume --last # 恢复最近会话
deepseek resume <SESSION_ID> # 按 UUID 恢复指定会话
deepseek fork <SESSION_ID> # 在指定轮次分叉会话
deepseek fork <SESSION_ID> # 将已保存会话分叉为兄弟路径
deepseek serve --http # HTTP/SSE API 服务
deepseek serve --acp # Zed/自定义智能体的 ACP stdio 适配器
deepseek run pr <N> # 获取 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 端点覆盖 |
+28 -1
View File
@@ -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
+7
View File
@@ -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),
+118 -1
View File
@@ -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();
+6
View File
@@ -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] <file_or_text>",
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] <file_or_text>",
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] <file_or_text>",
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] <file_or_text>"
}
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 => {
+1
View File
@@ -3152,6 +3152,7 @@ fn fork_session(session_id: Option<String>, 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();
+64 -1
View File
@@ -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<String>,
/// 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<usize>,
}
/// 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]
+25 -1
View File
@@ -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::<Vec<_>>()
.join("\n");
assert!(rendered.contains("fork parent"));
}
#[test]
fn build_list_lines_numbers_visible_rows_for_shortcuts() {
let sessions = vec![
+2
View File
@@ -1280,6 +1280,8 @@ fn saved_session_with_messages(messages: Vec<Message>) -> 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,
+16
View File
@@ -81,6 +81,7 @@ Run `deepseek --help` for the canonical list. Common flags:
- `deepseek exec --output-format stream-json <PROMPT>`: emit one JSON object per line for harnesses and backend wrappers
- `deepseek exec --resume <ID|PREFIX> <PROMPT>` / `--session-id <ID|PREFIX>`: continue a saved session non-interactively
- `deepseek exec --continue <PROMPT>`: continue the most recent saved session for this workspace non-interactively
- `deepseek fork <ID|PREFIX>` / `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 <MODEL>`: when using the `deepseek` facade, forward a DeepSeek model override to the TUI
- `--workspace <DIR>`: workspace root for file tools
- `--yolo`: start in YOLO mode
@@ -91,3 +92,18 @@ Run `deepseek --help` for the canonical list. Common flags:
- `--profile <NAME>`: select config profile
- `--config <PATH>`: config file path
- `-v, --verbose`: verbose logging
## Branching and Rollback
DeepSeek-TUI has three related but intentionally separate recovery paths:
- `deepseek fork <ID>` 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.
+7
View File
@@ -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).