From f187098eace121851cc2c22330fcf349527d60e7 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 11 Jul 2026 00:17:40 +0800 Subject: [PATCH] feat: surface session fork in the TUI (/fork [backend]) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch the focused session into an independent one from the TUI, optionally continuing it on a different backend — the third UI (after telegram + web) to expose fork. - protocol: add `ClientMessage::SessionFork { session_id, name?, provider_id? }` mirroring the daemon's `session.fork` wire shape; register it in `request_id` and `client_kind`. - commands: parse `/fork` (keep parent backend) and `/fork ` (continue on another, lowercased); add a CATALOG entry. Drop the now-conflicting `fork` alias for `/import` (import keeps its own name). - app: `fork_focused` sends the fork frame and, on success, adds + focuses the returned session so its tab goes active; the response-handling is extracted into the pure, unit-tested `apply_fork_response`. Tests: parser (with/without backend) + catalog presence; `apply_fork_response` add-and-focus + no-payload error paths; `client_kind` fork mapping; builder wire-shape; no-session and no-connection guard paths. Co-Authored-By: Claude Opus 4.8 --- crates/codeoid-client/src/connection.rs | 10 ++ crates/codeoid-protocol/src/client.rs | 17 +++ crates/codeoid-tui/src/app.rs | 170 +++++++++++++++++++++++- crates/codeoid-tui/src/commands/mod.rs | 40 +++++- 4 files changed, 234 insertions(+), 3 deletions(-) diff --git a/crates/codeoid-client/src/connection.rs b/crates/codeoid-client/src/connection.rs index 46278dc..8248fe2 100644 --- a/crates/codeoid-client/src/connection.rs +++ b/crates/codeoid-client/src/connection.rs @@ -492,6 +492,7 @@ fn client_kind(msg: &ClientMessage) -> &'static str { ClientMessage::SessionSearch { .. } => "session.search", ClientMessage::SessionSetModel { .. } => "session.set_model", ClientMessage::SessionSetProvider { .. } => "session.set_provider", + ClientMessage::SessionFork { .. } => "session.fork", ClientMessage::SessionRename { .. } => "session.rename", ClientMessage::ClaudeConfig { .. } => "claude.config", ClientMessage::SessionExport { .. } => "session.export", @@ -574,5 +575,14 @@ mod tests { }), "session.set_provider" ); + assert_eq!( + client_kind(&ClientMessage::SessionFork { + id: "1".into(), + session_id: "s".into(), + name: None, + provider_id: Some("codex".into()), + }), + "session.fork" + ); } } diff --git a/crates/codeoid-protocol/src/client.rs b/crates/codeoid-protocol/src/client.rs index d154125..2f24081 100644 --- a/crates/codeoid-protocol/src/client.rs +++ b/crates/codeoid-protocol/src/client.rs @@ -164,6 +164,22 @@ pub enum ClientMessage { provider_id: String, }, + /// Branch a session into an independent one, optionally onto a different + /// backend. The fork is seeded with a deep copy of the parent's canonical + /// history + restamped scrollback; the parent is untouched. `name` absent + /// ⇒ parent name + " (fork)"; `provider_id` absent ⇒ parent's backend. + /// Fail-closed: foreign/unknown session ⇒ not_found, unknown provider ⇒ + /// invalid_request. + #[serde(rename = "session.fork", rename_all = "camelCase")] + SessionFork { + id: String, + session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_id: Option, + }, + #[serde(rename = "session.set_model", rename_all = "camelCase")] SessionSetModel { id: String, @@ -247,6 +263,7 @@ impl ClientMessage { | Self::SessionRotate { id, .. } | Self::SessionSearch { id, .. } | Self::SessionSetProvider { id, .. } + | Self::SessionFork { id, .. } | Self::SessionSetModel { id, .. } | Self::SessionRename { id, .. } | Self::ClaudeConfig { id, .. } diff --git a/crates/codeoid-tui/src/app.rs b/crates/codeoid-tui/src/app.rs index b8ce47e..3d7049e 100644 --- a/crates/codeoid-tui/src/app.rs +++ b/crates/codeoid-tui/src/app.rs @@ -12,7 +12,9 @@ use std::time::Duration; use anyhow::{anyhow, Context, Result}; use codeoid_client::{connect, ClientHandle, Connected, StreamEvent}; -use codeoid_protocol::{ClientMessage, DaemonMessage, SessionMode, SessionStatus, ToolState}; +use codeoid_protocol::{ + ClientMessage, DaemonMessage, SessionInfo, SessionMode, SessionStatus, ToolState, +}; use crossterm::event::{Event as CtEvent, EventStream, KeyEventKind, MouseEventKind}; use futures_util::StreamExt; use ratatui::backend::CrosstermBackend; @@ -1029,6 +1031,7 @@ impl App { provider_id, } => self.create_session(name, workdir, provider_id).await, SlashCommand::Provider(provider_id) => self.set_provider(provider_id).await, + SlashCommand::Fork { provider_id } => self.fork_focused(provider_id).await, SlashCommand::Rename { name } => self.rename_focused(name).await, SlashCommand::Destroy => self.destroy_focused().await, SlashCommand::Interrupt => self.interrupt().await, @@ -1260,6 +1263,59 @@ impl App { } } + /// `/fork [backend]` — branch the focused session into an independent one, + /// optionally continuing it on another backend. The daemon returns the new + /// [`SessionInfo`]; we upsert it and focus it so the new tab is active. It + /// validates fail-closed (unknown session/backend) and its error lands via + /// the response. + async fn fork_focused(&mut self, provider_id: Option) { + let Some(session_id) = self + .state + .as_ref() + .and_then(|s| s.sessions.focused_id().map(ToString::to_string)) + else { + if let Some(state) = self.state.as_mut() { + state.record_error("no session focused — /fork needs one"); + } + return; + }; + let Some(handle) = self.handle.clone() else { + return; + }; + let msg = session_fork_message(ClientHandle::next_request_id(), session_id, provider_id); + match handle.request_ok(msg).await { + Ok(data) => { + let applied = self + .state + .as_mut() + .map(|state| apply_fork_response(state, data)); + match applied { + // Fork landed: refresh the list so the daemon's view and + // ours reconcile (the fork isn't attached, so nothing + // arrives on its own). + Some(Ok(())) => { + let _ = handle + .send(ClientMessage::SessionList { + id: ClientHandle::next_request_id(), + }) + .await; + } + Some(Err(e)) => { + if let Some(state) = self.state.as_mut() { + state.record_error(e); + } + } + None => {} + } + } + Err(e) => { + if let Some(state) = self.state.as_mut() { + state.record_error(format!("/fork failed: {e}")); + } + } + } + } + async fn destroy_focused(&mut self) { let Some(session_id) = self .state @@ -1759,6 +1815,40 @@ fn set_provider_message(id: String, session_id: String, provider_id: String) -> } } +/// Pure `session.fork` frame builder (see `session_create_message`). `name` is +/// left to the daemon default (parent name + " (fork)"); `provider_id` absent +/// keeps the parent's backend. +fn session_fork_message( + id: String, + session_id: String, + provider_id: Option, +) -> ClientMessage { + ClientMessage::SessionFork { + id, + session_id, + name: None, + provider_id, + } +} + +/// Apply a `session.fork` response to the tab strip: add the returned fork and +/// focus it so its tab becomes active. Pure over [`AppState`] (no daemon I/O) +/// so the add-and-focus behaviour is unit-testable. `Err` carries a message +/// the caller surfaces if the daemon answered without a session payload. +fn apply_fork_response( + state: &mut AppState, + data: Option, +) -> Result<(), String> { + let fork: SessionInfo = data + .and_then(|v| serde_json::from_value(v).ok()) + .ok_or_else(|| "/fork: daemon returned no session".to_string())?; + let fork_id = fork.id.clone(); + state.sessions.upsert(fork); + state.sessions.focus_id(&fork_id); + state.last_error = None; + Ok(()) +} + #[cfg(test)] mod tests { use codeoid_protocol::{ @@ -2059,6 +2149,78 @@ mod tests { assert_eq!(json["type"], "session.set_provider"); assert_eq!(json["sessionId"], "s1"); assert_eq!(json["providerId"], "pi"); + + // Fork onto another backend: type + session + backend, name defaulted + // by the daemon (absent on the wire). + let fork = session_fork_message("r4".into(), "s1".into(), Some("codex".into())); + let json = serde_json::to_value(&fork).unwrap(); + assert_eq!(json["type"], "session.fork"); + assert_eq!(json["sessionId"], "s1"); + assert_eq!(json["providerId"], "codex"); + assert!(json.get("name").is_none()); + + // Fork without a backend → providerId absent (parent's backend kept). + let same = session_fork_message("r5".into(), "s1".into(), None); + let json = serde_json::to_value(&same).unwrap(); + assert!(json.get("providerId").is_none()); + } + + #[test] + fn apply_fork_response_adds_and_focuses_the_returned_fork() { + let mut state = mk_state(); + // Parent "s1" is focused to start. + assert_eq!(state.sessions.focused_id(), Some("s1")); + + // The daemon's fork payload: same shape as the parent, new id + name. + let fork_info = SessionInfo { + id: "s1-fork".into(), + name: "demo (fork)".into(), + ..state.sessions.items()[0].clone() + }; + let data = Some(serde_json::to_value(&fork_info).unwrap()); + apply_fork_response(&mut state, data).expect("valid payload"); + + // The fork is now a tab AND focused; the parent is untouched. + assert_eq!(state.sessions.focused_id(), Some("s1-fork")); + assert!(state.sessions.items().iter().any(|s| s.id == "s1")); + assert!(state.sessions.items().iter().any(|s| s.id == "s1-fork")); + assert!(state.last_error.is_none()); + } + + #[test] + fn apply_fork_response_errors_without_a_session_payload() { + let mut state = mk_state(); + // Daemon answered `ok` but with no data (or an unparseable body). + assert!(apply_fork_response(&mut state, None) + .unwrap_err() + .contains("no session")); + assert!(apply_fork_response(&mut state, Some(serde_json::json!({"junk": 1}))).is_err()); + // Focus stayed on the parent — nothing was added. + assert_eq!(state.sessions.focused_id(), Some("s1")); + } + + #[tokio::test] + async fn fork_focused_requires_a_focused_session() { + let mut app = App::new("ws://test".into(), "tok".into()); + app.state = Some(AppState::new(codeoid_protocol::AuthOkMsg { + identity: codeoid_protocol::MessageIdentity { + sub: "u".into(), + name: None, + kind: codeoid_protocol::IdentityType::Human, + }, + scopes: vec![], + protocol_version: Some(1), + capabilities: None, + providers: Some(vec!["claude".into(), "codex".into()]), + })); + app.fork_focused(None).await; + assert!(app + .state + .as_ref() + .unwrap() + .last_error + .as_deref() + .is_some_and(|e| e.contains("no session focused"))); } #[tokio::test] @@ -2103,6 +2265,12 @@ mod tests { .prompt .insert_str("/new demo --provider pi"); app.submit_prompt().await; + // `/fork` and `/fork ` route through dispatch and return at + // the missing-handle guard without queueing anything. + app.state.as_mut().unwrap().prompt.insert_str("/fork"); + app.submit_prompt().await; + app.state.as_mut().unwrap().prompt.insert_str("/fork codex"); + app.submit_prompt().await; assert!(app.pending_sends.is_empty(), "no handle — nothing queued"); } } diff --git a/crates/codeoid-tui/src/commands/mod.rs b/crates/codeoid-tui/src/commands/mod.rs index 66e5f1d..78bd6c5 100644 --- a/crates/codeoid-tui/src/commands/mod.rs +++ b/crates/codeoid-tui/src/commands/mod.rs @@ -42,6 +42,11 @@ pub enum SlashCommand { /// `/provider ` — switch the focused session's backend mid-session /// (claude ⇄ pi …). The daemon rejects unknown ids and mid-turn switches. Provider(String), + /// `/fork [backend]` — branch the focused session into an independent + /// one, optionally continuing it on a different backend. With no argument + /// the fork keeps the parent's backend; the daemon fail-closes on an + /// unknown backend id. + Fork { provider_id: Option }, /// `/who` — show the authenticated ZeroID identity + scopes. Who, /// `/rotate` — rotate the backing Claude Code context. @@ -56,7 +61,7 @@ pub enum SlashCommand { /// `/export [path]` — write the focused session to a JSON bundle /// (under `~/.codeoid/exports/` by default; or to the given path). Export { path: Option }, - /// `/import ` — fork a bundle into a + /// `/import ` — load a bundle into a /// fresh session anchored at `target-workdir`. Import { bundle_path: String, @@ -161,6 +166,12 @@ pub fn parse(text: &str) -> Result, ParseError> { .ok_or(ParseError::ProviderMissingId)?; SlashCommand::Provider(id.to_lowercase()) } + "fork" => { + // `/fork` keeps the parent backend; `/fork ` continues + // the branch on another one. The backend id is a single token. + let provider_id = rest_of_line.first().map(|s| s.to_lowercase()); + SlashCommand::Fork { provider_id } + } "rename" | "mv" => { // Everything after the command is the full name — users may // want spaces or punctuation in labels. Reject empty / pure @@ -198,7 +209,7 @@ pub fn parse(text: &str) -> Result, ParseError> { }; SlashCommand::Export { path } } - "import" | "fork" => { + "import" => { if rest_of_line.len() < 2 { return Err(ParseError::ImportMissingArgs); } @@ -241,6 +252,10 @@ pub const CATALOG: &[(&str, &str)] = &[ "/provider ", "switch the session's backend (claude, pi, …)", ), + ( + "/fork [backend]", + "branch this session, optionally onto another backend", + ), ("/rename ", "rename the focused session"), ("/destroy", "destroy the focused session"), ("/interrupt", "stop the running agent"), @@ -542,6 +557,27 @@ mod tests { assert_eq!(parse("/provider"), Err(ParseError::ProviderMissingId)); } + #[test] + fn fork_command_parses_with_optional_lowercased_backend() { + // Bare `/fork` keeps the parent backend (no id). + assert_eq!( + parse("/fork"), + Ok(Some(SlashCommand::Fork { provider_id: None })) + ); + // `/fork ` continues the branch elsewhere, lowercased. + assert_eq!( + parse("/fork CODEX"), + Ok(Some(SlashCommand::Fork { + provider_id: Some("codex".into()) + })) + ); + } + + #[test] + fn fork_is_in_catalog() { + assert!(CATALOG.iter().any(|(usage, _)| usage.starts_with("/fork"))); + } + #[test] fn new_accepts_a_provider_flag_anywhere() { let expected = SlashCommand::New {