feat(session): fork conversations inside the TUI
This commit is contained in:
+28
-1
@@ -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
@@ -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 エンドポイント上書き |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user