diff --git a/crates/codeoid-protocol/src/lib.rs b/crates/codeoid-protocol/src/lib.rs index ea4f722..1902206 100644 --- a/crates/codeoid-protocol/src/lib.rs +++ b/crates/codeoid-protocol/src/lib.rs @@ -48,7 +48,8 @@ pub use message::{ ContentPart, IdentityType, MessageIdentity, MessageRole, SessionMessage, SessionMessageDelta, }; pub use session::{ - ForkedFrom, SessionInfo, SessionMode, SessionStatus, SessionUsage, Subagent, TurnUsage, + ForkedFrom, SessionInfo, SessionMode, SessionStatus, SessionUsage, SessionWorktree, Subagent, + TurnUsage, }; pub use tool::{CancelReason, ConfirmedBy, ToolInfo, ToolPhase, ToolState}; diff --git a/crates/codeoid-protocol/src/session.rs b/crates/codeoid-protocol/src/session.rs index 5ac9717..3cdd526 100644 --- a/crates/codeoid-protocol/src/session.rs +++ b/crates/codeoid-protocol/src/session.rs @@ -88,6 +88,12 @@ pub struct SessionInfo { /// Rendered as a "⑃ from ·t" tag in the session title. #[serde(default, skip_serializing_if = "Option::is_none")] pub forked_from: Option, + + /// Git worktree backing this session's workdir (fork isolation / bind). + /// Absent = shares its workdir with no git isolation. Rendered as a + /// "⎇ " tag in the session title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option, } /// Where a forked session came from — the parent id, the parent's name at @@ -101,6 +107,17 @@ pub struct ForkedFrom { pub at_turn: u32, } +/// A git worktree backing a session's workdir. `created_by_codeoid` marks a +/// worktree codeoid created for fork isolation (removed on destroy) versus one +/// the user bound (never touched). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionWorktree { + pub path: String, + pub branch: String, + pub created_by_codeoid: bool, +} + /// Rotation telemetry — how many times the backing Claude Code session has /// been rolled over to avoid context compaction. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/codeoid-protocol/tests/wire_format.rs b/crates/codeoid-protocol/tests/wire_format.rs index b2b305f..5556a83 100644 --- a/crates/codeoid-protocol/tests/wire_format.rs +++ b/crates/codeoid-protocol/tests/wire_format.rs @@ -113,6 +113,11 @@ fn sample_session_info() -> SessionInfo { name: "Sandbox".into(), at_turn: 12, }), + worktree: Some(codeoid_protocol::SessionWorktree { + path: "/repo-worktrees/fix-a1b2".into(), + branch: "codeoid/fix-a1b2".into(), + created_by_codeoid: true, + }), } } diff --git a/crates/codeoid-tui/src/app.rs b/crates/codeoid-tui/src/app.rs index 6b58540..721db61 100644 --- a/crates/codeoid-tui/src/app.rs +++ b/crates/codeoid-tui/src/app.rs @@ -2225,6 +2225,7 @@ mod tests { fallback_model: None, provider_id: None, forked_from: None, + worktree: None, }); state } diff --git a/crates/codeoid-tui/src/state/mod.rs b/crates/codeoid-tui/src/state/mod.rs index 8684895..0b48136 100644 --- a/crates/codeoid-tui/src/state/mod.rs +++ b/crates/codeoid-tui/src/state/mod.rs @@ -766,6 +766,7 @@ mod tests { fallback_model: None, provider_id: None, forked_from: None, + worktree: None, } } diff --git a/crates/codeoid-tui/src/state/sessions.rs b/crates/codeoid-tui/src/state/sessions.rs index e7a40bc..e7ad9a5 100644 --- a/crates/codeoid-tui/src/state/sessions.rs +++ b/crates/codeoid-tui/src/state/sessions.rs @@ -129,6 +129,7 @@ mod tests { fallback_model: None, provider_id: None, forked_from: None, + worktree: None, } } diff --git a/crates/codeoid-tui/src/ui/approval.rs b/crates/codeoid-tui/src/ui/approval.rs index c6e80ed..102113b 100644 --- a/crates/codeoid-tui/src/ui/approval.rs +++ b/crates/codeoid-tui/src/ui/approval.rs @@ -291,6 +291,7 @@ mod tests { fallback_model: None, provider_id: None, forked_from: None, + worktree: None, }); let mut m = msg( MessageRole::ToolCall, diff --git a/crates/codeoid-tui/src/ui/scrollback.rs b/crates/codeoid-tui/src/ui/scrollback.rs index 7a51992..87a21ff 100644 --- a/crates/codeoid-tui/src/ui/scrollback.rs +++ b/crates/codeoid-tui/src/ui/scrollback.rs @@ -353,6 +353,17 @@ fn session_title(session: &SessionInfo) -> Line<'static> { .fg(Color::Magenta) .add_modifier(Modifier::ITALIC), ), + // Isolated git worktree — mirrors the web WorktreeChip. Shows the + // branch, so a fork running in its own worktree is self-describing. + Span::styled( + match &session.worktree { + Some(w) => format!(" · ⎇ {}", truncate_name(&w.branch, 24)), + None => String::new(), + }, + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::ITALIC), + ), Span::raw(" "), ]) } @@ -631,6 +642,7 @@ mod tests { fallback_model: None, provider_id: None, forked_from: None, + worktree: None, } } @@ -969,6 +981,32 @@ mod tests { assert!(!title.contains("⑃"), "{title}"); } + #[test] + fn session_title_shows_worktree() { + let mut session = mk_session("wt-1"); + session.worktree = Some(codeoid_protocol::SessionWorktree { + path: "/repo-worktrees/fix-a1b2".into(), + branch: "codeoid/fix-a1b2".into(), + created_by_codeoid: true, + }); + let title: String = session_title(&session) + .spans + .iter() + .map(|sp| sp.content.clone().into_owned()) + .collect(); + assert!(title.contains("⎇"), "{title}"); + assert!(title.contains("codeoid/fix-a1b2"), "{title}"); + + // A session without a worktree shows no worktree tag. + let plain = mk_session("s2"); + let title: String = session_title(&plain) + .spans + .iter() + .map(|sp| sp.content.clone().into_owned()) + .collect(); + assert!(!title.contains("⎇"), "{title}"); + } + #[test] fn truncate_name_caps_long_parent_names() { assert_eq!(truncate_name("short", 20), "short"); diff --git a/crates/codeoid-tui/src/ui/worker.rs b/crates/codeoid-tui/src/ui/worker.rs index 3c04dad..7b7760b 100644 --- a/crates/codeoid-tui/src/ui/worker.rs +++ b/crates/codeoid-tui/src/ui/worker.rs @@ -372,6 +372,7 @@ mod tests { fallback_model: None, provider_id: None, forked_from: None, + worktree: None, }); state.provider_commands.insert( "s1".into(),