Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,95 @@
mod v1;
use serde::Deserialize;
use crate::terminal::CLIAgent;
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
type EventParser = fn(&str) -> Option<CLIAgentEvent>;
/// Sentinel title that identifies structured CLI agent events sent via OSC 777.
/// The `"agent"` field in the JSON body distinguishes which agent sent it.
pub const CLI_AGENT_NOTIFICATION_SENTINEL: &str = "warp://cli-agent";
/// The event type encoded in the `"event"` field of the JSON body.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CLIAgentEventType {
SessionStart,
PromptSubmit,
ToolComplete,
Stop,
PermissionRequest,
PermissionReplied,
QuestionAsked,
IdlePrompt,
Unknown(String),
}
/// Event-specific fields that vary by event type.
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub struct CLIAgentEventPayload {
pub query: Option<String>,
pub response: Option<String>,
pub transcript_path: Option<String>,
pub summary: Option<String>,
pub tool_name: Option<String>,
pub tool_input_preview: Option<String>,
pub plugin_version: Option<String>,
}
/// A parsed event from a CLI agent plugin.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct CLIAgentEvent {
pub v: u32,
pub agent: CLIAgent,
pub event: CLIAgentEventType,
pub session_id: Option<String>,
pub cwd: Option<String>,
pub project: Option<String>,
pub payload: CLIAgentEventPayload,
}
/// Version-specific parsers, indexed by (version - 1).
/// Adding a new version means appending a parser here,
/// which automatically bumps `current_protocol_version()`.
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
const VERSIONED_PARSERS: &[EventParser] = &[v1::parse];
/// The current CLI agent protocol version this build of Warp supports.
/// Exported as the `WARP_CLI_AGENT_PROTOCOL_VERSION` env var on the PTY
/// so plugins can negotiate a compatible payload format.
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
pub const fn current_protocol_version() -> u32 {
VERSIONED_PARSERS.len() as u32
}
/// Attempts to parse an OSC 777 `PluggableNotification` into a typed `CLIAgentEvent`.
/// Dispatches to the correct version-specific parser based on the `"v"` field. Returns `None`
/// if the title doesn't match the sentinel, the body isn't valid JSON, or the version is unsupported.
pub fn parse_event(title: Option<&str>, body: &str) -> Option<CLIAgentEvent> {
if title? != CLI_AGENT_NOTIFICATION_SENTINEL {
return None;
}
let version_probe: VersionProbe = serde_json::from_str(body).ok()?;
let version = version_probe.v.unwrap_or(1);
let index = (version as usize).checked_sub(1)?;
match VERSIONED_PARSERS.get(index) {
Some(parser) => parser(body),
None => {
log::error!(
"Received CLI agent event with unsupported schema version \
{version}. The CLI agent plugin or Warp may need to be updated."
);
None
}
}
}
#[derive(Deserialize)]
struct VersionProbe {
v: Option<u32>,
}
@@ -0,0 +1,76 @@
use serde::Deserialize;
use crate::terminal::CLIAgent;
use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType};
/// Resolves a CLI agent from the `"agent"` string in a CLI agent event.
/// Returns `None` if the string doesn't match any known agent.
fn resolve_agent(agent: &str) -> Option<CLIAgent> {
enum_iterator::all::<CLIAgent>()
.find(|a| !matches!(a, CLIAgent::Unknown) && a.command_prefix() == agent)
}
pub(super) fn parse(body: &str) -> Option<CLIAgentEvent> {
let raw: RawEvent = serde_json::from_str(body).ok()?;
let event = match raw.event.as_str() {
"session_start" => CLIAgentEventType::SessionStart,
"prompt_submit" => CLIAgentEventType::PromptSubmit,
"tool_complete" => CLIAgentEventType::ToolComplete,
"stop" => CLIAgentEventType::Stop,
"permission_request" => CLIAgentEventType::PermissionRequest,
"permission_replied" => CLIAgentEventType::PermissionReplied,
"question_asked" => CLIAgentEventType::QuestionAsked,
"idle_prompt" => CLIAgentEventType::IdlePrompt,
other => CLIAgentEventType::Unknown(other.to_string()),
};
let tool_input_preview = raw.tool_input.as_ref().and_then(|val| {
val.get("command")
.or_else(|| val.get("file_path"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let agent = raw
.agent
.as_deref()
.and_then(resolve_agent)
.unwrap_or(CLIAgent::Unknown);
Some(CLIAgentEvent {
v: raw.v.unwrap_or(1),
agent,
event,
session_id: raw.session_id,
cwd: raw.cwd,
project: raw.project,
payload: CLIAgentEventPayload {
query: raw.query,
response: raw.response,
transcript_path: raw.transcript_path,
summary: raw.summary,
tool_name: raw.tool_name,
tool_input_preview,
plugin_version: raw.plugin_version,
},
})
}
#[derive(Deserialize)]
struct RawEvent {
v: Option<u32>,
agent: Option<String>,
event: String,
session_id: Option<String>,
cwd: Option<String>,
project: Option<String>,
query: Option<String>,
response: Option<String>,
transcript_path: Option<String>,
summary: Option<String>,
tool_name: Option<String>,
tool_input: Option<serde_json::Value>,
plugin_version: Option<String>,
}
@@ -0,0 +1,289 @@
use warpui::{EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::{CLIAgentEvent, CLIAgentSessionsModel};
use crate::terminal::cli_agent_sessions::event::parse_event;
use crate::terminal::cli_agent_sessions::event::{CLIAgentEventPayload, CLIAgentEventType};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::CLIAgent;
/// Per-agent handler that filters and transforms parsed CLI agent events.
/// Each CLI agent can have a different implementation depending on which events
/// it cares about.
trait CLIAgentSessionHandler {
/// Attempt to parse a raw `PluggableNotification` into a typed event.
/// The default implementation delegates to the structured JSON parser
/// (`parse_event`); agents with non-JSON notification formats (e.g. Codex
/// OSC 9 plain text) should override this.
fn try_parse(&self, title: Option<&str>, body: &str) -> Option<CLIAgentEvent> {
parse_event(title, body)
}
/// Decide whether a parsed event should be forwarded to the sessions model.
/// Returns the event (possibly transformed) if it should be processed.
fn handle_event(&mut self, event: CLIAgentEvent) -> Option<CLIAgentEvent>;
/// Whether this handler provides meaningful, fine-grained status
/// (e.g. in-progress / blocked / success) that should be shown in the UI.
/// Handlers backed by the structured plugin protocol report rich status;
/// handlers that only forward opaque OS notifications (e.g. Codex) do not.
fn supports_rich_status(&self) -> bool {
true
}
}
/// Whether the listener for the given agent provides rich status.
/// Returns `false` for agents without a handler or whose handler opts out.
pub fn agent_supports_rich_status(agent: &CLIAgent) -> bool {
create_handler(agent).is_some_and(|h| h.supports_rich_status())
}
/// Returns `true` if the given CLI agent has a supported session handler.
pub fn is_agent_supported(agent: &CLIAgent) -> bool {
matches!(
agent,
CLIAgent::Claude
| CLIAgent::OpenCode
| CLIAgent::Codex
| CLIAgent::Gemini
| CLIAgent::Auggie
)
}
/// Creates the appropriate handler for the given CLI agent.
fn create_handler(agent: &CLIAgent) -> Option<Box<dyn CLIAgentSessionHandler>> {
match agent {
// Auggie is supported via the community-maintained auggie-warp plugin
// (https://github.com/augmentmoogi/auggie-warp), which emits the same
// structured OSC 777 events as the first-party Claude/OpenCode/Gemini
// plugins. We don't ship an install flow for it — we just listen.
CLIAgent::Claude | CLIAgent::OpenCode | CLIAgent::Gemini | CLIAgent::Auggie => {
Some(Box::new(DefaultSessionListener))
}
CLIAgent::Codex => Some(Box::new(CodexSessionHandler)),
CLIAgent::Amp
| CLIAgent::Droid
| CLIAgent::Copilot
| CLIAgent::Pi
| CLIAgent::CursorCli
| CLIAgent::Unknown => None,
}
}
/// Default handler shared by agents whose events need no special filtering
/// beyond skipping the initial `SessionStart`.
struct DefaultSessionListener;
impl CLIAgentSessionHandler for DefaultSessionListener {
fn handle_event(&mut self, event: CLIAgentEvent) -> Option<CLIAgentEvent> {
// Skip session_start events (handled during listener construction)
if event.event == CLIAgentEventType::SessionStart {
return None;
}
Some(event)
}
}
/// Codex-specific handler that parses plain-text OSC 9 desktop notifications
/// into CLI agent events.
///
/// Codex sends notifications via OSC 9 (`\x1b]9;message\x07`) with
/// human-readable text. Since there's no way to distinguish notification types
/// from the raw text, all OSC 9 notifications are treated as `Stop` (success).
/// The notification body becomes the event's `query` so it surfaces as the
/// notification title in the UI.
struct CodexSessionHandler;
impl CodexSessionHandler {
/// Parse a plain-text OSC 9 notification body into a `CLIAgentEvent`.
/// Returns `None` only for empty bodies.
fn parse_osc9_text(body: &str) -> Option<CLIAgentEvent> {
let body = body.trim();
if body.is_empty() {
return None;
}
Some(CLIAgentEvent {
v: 1,
agent: CLIAgent::Codex,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload {
query: Some(body.to_owned()),
..Default::default()
},
})
}
}
impl CLIAgentSessionHandler for CodexSessionHandler {
/// Codex sends plain-text OSC 9 notifications (title = `None`) instead of
/// the structured OSC 777 JSON used by Claude Code / OpenCode.
fn try_parse(&self, title: Option<&str>, body: &str) -> Option<CLIAgentEvent> {
// If the notification carries the structured sentinel, try the normal
// JSON parser first (future-proofing in case Codex adds plugin
// support later).
if let Some(parsed) = parse_event(title, body) {
return Some(parsed);
}
// OSC 9 notifications have no title.
if title.is_some() {
return None;
}
Self::parse_osc9_text(body)
}
fn handle_event(&mut self, event: CLIAgentEvent) -> Option<CLIAgentEvent> {
Some(event)
}
fn supports_rich_status(&self) -> bool {
false
}
}
/// Per-agent listener that subscribes to PTY events and forwards them to the
/// sessions model. Stored on [`super::CLIAgentSession`] so its lifetime is
/// tied to the session; dropping the handle cleans up the subscription.
pub struct CLIAgentSessionListener {
terminal_view_id: EntityId,
inner: Box<dyn CLIAgentSessionHandler>,
}
impl warpui::Entity for CLIAgentSessionListener {
type Event = ();
}
impl CLIAgentSessionListener {
pub fn new(
terminal_view_id: EntityId,
agent: CLIAgent,
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
ctx: &mut ModelContext<Self>,
) -> Self {
let handler =
create_handler(&agent).expect("is_agent_supported must be checked before calling new");
// Subscribe to subsequent OSC events from this terminal's PTY.
// Parsing is delegated to the handler's `try_parse`; the handler's
// `handle_event` then filters/transforms the result.
ctx.subscribe_to_model(model_event_dispatcher, move |me, event, ctx| {
if let ModelEvent::PluggableNotification { title, body } = event {
let Some(parsed) = me.inner.try_parse(title.as_deref(), body) else {
return;
};
if let Some(event) = me.inner.handle_event(parsed) {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| {
sessions_model.update_from_event(me.terminal_view_id, &event, ctx);
});
}
}
});
Self {
terminal_view_id,
inner: handler,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::cli_agent_sessions::event::CLIAgentEventType;
#[test]
fn codex_parses_any_text_as_stop() {
let event = CodexSessionHandler::parse_osc9_text("Agent turn complete").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(event.agent, CLIAgent::Codex);
assert_eq!(event.payload.query.as_deref(), Some("Agent turn complete"));
}
#[test]
fn codex_body_becomes_query() {
let event = CodexSessionHandler::parse_osc9_text(
"I've updated the README with the new instructions.",
)
.unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(
event.payload.query.as_deref(),
Some("I've updated the README with the new instructions.")
);
}
#[test]
fn codex_approval_text_still_becomes_stop() {
let event =
CodexSessionHandler::parse_osc9_text("Approval requested: rm -rf /tmp/foo").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(
event.payload.query.as_deref(),
Some("Approval requested: rm -rf /tmp/foo")
);
}
#[test]
fn codex_ignores_empty_body() {
assert!(CodexSessionHandler::parse_osc9_text("").is_none());
assert!(CodexSessionHandler::parse_osc9_text(" ").is_none());
}
#[test]
fn codex_try_parse_ignores_titled_notifications() {
let handler = CodexSessionHandler;
assert!(handler
.try_parse(Some("some-title"), "Agent turn complete")
.is_none());
}
#[test]
fn codex_try_parse_handles_osc9() {
let handler = CodexSessionHandler;
let event = handler.try_parse(None, "Agent turn complete").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
}
#[test]
fn auggie_is_supported() {
assert!(is_agent_supported(&CLIAgent::Auggie));
}
#[test]
fn auggie_uses_default_handler_with_rich_status() {
assert!(agent_supports_rich_status(&CLIAgent::Auggie));
}
#[test]
fn auggie_default_handler_skips_session_start() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
v: 1,
agent: CLIAgent::Auggie,
event: CLIAgentEventType::SessionStart,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_none());
}
#[test]
fn auggie_default_handler_forwards_stop() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
v: 1,
agent: CLIAgent::Auggie,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_some());
}
}
+534
View File
@@ -0,0 +1,534 @@
pub mod event;
pub mod listener;
#[cfg(not(target_family = "wasm"))]
pub(crate) mod plugin_manager;
use std::collections::{HashMap, HashSet};
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::blocklist::InputConfig;
use self::listener::CLIAgentSessionListener;
use super::CLIAgent;
use event::{CLIAgentEvent, CLIAgentEventType};
/// Status of a tracked CLI agent session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CLIAgentSessionStatus {
InProgress,
Success,
Blocked { message: Option<String> },
}
impl CLIAgentSessionStatus {
pub fn to_conversation_status(&self) -> crate::ai::agent::conversation::ConversationStatus {
use crate::ai::agent::conversation::ConversationStatus;
match self {
CLIAgentSessionStatus::InProgress => ConversationStatus::InProgress,
CLIAgentSessionStatus::Success => ConversationStatus::Success,
CLIAgentSessionStatus::Blocked { message } => ConversationStatus::Blocked {
blocked_action: message.clone().unwrap_or_default(),
},
}
}
}
/// Rich context accumulated from CLI agent session events.
#[derive(Debug, Clone, Default)]
pub struct CLIAgentSessionContext {
pub cwd: Option<String>,
pub project: Option<String>,
pub session_id: Option<String>,
pub tool_name: Option<String>,
pub tool_input_preview: Option<String>,
pub summary: Option<String>,
pub query: Option<String>,
pub response: Option<String>,
}
/// State of the rich input editor for composing a prompt to send to a CLI agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CLIAgentInputState {
/// The rich input editor is not open.
Closed,
/// The rich input editor is open.
Open {
/// How this session was opened (for telemetry).
entrypoint: CLIAgentInputEntrypoint,
/// The input config that was active before opening rich input.
previous_input_config: InputConfig,
/// Whether the previous lock state was established while the input buffer was empty.
previous_was_lock_set_with_empty_buffer: bool,
},
}
/// Why the CLI agent rich input was closed (for telemetry).
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum CLIAgentRichInputCloseReason {
/// User explicitly closed (Escape, Ctrl-G, footer button).
Manual,
/// Auto-closed due to agent status change (e.g. Blocked).
AutoToggle,
/// Auto-dismissed after submitting a prompt.
Submit,
/// Closed for another reason (chip removed, session ended, shared session sync).
Other,
}
/// How a [`CLIAgentInputState`] was opened.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum CLIAgentInputEntrypoint {
/// User pressed Ctrl-G while a CLI agent was active.
CtrlG,
/// User clicked the rich input button in the CLI agent footer.
FooterButton,
/// Automatically opened when the CLI agent resumed work (left a blocked state)
/// and the auto-show setting is enabled.
AutoShow,
/// Rich input was opened to mirror a shared-session participant's state.
SharedSessionSync,
}
impl CLIAgentSessionContext {
pub(crate) fn display_title(&self) -> Option<String> {
self.latest_user_prompt().or_else(|| self.title_like_text())
}
pub(crate) fn latest_user_prompt(&self) -> Option<String> {
self.query
.as_deref()
.map(str::trim)
.filter(|query| !query.is_empty())
.map(str::to_owned)
}
/// Returns summary text suitable as a fallback title when no user prompt is available.
pub(crate) fn title_like_text(&self) -> Option<String> {
self.summary
.as_deref()
.map(str::trim)
.filter(|summary| !summary.is_empty())
.map(str::to_owned)
}
}
/// A tracked CLI agent session.
#[derive(Debug, Clone)]
pub struct CLIAgentSession {
pub agent: CLIAgent,
pub status: CLIAgentSessionStatus,
pub session_context: CLIAgentSessionContext,
/// Rich input editor state.
pub input_state: CLIAgentInputState,
/// Whether status-driven auto-toggle is enabled for this session.
pub should_auto_toggle_input: bool,
/// Plugin-backed event listener, if the CLI agent plugin is installed.
/// `None` for sessions created by command detection alone.
/// Dropping this handle cleans up the listener's PTY event subscription.
pub listener: Option<ModelHandle<CLIAgentSessionListener>>,
/// The plugin version reported by the `SessionStart` event.
/// `None` if the plugin predates version reporting or hasn't connected yet.
pub plugin_version: Option<String>,
/// `None` when the session is local.
/// `Some("user@hostname")` when running over SSH (warpified or legacy).
/// Used as a key for per-host plugin install failure tracking.
pub remote_host: Option<String>,
/// Draft text saved from the rich input composer when it was closed.
/// Restored into the editor when the composer is reopened.
pub draft_text: Option<String>,
/// When the session was detected via a custom toolbar command pattern,
/// the first word of the command (the binary/alias the user typed).
/// Used to customize plugin instructions and force manual install mode.
pub custom_command_prefix: Option<String>,
}
impl CLIAgentSession {
pub fn is_remote(&self) -> bool {
self.remote_host.is_some()
}
/// Applies an event to this session, updating context and status.
/// Returns the new status if it changed, or `None` if the event was irrelevant.
fn apply_event(&mut self, event: &CLIAgentEvent) -> Option<CLIAgentSessionStatus> {
self.session_context.cwd = event.cwd.clone().or(self.session_context.cwd.take());
self.session_context.project = event
.project
.clone()
.or(self.session_context.project.take());
self.session_context.session_id = event
.session_id
.clone()
.or(self.session_context.session_id.take());
let new_status = match &event.event {
CLIAgentEventType::PromptSubmit => {
self.session_context.query = event.payload.query.clone();
self.session_context.response = None;
CLIAgentSessionStatus::InProgress
}
CLIAgentEventType::ToolComplete => {
if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) {
return None;
}
CLIAgentSessionStatus::InProgress
}
CLIAgentEventType::Stop => {
self.session_context.query = event.payload.query.clone();
self.session_context.response = event.payload.response.clone();
CLIAgentSessionStatus::Success
}
CLIAgentEventType::PermissionRequest => {
self.session_context.summary = event.payload.summary.clone();
self.session_context.tool_name = event.payload.tool_name.clone();
self.session_context.tool_input_preview = event.payload.tool_input_preview.clone();
CLIAgentSessionStatus::Blocked {
message: event.payload.summary.clone(),
}
}
CLIAgentEventType::QuestionAsked => CLIAgentSessionStatus::Blocked {
message: event
.payload
.summary
.clone()
.or_else(|| Some("Waiting for your answer".to_owned())),
},
CLIAgentEventType::PermissionReplied => {
if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) {
return None;
}
CLIAgentSessionStatus::InProgress
}
// IdlePrompt means the agent is sitting at its prompt waiting for input.
// This should not affect status — otherwise it would override Success after a Stop event.
CLIAgentEventType::IdlePrompt => return None,
CLIAgentEventType::SessionStart => {
self.plugin_version = event.payload.plugin_version.clone();
return None;
}
CLIAgentEventType::Unknown(_) => return None,
};
self.status = new_status.clone();
Some(new_status)
}
}
/// Events emitted by `CLIAgentSessionsModel` for subscribers (e.g., `AgentNotificationsModel`).
#[allow(dead_code)] // `agent` fields on Started/InputSessionChanged/Ended are used for logging and future subscribers.
#[derive(Debug, Clone)]
pub enum CLIAgentSessionsModelEvent {
Started {
terminal_view_id: EntityId,
agent: CLIAgent,
},
StatusChanged {
terminal_view_id: EntityId,
agent: CLIAgent,
status: CLIAgentSessionStatus,
session_context: Box<CLIAgentSessionContext>,
},
InputSessionChanged {
terminal_view_id: EntityId,
agent: CLIAgent,
/// The input state BEFORE this change. When transitioning from
/// `Open` → `Closed`, contains the saved input config to restore.
previous_input_state: CLIAgentInputState,
/// The input state AFTER this change.
new_input_state: CLIAgentInputState,
},
Ended {
terminal_view_id: EntityId,
agent: CLIAgent,
},
/// The agent session has been updated. Subscribers may use this as a trigger for best-effort
/// saving of state derived from the agent's session.
SessionUpdated {
terminal_view_id: EntityId,
agent: CLIAgent,
},
}
impl CLIAgentSessionsModelEvent {
pub fn terminal_view_id(&self) -> EntityId {
match self {
CLIAgentSessionsModelEvent::Started {
terminal_view_id, ..
}
| CLIAgentSessionsModelEvent::StatusChanged {
terminal_view_id, ..
}
| CLIAgentSessionsModelEvent::InputSessionChanged {
terminal_view_id, ..
}
| CLIAgentSessionsModelEvent::Ended {
terminal_view_id, ..
}
| CLIAgentSessionsModelEvent::SessionUpdated {
terminal_view_id, ..
} => *terminal_view_id,
}
}
}
/// Singleton model that tracks pane-scoped CLI agent state and plugin-enriched session context.
pub struct CLIAgentSessionsModel {
sessions: HashMap<EntityId, CLIAgentSession>,
/// Tracks (agent, remote_host) pairs where an auto plugin operation (install or update) has failed.
/// Shared across all views so failure in one tab is reflected everywhere.
plugin_auto_failures: HashSet<(CLIAgent, Option<String>)>,
}
impl Entity for CLIAgentSessionsModel {
type Event = CLIAgentSessionsModelEvent;
}
impl SingletonEntity for CLIAgentSessionsModel {}
impl CLIAgentSessionsModel {
pub fn new() -> Self {
Self {
sessions: HashMap::new(),
plugin_auto_failures: HashSet::new(),
}
}
pub fn session(&self, terminal_view_id: EntityId) -> Option<&CLIAgentSession> {
self.sessions.get(&terminal_view_id)
}
/// Returns `true` if the rich input editor is currently open for this terminal.
pub fn is_input_open(&self, terminal_view_id: EntityId) -> bool {
self.sessions
.get(&terminal_view_id)
.is_some_and(|s| matches!(s.input_state, CLIAgentInputState::Open { .. }))
}
/// Registers a plugin-backed listener on the session for this terminal.
///
/// If a session for the same agent already exists (e.g. created earlier by
/// command detection), it is upgraded with the listener and plugin context.
/// Otherwise a new session is created.
///
/// The optional `cwd` / `project` / `session_id` fields supply initial
/// context when available (e.g. from a `SessionStart` event). Passing
/// `None` for all three is fine — happens when the plugin is installed
/// mid-session and there is no start event to extract context from.
#[allow(clippy::too_many_arguments)]
pub fn register_listener(
&mut self,
terminal_view_id: EntityId,
agent: CLIAgent,
cwd: Option<String>,
project: Option<String>,
session_id: Option<String>,
plugin_version: Option<String>,
remote_host: Option<String>,
should_auto_toggle_input: bool,
listener: ModelHandle<CLIAgentSessionListener>,
ctx: &mut ModelContext<Self>,
) {
if let Some(session) = self
.sessions
.get_mut(&terminal_view_id)
.filter(|s| s.agent == agent)
{
// Upgrade existing session with plugin context.
session.status = CLIAgentSessionStatus::InProgress;
session.listener = Some(listener);
session.plugin_version = plugin_version;
session.remote_host = remote_host;
session.should_auto_toggle_input = should_auto_toggle_input;
session.session_context.cwd = cwd.or(session.session_context.cwd.take());
session.session_context.project = project.or(session.session_context.project.take());
session.session_context.session_id =
session_id.or(session.session_context.session_id.take());
return;
}
self.set_session(
terminal_view_id,
CLIAgentSession {
agent,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext {
cwd,
project,
session_id,
..Default::default()
},
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input,
listener: Some(listener),
plugin_version,
remote_host,
draft_text: None,
custom_command_prefix: None,
},
ctx,
);
}
pub fn remove_session(&mut self, terminal_view_id: EntityId, ctx: &mut ModelContext<Self>) {
if let Some(session) = self.sessions.remove(&terminal_view_id) {
ctx.emit(CLIAgentSessionsModelEvent::Ended {
terminal_view_id,
agent: session.agent,
});
}
}
/// Updates the session's status and context from a parsed CLI agent event.
pub fn update_from_event(
&mut self,
terminal_view_id: EntityId,
event: &CLIAgentEvent,
ctx: &mut ModelContext<Self>,
) {
let Some(session) = self.sessions.get_mut(&terminal_view_id) else {
return;
};
let event_type = &event.event;
if let Some(new_status) = session.apply_event(event) {
let agent = session.agent;
ctx.emit(CLIAgentSessionsModelEvent::StatusChanged {
terminal_view_id,
agent,
status: new_status,
session_context: Box::new(session.session_context.clone()),
});
}
if matches!(
event_type,
CLIAgentEventType::SessionStart
| CLIAgentEventType::PromptSubmit
| CLIAgentEventType::ToolComplete
) {
ctx.emit(CLIAgentSessionsModelEvent::SessionUpdated {
terminal_view_id,
agent: session.agent,
});
}
}
pub fn open_input(
&mut self,
terminal_view_id: EntityId,
entrypoint: CLIAgentInputEntrypoint,
previous_input_config: InputConfig,
previous_was_lock_set_with_empty_buffer: bool,
should_auto_toggle_input: bool,
ctx: &mut ModelContext<Self>,
) {
let Some(session) = self.sessions.get_mut(&terminal_view_id) else {
return;
};
let previous_input_state = session.input_state;
session.input_state = CLIAgentInputState::Open {
entrypoint,
previous_input_config,
previous_was_lock_set_with_empty_buffer,
};
session.should_auto_toggle_input = should_auto_toggle_input;
ctx.emit(CLIAgentSessionsModelEvent::InputSessionChanged {
terminal_view_id,
agent: session.agent,
previous_input_state,
new_input_state: session.input_state,
});
}
pub fn close_input(
&mut self,
terminal_view_id: EntityId,
should_auto_toggle_input: bool,
ctx: &mut ModelContext<Self>,
) {
let Some(session) = self.sessions.get_mut(&terminal_view_id) else {
return;
};
if session.input_state == CLIAgentInputState::Closed {
return;
}
let previous_input_state = session.input_state;
session.input_state = CLIAgentInputState::Closed;
session.should_auto_toggle_input = should_auto_toggle_input;
ctx.emit(CLIAgentSessionsModelEvent::InputSessionChanged {
terminal_view_id,
agent: session.agent,
previous_input_state,
new_input_state: CLIAgentInputState::Closed,
});
}
pub fn set_session(
&mut self,
terminal_view_id: EntityId,
session: CLIAgentSession,
ctx: &mut ModelContext<Self>,
) {
let agent = session.agent;
// Close any open rich input before replacing, so subscribers can
// restore input config before the session ends.
self.close_input(terminal_view_id, false, ctx);
if let Some(old) = self.sessions.insert(terminal_view_id, session) {
ctx.emit(CLIAgentSessionsModelEvent::Ended {
terminal_view_id,
agent: old.agent,
});
}
ctx.emit(CLIAgentSessionsModelEvent::Started {
terminal_view_id,
agent,
});
}
/// Records that an auto plugin operation (install or update) failed for the given agent/host.
/// `remote_host` is `None` for local sessions, `Some("user@hostname")` for remote.
#[cfg(not(target_family = "wasm"))]
pub fn record_plugin_auto_failure(&mut self, agent: CLIAgent, remote_host: Option<String>) {
self.plugin_auto_failures.insert((agent, remote_host));
}
/// Saves draft text from the rich input composer for the given terminal.
/// Stores `None` for empty or whitespace-only text.
pub fn set_draft(&mut self, terminal_view_id: EntityId, text: String) {
if let Some(session) = self.sessions.get_mut(&terminal_view_id) {
session.draft_text = if text.trim().is_empty() {
None
} else {
Some(text)
};
}
}
/// Clears any saved draft text for the given terminal.
pub fn clear_draft(&mut self, terminal_view_id: EntityId) {
if let Some(session) = self.sessions.get_mut(&terminal_view_id) {
session.draft_text = None;
}
}
/// Returns and clears the draft text for the given terminal, if any.
pub fn take_draft(&mut self, terminal_view_id: EntityId) -> Option<String> {
self.sessions
.get_mut(&terminal_view_id)
.and_then(|s| s.draft_text.take())
}
/// Whether an auto plugin operation has previously failed for this agent on this host.
pub fn has_plugin_auto_failed(&self, agent: CLIAgent, remote_host: &Option<String>) -> bool {
self.plugin_auto_failures
.contains(&(agent, remote_host.clone()))
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
@@ -0,0 +1,410 @@
use super::event::{parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType};
use super::{
CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext,
CLIAgentSessionStatus, CLIAgentSessionsModel,
};
use crate::ai::blocklist::{InputConfig, InputType};
use crate::terminal::CLIAgent;
#[test]
fn parse_stop_notification() {
let body = r#"{"v":1,"agent":"claude","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe","transcript_path":"/tmp/t.jsonl"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.v, 1);
assert_eq!(notif.agent, CLIAgent::Claude);
assert_eq!(notif.event, CLIAgentEventType::Stop);
assert_eq!(notif.session_id.as_deref(), Some("abc"));
assert_eq!(notif.cwd.as_deref(), Some("/tmp/proj"));
assert_eq!(notif.project.as_deref(), Some("proj"));
assert_eq!(notif.payload.query.as_deref(), Some("write a haiku"));
assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe"));
assert_eq!(
notif.payload.transcript_path.as_deref(),
Some("/tmp/t.jsonl")
);
}
#[test]
fn cli_agent_session_context_title_like_text_uses_trimmed_summary() {
let context = CLIAgentSessionContext {
summary: Some(" Reviewing changes ".to_string()),
query: Some("Latest prompt".to_string()),
..Default::default()
};
assert_eq!(
context.title_like_text(),
Some("Reviewing changes".to_string())
);
}
#[test]
fn cli_agent_session_context_latest_user_prompt_uses_trimmed_query() {
let context = CLIAgentSessionContext {
summary: Some("Reviewing changes".to_string()),
query: Some(" Latest prompt ".to_string()),
..Default::default()
};
assert_eq!(
context.latest_user_prompt(),
Some("Latest prompt".to_string())
);
}
#[test]
fn cli_agent_session_context_title_helpers_ignore_empty_text() {
let context = CLIAgentSessionContext {
summary: Some(" ".to_string()),
query: Some("".to_string()),
..Default::default()
};
assert_eq!(context.title_like_text(), None);
assert_eq!(context.latest_user_prompt(), None);
}
#[test]
fn parse_permission_request_notification() {
let body = r#"{"v":1,"agent":"claude","event":"permission_request","session_id":"abc","cwd":"/tmp/proj","project":"proj","summary":"Wants to run Bash: rm -rf /tmp","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp"}}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.event, CLIAgentEventType::PermissionRequest);
assert_eq!(
notif.payload.summary.as_deref(),
Some("Wants to run Bash: rm -rf /tmp")
);
assert_eq!(notif.payload.tool_name.as_deref(), Some("Bash"));
assert_eq!(
notif.payload.tool_input_preview.as_deref(),
Some("rm -rf /tmp")
);
}
#[test]
fn parse_permission_request_with_file_path() {
let body = r#"{"v":1,"agent":"claude","event":"permission_request","session_id":"abc","cwd":"/tmp","project":"tmp","tool_name":"Write","tool_input":{"file_path":"/tmp/test.py","content":"print('hi')"}}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(
notif.payload.tool_input_preview.as_deref(),
Some("/tmp/test.py")
);
}
#[test]
fn parse_idle_prompt_notification() {
let body = r#"{"v":1,"agent":"claude","event":"idle_prompt","session_id":"abc","cwd":"/tmp","project":"tmp","summary":"Claude is waiting for your input"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.event, CLIAgentEventType::IdlePrompt);
assert_eq!(
notif.payload.summary.as_deref(),
Some("Claude is waiting for your input")
);
}
#[test]
fn parse_session_start_notification() {
let body = r#"{"v":1,"agent":"claude","event":"session_start","session_id":"abc","cwd":"/tmp","project":"tmp","plugin_version":"1.1.0"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.event, CLIAgentEventType::SessionStart);
assert_eq!(notif.payload.plugin_version.as_deref(), Some("1.1.0"));
}
#[test]
fn returns_none_for_wrong_sentinel() {
let body = r#"{"v":1,"agent":"claude","event":"stop"}"#;
assert!(parse_event(Some("Claude Code"), body).is_none());
}
#[test]
fn returns_none_for_missing_title() {
let body = r#"{"v":1,"agent":"claude","event":"stop"}"#;
assert!(parse_event(None, body).is_none());
}
#[test]
fn returns_none_for_invalid_json() {
assert!(parse_event(Some("warp://cli-agent"), "not json").is_none());
}
#[test]
fn handles_unknown_event_type() {
let body = r#"{"v":1,"agent":"claude","event":"some_future_event"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(
notif.event,
CLIAgentEventType::Unknown("some_future_event".to_string())
);
}
#[test]
fn handles_missing_optional_fields() {
let body = r#"{"event":"stop"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.v, 1);
assert_eq!(notif.agent, CLIAgent::Unknown);
assert_eq!(notif.event, CLIAgentEventType::Stop);
assert!(notif.session_id.is_none());
assert!(notif.cwd.is_none());
assert!(notif.project.is_none());
assert!(notif.payload.query.is_none());
}
#[test]
fn handles_special_characters_in_values() {
let body = r#"{"v":1,"agent":"claude","event":"stop","query":"what does \"hello\" mean?","response":"It means greeting. Use: printf(\"hello\")"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(
notif.payload.query.as_deref(),
Some("what does \"hello\" mean?")
);
assert_eq!(
notif.payload.response.as_deref(),
Some("It means greeting. Use: printf(\"hello\")")
);
}
#[test]
fn rejects_unsupported_schema_version() {
let body = r#"{"v":2,"agent":"claude","event":"stop"}"#;
assert!(parse_event(Some("warp://cli-agent"), body).is_none());
}
#[test]
fn defaults_to_v1_when_version_missing() {
let body = r#"{"agent":"claude","event":"stop","query":"hi"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.v, 1);
assert_eq!(notif.payload.query.as_deref(), Some("hi"));
}
#[test]
fn explicit_v1_parses_correctly() {
let body = r#"{"v":1,"agent":"claude","event":"stop","query":"test"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.v, 1);
assert_eq!(notif.payload.query.as_deref(), Some("test"));
}
#[test]
fn parse_prompt_submit_notification() {
let body = r#"{"v":1,"agent":"claude","event":"prompt_submit","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"fix the bug"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.event, CLIAgentEventType::PromptSubmit);
assert_eq!(notif.payload.query.as_deref(), Some("fix the bug"));
}
#[test]
fn parse_tool_complete_notification() {
let body = r#"{"v":1,"agent":"claude","event":"tool_complete","session_id":"abc","cwd":"/tmp/proj","project":"proj","tool_name":"Bash"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.event, CLIAgentEventType::ToolComplete);
assert_eq!(notif.payload.tool_name.as_deref(), Some("Bash"));
}
#[test]
fn parse_auggie_stop_notification() {
// Mirrors what the community auggie-warp plugin emits on the Stop hook.
let body = r#"{"v":1,"agent":"auggie","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.agent, CLIAgent::Auggie);
assert_eq!(notif.event, CLIAgentEventType::Stop);
assert_eq!(notif.payload.query.as_deref(), Some("write a haiku"));
assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe"));
}
#[test]
fn apply_event_preserves_input_session() {
let input_state = CLIAgentInputState::Open {
entrypoint: CLIAgentInputEntrypoint::CtrlG,
previous_input_config: InputConfig {
input_type: InputType::Shell,
is_locked: false,
},
previous_was_lock_set_with_empty_buffer: true,
};
let mut session = CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state,
should_auto_toggle_input: false,
listener: None,
remote_host: None,
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
};
let event = CLIAgentEvent {
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PermissionRequest,
session_id: Some("abc".to_string()),
cwd: Some("/tmp/proj".to_string()),
project: Some("proj".to_string()),
payload: CLIAgentEventPayload {
summary: Some("Needs approval".to_string()),
..Default::default()
},
};
session.apply_event(&event);
assert_eq!(session.input_state, input_state);
}
#[test]
fn is_remote_returns_true_when_remote_host_is_set() {
let session = CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
plugin_version: None,
draft_text: None,
remote_host: Some("user@devbox".to_owned()),
custom_command_prefix: None,
};
assert!(session.is_remote());
}
#[test]
fn is_remote_returns_false_when_remote_host_is_none() {
let session = CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
remote_host: None,
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
};
assert!(!session.is_remote());
}
#[test]
fn local_failure_is_shared_across_local_sessions() {
let mut model = CLIAgentSessionsModel::new();
model.record_plugin_auto_failure(CLIAgent::Claude, None);
assert!(model.has_plugin_auto_failed(CLIAgent::Claude, &None));
}
#[test]
fn local_failure_does_not_affect_remote_host() {
let mut model = CLIAgentSessionsModel::new();
model.record_plugin_auto_failure(CLIAgent::Claude, None);
let remote = Some("user@devbox".to_owned());
assert!(!model.has_plugin_auto_failed(CLIAgent::Claude, &remote));
}
#[test]
fn remote_failure_does_not_affect_local() {
let mut model = CLIAgentSessionsModel::new();
model.record_plugin_auto_failure(CLIAgent::Claude, Some("user@devbox".to_owned()));
assert!(!model.has_plugin_auto_failed(CLIAgent::Claude, &None));
}
#[test]
fn remote_failures_are_independent_per_host() {
let mut model = CLIAgentSessionsModel::new();
let host_a = Some("user@host-a".to_owned());
let host_b = Some("user@host-b".to_owned());
model.record_plugin_auto_failure(CLIAgent::Claude, host_a.clone());
assert!(model.has_plugin_auto_failed(CLIAgent::Claude, &host_a));
assert!(!model.has_plugin_auto_failed(CLIAgent::Claude, &host_b));
}
#[test]
fn failure_tracking_is_independent_per_agent() {
let mut model = CLIAgentSessionsModel::new();
model.record_plugin_auto_failure(CLIAgent::Claude, None);
assert!(model.has_plugin_auto_failed(CLIAgent::Claude, &None));
assert!(!model.has_plugin_auto_failed(CLIAgent::Gemini, &None));
}
#[test]
fn session_start_sets_plugin_version() {
let mut session = CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
plugin_version: None,
draft_text: None,
remote_host: None,
custom_command_prefix: None,
};
let event = CLIAgentEvent {
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::SessionStart,
session_id: Some("abc".to_owned()),
cwd: Some("/tmp".to_owned()),
project: Some("proj".to_owned()),
payload: CLIAgentEventPayload {
plugin_version: Some("1.5.0".to_owned()),
..Default::default()
},
};
session.apply_event(&event);
assert_eq!(session.plugin_version.as_deref(), Some("1.5.0"));
}
#[test]
fn session_start_without_plugin_version_leaves_none() {
let mut session = CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
plugin_version: None,
draft_text: None,
remote_host: None,
custom_command_prefix: None,
};
let event = CLIAgentEvent {
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::SessionStart,
session_id: Some("abc".to_owned()),
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
session.apply_event(&event);
assert_eq!(session.plugin_version, None);
}
@@ -0,0 +1,264 @@
use std::collections::HashMap;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use async_trait::async_trait;
use serde_json::Value;
use super::{
compare_versions, run_cli_command_logged, CliAgentPluginManager, PluginInstallError,
PluginInstructionStep, PluginInstructions,
};
use crate::terminal::model::session::LocalCommandExecutor;
use crate::terminal::shell::ShellType;
const PLUGIN_KEY: &str = "warp@claude-code-warp";
const MARKETPLACE_REPO: &str = "warpdotdev/claude-code-warp";
const MARKETPLACE_NAME: &str = "claude-code-warp";
const PLATFORM_PLUGIN_KEY: &str = "oz-harness-support@claude-code-warp";
// Note: we will eventually publish this to the same marketplace repo, but are using the internal one as we build out multi-harness.
const PLATFORM_MARKETPLACE_REPO: &str = "warpdotdev/claude-code-warp-internal";
// Keep in sync with the plugin version in warpdotdev/claude-code-warp.
// (See the Versioning section of that repo's README.)
const MINIMUM_PLUGIN_VERSION: &str = "2.0.0";
pub(super) struct ClaudeCodePluginManager {
executor: LocalCommandExecutor,
path_env_var: Option<String>,
}
impl ClaudeCodePluginManager {
pub(super) fn new(
shell_path: Option<PathBuf>,
shell_type: Option<ShellType>,
path_env_var: Option<String>,
) -> Self {
let shell_type = shell_type.unwrap_or(ShellType::Bash);
Self {
executor: LocalCommandExecutor::new(shell_path, shell_type),
path_env_var,
}
}
async fn run_logged(&self, args: &[&str], log: &mut String) -> Result<(), PluginInstallError> {
let env_vars = self
.path_env_var
.as_deref()
.map(|path| HashMap::from([("PATH".to_owned(), path.to_owned())]));
run_cli_command_logged("claude", args, &self.executor, env_vars, log).await
}
}
#[async_trait]
impl CliAgentPluginManager for ClaudeCodePluginManager {
fn minimum_plugin_version(&self) -> &'static str {
MINIMUM_PLUGIN_VERSION
}
fn can_auto_install(&self) -> bool {
true
}
fn is_installed(&self) -> bool {
let Ok(claude_dir) = claude_home_dir() else {
return false;
};
check_installed(&claude_dir)
}
/// Runs `claude plugin` CLI commands via the session shell.
async fn install(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
self.run_logged(
&["plugin", "marketplace", "add", MARKETPLACE_REPO],
&mut log,
)
.await?;
self.run_logged(&["plugin", "install", PLUGIN_KEY], &mut log)
.await?;
Ok(())
}
async fn update(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
// Remove/re-add the marketplace to ensure the local clone is fresh, then
// reinstall the plugin.
// We use `plugin install` (not `plugin update`) because `marketplace
// remove` unlinks the plugin, so `plugin update` would fail with
// "Plugin is not installed".
let _ = self
.run_logged(
&["plugin", "marketplace", "remove", MARKETPLACE_NAME],
&mut log,
)
.await;
self.run_logged(
&["plugin", "marketplace", "add", MARKETPLACE_REPO],
&mut log,
)
.await?;
self.run_logged(&["plugin", "install", PLUGIN_KEY], &mut log)
.await?;
// Sanity check: verify the on-disk version actually changed.
let still_outdated = claude_home_dir()
.ok()
.and_then(|dir| installed_version(&dir))
.map(|v| compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt())
.unwrap_or(true);
if still_outdated {
log.push_str("Post-update version check: plugin is still outdated\n");
return Err(PluginInstallError {
message: "Plugin update did not take effect".to_owned(),
log,
});
}
Ok(())
}
fn install_success_message(&self) -> &'static str {
"Warp plugin installed. Please run /reload-plugins to activate."
}
fn update_success_message(&self) -> &'static str {
"Warp plugin updated. Please run /reload-plugins to activate."
}
fn install_instructions(&self) -> &'static PluginInstructions {
&INSTALL_INSTRUCTIONS
}
fn update_instructions(&self) -> &'static PluginInstructions {
&UPDATE_INSTRUCTIONS
}
fn needs_update(&self) -> bool {
let Ok(claude_dir) = claude_home_dir() else {
return false;
};
match installed_version(&claude_dir) {
Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(),
// No version field means very old plugin.
None => check_installed(&claude_dir),
}
}
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
self.run_logged(
&["plugin", "marketplace", "add", PLATFORM_MARKETPLACE_REPO],
&mut log,
)
.await?;
self.run_logged(&["plugin", "install", PLATFORM_PLUGIN_KEY], &mut log)
.await?;
Ok(())
}
}
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
PluginInstructions {
title: "Install Warp Plugin for Claude Code",
subtitle: "Ensure that jq is installed on your machine. Then, run these commands.",
steps: &[
PluginInstructionStep {
description: "Add the Warp plugin marketplace repository",
command: "claude plugin marketplace add warpdotdev/claude-code-warp",
executable: true,
link: None,
},
PluginInstructionStep {
description: "Install the Warp plugin",
command: "claude plugin install warp@claude-code-warp",
executable: true,
link: None,
},
],
post_install_notes: &[
"Restart Claude Code to activate the plugin.",
"There are some known issues with Claude Code's plugin system. \
If the plugin is not found after step 1, you can try manually adding an \"extraKnownMarketplaces\" entry to ~/.claude/settings.json.",
],
}
});
static UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| PluginInstructions {
title: "Update Warp Plugin for Claude Code",
subtitle: "Run the following commands.",
steps: &[
PluginInstructionStep {
description: "Remove the existing marketplace (if present)",
command: "claude plugin marketplace remove claude-code-warp",
executable: true,
link: None,
},
PluginInstructionStep {
description: "Re-add the marketplace",
command: "claude plugin marketplace add warpdotdev/claude-code-warp",
executable: true,
link: None,
},
PluginInstructionStep {
description: "Install the latest plugin version",
command: "claude plugin install warp@claude-code-warp",
executable: true,
link: None,
},
],
post_install_notes: &["Restart Claude Code to activate the update."],
});
fn check_installed(claude_dir: &Path) -> bool {
let plugins_path = claude_dir.join("plugins").join("installed_plugins.json");
let Ok(contents) = fs::read_to_string(plugins_path) else {
return false;
};
let Ok(parsed) = serde_json::from_str::<Value>(&contents) else {
return false;
};
parsed
.get("plugins")
.and_then(|p| p.get(PLUGIN_KEY))
.and_then(|v| v.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(false)
}
/// Reads the installed version string for the Warp plugin, if present.
fn installed_version(claude_dir: &Path) -> Option<String> {
let plugins_path = claude_dir.join("plugins").join("installed_plugins.json");
let contents = fs::read_to_string(plugins_path).ok()?;
let parsed: Value = serde_json::from_str(&contents).ok()?;
parsed
.get("plugins")?
.get(PLUGIN_KEY)?
.as_array()?
.first()?
.get("version")?
.as_str()
.map(|s| s.to_owned())
}
/// Checks `CLAUDE_HOME` env var first, falls back to `~/.claude`.
fn claude_home_dir() -> io::Result<PathBuf> {
if let Ok(claude_home) = env::var("CLAUDE_HOME") {
return Ok(PathBuf::from(claude_home));
}
dirs::home_dir()
.map(|home| home.join(".claude"))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"could not determine home directory",
)
})
}
#[cfg(test)]
#[path = "claude_tests.rs"]
mod tests;
@@ -0,0 +1,193 @@
use std::fs;
use super::{check_installed, installed_version, ClaudeCodePluginManager, CliAgentPluginManager};
#[test]
fn installed_when_plugin_present() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"warp@claude-code-warp": [{"version": "1.0.0"}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(check_installed(dir.path()));
}
#[test]
fn not_installed_when_plugin_key_absent() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"some-other-plugin": [{"version": "1.0.0"}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(!check_installed(dir.path()));
}
#[test]
fn not_installed_when_plugin_array_empty() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"warp@claude-code-warp": []
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(!check_installed(dir.path()));
}
#[test]
fn not_installed_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
assert!(!check_installed(dir.path()));
}
#[test]
fn not_installed_when_json_invalid() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
fs::write(plugins_dir.join("installed_plugins.json"), "not json").unwrap();
assert!(!check_installed(dir.path()));
}
#[test]
fn not_installed_when_plugins_key_missing() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({"other_key": "value"});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(!check_installed(dir.path()));
}
/// Tests `ClaudeCodePluginManager::is_installed` end-to-end by pointing
/// `CLAUDE_HOME` at a temp directory with a valid installed_plugins.json.
#[test]
#[serial_test::serial]
fn is_installed_via_trait_with_claude_home_env() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"warp@claude-code-warp": [{"version": "1.0.0"}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
std::env::set_var("CLAUDE_HOME", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).is_installed();
std::env::remove_var("CLAUDE_HOME");
assert!(result);
}
#[test]
#[serial_test::serial]
fn not_installed_via_trait_when_claude_home_empty() {
let dir = tempfile::tempdir().unwrap();
std::env::set_var("CLAUDE_HOME", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).is_installed();
std::env::remove_var("CLAUDE_HOME");
assert!(!result);
}
#[test]
fn can_auto_install_is_true() {
assert!(ClaudeCodePluginManager::new(None, None, None).can_auto_install());
}
#[test]
fn minimum_version() {
assert_eq!(
ClaudeCodePluginManager::new(None, None, None).minimum_plugin_version(),
"2.0.0"
);
}
#[test]
fn installed_version_returns_version_when_present() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"warp@claude-code-warp": [{"version": "1.5.0"}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert_eq!(installed_version(dir.path()).as_deref(), Some("1.5.0"));
}
#[test]
fn installed_version_returns_none_when_no_version_field() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"warp@claude-code-warp": [{"scope": "user"}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert_eq!(installed_version(dir.path()), None);
}
#[test]
fn installed_version_returns_none_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(installed_version(dir.path()), None);
}
@@ -0,0 +1,63 @@
use std::sync::LazyLock;
use async_trait::async_trait;
use super::{CliAgentPluginManager, PluginInstructionStep, PluginInstructions};
pub(super) struct CodexPluginManager;
#[async_trait]
impl CliAgentPluginManager for CodexPluginManager {
fn minimum_plugin_version(&self) -> &'static str {
"0.0.0"
}
fn can_auto_install(&self) -> bool {
false
}
fn supports_update(&self) -> bool {
false
}
fn install_instructions(&self) -> &'static PluginInstructions {
&INSTALL_INSTRUCTIONS
}
fn update_instructions(&self) -> &'static PluginInstructions {
&EMPTY_INSTRUCTIONS
}
}
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
PluginInstructions {
title: "Enable Warp Notifications for Codex",
subtitle: "Update Codex to the latest version, then enable in-focus notifications so Warp can display them while you work.",
steps: &[
PluginInstructionStep {
description: "Update Codex to the latest version.",
command: "",
executable: false,
link: Some("https://developers.openai.com/codex/cli#upgrade"),
},
PluginInstructionStep {
description: "Set the notification condition to \"always\" in your Codex config. Open or create ~/.codex/config.toml and add:",
command: "[tui]\nnotification_condition = \"always\"",
executable: false,
link: None,
},
],
post_install_notes: &["Restart Codex to apply the changes."],
}
});
static EMPTY_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| PluginInstructions {
title: "",
subtitle: "",
steps: &[],
post_install_notes: &[],
});
#[cfg(test)]
#[path = "codex_tests.rs"]
mod tests;
@@ -0,0 +1,19 @@
use super::CodexPluginManager;
use crate::terminal::cli_agent_sessions::plugin_manager::CliAgentPluginManager;
#[test]
fn can_auto_install_is_false() {
assert!(!CodexPluginManager.can_auto_install());
}
#[test]
fn does_not_support_update() {
assert!(!CodexPluginManager.supports_update());
}
#[test]
fn install_instructions_has_steps() {
let instructions = CodexPluginManager.install_instructions();
assert!(!instructions.steps.is_empty());
assert!(!instructions.title.is_empty());
}
@@ -0,0 +1,185 @@
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use async_trait::async_trait;
use serde_json::Value;
use super::{
compare_versions, run_cli_command_logged, CliAgentPluginManager, PluginInstallError,
PluginInstructionStep, PluginInstructions,
};
use crate::terminal::model::session::LocalCommandExecutor;
use crate::terminal::shell::ShellType;
const EXTENSION_REPO: &str = "https://github.com/warpdotdev/gemini-cli-warp";
const EXTENSION_NAME: &str = "gemini-warp";
// Keep in sync with the plugin version in warpdotdev/gemini-warp.
const MINIMUM_PLUGIN_VERSION: &str = "1.0.0";
pub(super) struct GeminiPluginManager {
executor: LocalCommandExecutor,
path_env_var: Option<String>,
}
impl GeminiPluginManager {
pub(super) fn new(
shell_path: Option<PathBuf>,
shell_type: Option<ShellType>,
path_env_var: Option<String>,
) -> Self {
let shell_type = shell_type.unwrap_or(ShellType::Bash);
Self {
executor: LocalCommandExecutor::new(shell_path, shell_type),
path_env_var,
}
}
async fn run_logged(&self, args: &[&str], log: &mut String) -> Result<(), PluginInstallError> {
let env_vars = self
.path_env_var
.as_deref()
.map(|path| HashMap::from([("PATH".to_owned(), path.to_owned())]));
run_cli_command_logged("gemini", args, &self.executor, env_vars, log).await
}
}
#[async_trait]
impl CliAgentPluginManager for GeminiPluginManager {
fn minimum_plugin_version(&self) -> &'static str {
MINIMUM_PLUGIN_VERSION
}
fn can_auto_install(&self) -> bool {
true
}
fn is_installed(&self) -> bool {
let Ok(extensions_dir) = gemini_extensions_dir() else {
return false;
};
check_installed(&extensions_dir)
}
fn needs_update(&self) -> bool {
let Ok(extensions_dir) = gemini_extensions_dir() else {
return false;
};
match installed_version(&extensions_dir) {
Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(),
// No version field means very old or malformed extension.
None => check_installed(&extensions_dir),
}
}
async fn install(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
self.run_logged(
&["extensions", "install", EXTENSION_REPO, "--consent"],
&mut log,
)
.await?;
Ok(())
}
async fn update(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
self.run_logged(&["extensions", "update", EXTENSION_NAME], &mut log)
.await?;
// Sanity check: verify the on-disk version actually changed.
let still_outdated = gemini_extensions_dir()
.ok()
.and_then(|dir| installed_version(&dir))
.map(|v| compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt())
.unwrap_or(true);
if still_outdated {
log.push_str("Post-update version check: plugin is still outdated\n");
return Err(PluginInstallError {
message: "Plugin update did not take effect".to_owned(),
log,
});
}
Ok(())
}
fn install_success_message(&self) -> &'static str {
"Warp plugin installed. Please restart Gemini CLI to activate."
}
fn update_success_message(&self) -> &'static str {
"Warp plugin updated. Please restart Gemini CLI to activate."
}
fn install_instructions(&self) -> &'static PluginInstructions {
&INSTALL_INSTRUCTIONS
}
fn update_instructions(&self) -> &'static PluginInstructions {
&UPDATE_INSTRUCTIONS
}
}
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| PluginInstructions {
title: "Install Warp Plugin for Gemini CLI",
subtitle: "Run the following command, then restart Gemini CLI.",
steps: &[PluginInstructionStep {
description: "Install the Warp extension",
command:
"gemini extensions install https://github.com/warpdotdev/gemini-cli-warp --consent",
executable: true,
link: None,
}],
post_install_notes: &["Restart Gemini CLI to activate the plugin."],
});
static UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| PluginInstructions {
title: "Update Warp Plugin for Gemini CLI",
subtitle: "Run the following command, then restart Gemini CLI.",
steps: &[PluginInstructionStep {
description: "Update the Warp extension",
command: "gemini extensions update gemini-warp",
executable: true,
link: None,
}],
post_install_notes: &["Restart Gemini CLI to activate the update."],
});
fn check_installed(extensions_dir: &Path) -> bool {
let manifest_path = extensions_dir
.join(EXTENSION_NAME)
.join("gemini-extension.json");
let Ok(contents) = fs::read_to_string(manifest_path) else {
return false;
};
serde_json::from_str::<Value>(&contents).is_ok()
}
/// Reads the installed version string for the Warp extension, if present.
fn installed_version(extensions_dir: &Path) -> Option<String> {
let manifest_path = extensions_dir
.join(EXTENSION_NAME)
.join("gemini-extension.json");
let contents = fs::read_to_string(manifest_path).ok()?;
let parsed: Value = serde_json::from_str(&contents).ok()?;
parsed.get("version")?.as_str().map(|s| s.to_owned())
}
/// Returns the path to `~/.gemini/extensions`.
fn gemini_extensions_dir() -> io::Result<PathBuf> {
dirs::home_dir()
.map(|home| home.join(".gemini").join("extensions"))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"could not determine home directory",
)
})
}
#[cfg(test)]
#[path = "gemini_tests.rs"]
mod tests;
@@ -0,0 +1,158 @@
use std::fs;
use super::{
check_installed, compare_versions, installed_version, CliAgentPluginManager,
GeminiPluginManager, MINIMUM_PLUGIN_VERSION,
};
#[test]
fn can_auto_install_is_true() {
assert!(GeminiPluginManager::new(None, None, None).can_auto_install());
}
#[test]
fn minimum_version() {
assert_eq!(
GeminiPluginManager::new(None, None, None).minimum_plugin_version(),
"1.0.0"
);
}
#[test]
fn install_instructions_has_steps() {
let instructions = GeminiPluginManager::new(None, None, None).install_instructions();
assert!(!instructions.steps.is_empty());
assert!(!instructions.title.is_empty());
}
#[test]
fn update_instructions_has_steps() {
let instructions = GeminiPluginManager::new(None, None, None).update_instructions();
assert!(!instructions.steps.is_empty());
assert!(!instructions.title.is_empty());
}
#[test]
fn installed_when_extension_present() {
let dir = tempfile::tempdir().unwrap();
let ext_dir = dir.path().join("gemini-warp");
fs::create_dir_all(&ext_dir).unwrap();
let json = serde_json::json!({
"name": "warp",
"version": "1.0.0",
"description": "Warp terminal integration for Gemini CLI"
});
fs::write(
ext_dir.join("gemini-extension.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(check_installed(dir.path()));
}
#[test]
fn not_installed_when_extension_missing() {
let dir = tempfile::tempdir().unwrap();
assert!(!check_installed(dir.path()));
}
#[test]
fn not_installed_when_json_invalid() {
let dir = tempfile::tempdir().unwrap();
let ext_dir = dir.path().join("gemini-warp");
fs::create_dir_all(&ext_dir).unwrap();
fs::write(ext_dir.join("gemini-extension.json"), "not json").unwrap();
assert!(!check_installed(dir.path()));
}
#[test]
fn installed_version_returns_version_when_present() {
let dir = tempfile::tempdir().unwrap();
let ext_dir = dir.path().join("gemini-warp");
fs::create_dir_all(&ext_dir).unwrap();
let json = serde_json::json!({
"name": "warp",
"version": "1.5.0"
});
fs::write(
ext_dir.join("gemini-extension.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert_eq!(installed_version(dir.path()).as_deref(), Some("1.5.0"));
}
#[test]
fn installed_version_returns_none_when_no_version_field() {
let dir = tempfile::tempdir().unwrap();
let ext_dir = dir.path().join("gemini-warp");
fs::create_dir_all(&ext_dir).unwrap();
let json = serde_json::json!({
"name": "warp"
});
fs::write(
ext_dir.join("gemini-extension.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert_eq!(installed_version(dir.path()), None);
}
#[test]
fn installed_version_returns_none_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(installed_version(dir.path()), None);
}
#[test]
fn needs_update_logic_true_when_version_outdated() {
let dir = tempfile::tempdir().unwrap();
let ext_dir = dir.path().join("gemini-warp");
fs::create_dir_all(&ext_dir).unwrap();
let json = serde_json::json!({
"name": "warp",
"version": "0.9.0"
});
fs::write(
ext_dir.join("gemini-extension.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
let needs_update = match installed_version(dir.path()) {
Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(),
None => check_installed(dir.path()),
};
assert!(needs_update);
}
#[test]
fn needs_update_logic_false_when_version_current() {
let dir = tempfile::tempdir().unwrap();
let ext_dir = dir.path().join("gemini-warp");
fs::create_dir_all(&ext_dir).unwrap();
let json = serde_json::json!({
"name": "warp",
"version": "1.0.0"
});
fs::write(
ext_dir.join("gemini-extension.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
let needs_update = match installed_version(dir.path()) {
Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(),
None => check_installed(dir.path()),
};
assert!(!needs_update);
}
@@ -0,0 +1,272 @@
pub(crate) mod claude;
pub(crate) mod codex;
pub(crate) mod gemini;
pub(crate) mod opencode;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::fmt;
use std::io;
use std::path::PathBuf;
use async_trait::async_trait;
use crate::features::FeatureFlag;
use crate::terminal::model::session::LocalCommandExecutor;
use crate::terminal::shell::ShellType;
use crate::terminal::CLIAgent;
use claude::ClaudeCodePluginManager;
use codex::CodexPluginManager;
use gemini::GeminiPluginManager;
use opencode::OpenCodePluginManager;
/// Distinguishes whether the plugin instructions modal should show install or update steps.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginModalKind {
Install,
Update,
}
/// A single step in the plugin install/update instructions pane.
pub(crate) struct PluginInstructionStep {
pub description: &'static str,
pub command: &'static str,
/// When true, the code block shows a "Run" button that inserts the command into the terminal.
/// Defaults-by-convention to `true`; set to `false` for steps that are not runnable
/// (e.g. config file snippets).
pub executable: bool,
/// Optional URL rendered as a clickable "Learn more" link after the description.
/// When set with an empty `command`, the code block is omitted entirely.
pub link: Option<&'static str>,
}
/// All content needed to render the plugin instructions pane for a given agent.
pub(crate) struct PluginInstructions {
pub title: &'static str,
pub subtitle: &'static str,
pub steps: &'static [PluginInstructionStep],
/// Displayed after the steps in the same style as the subtitle, one per paragraph.
pub post_install_notes: &'static [&'static str],
}
/// Error returned when plugin installation fails.
/// Carries both a short user-facing message (for the toast) and a detailed
/// command log (for the log file the user can inspect).
pub(crate) struct PluginInstallError {
/// Short description shown in the toast notification.
pub message: String,
/// Detailed log of every command/step that was attempted.
pub log: String,
}
impl fmt::Display for PluginInstallError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl From<io::Error> for PluginInstallError {
fn from(err: io::Error) -> Self {
let msg = err.to_string();
Self {
message: msg.clone(),
log: msg,
}
}
}
/// Compares two `X.Y.Z` version strings.
/// Returns `Ordering::Less` if `a < b`, etc.
/// Unparseable components are treated as 0.
pub(crate) fn compare_versions(a: &str, b: &str) -> Ordering {
let parse = |s: &str| -> [u64; 3] {
let mut parts = s.splitn(3, '.');
let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
[major, minor, patch]
};
parse(a).cmp(&parse(b))
}
/// Runs a CLI subcommand through [`LocalCommandExecutor`], appending the
/// command and its output to `log`.
pub(crate) async fn run_cli_command_logged(
cli_name: &str,
args: &[&str],
executor: &LocalCommandExecutor,
env_vars: Option<HashMap<String, String>>,
log: &mut String,
) -> Result<(), PluginInstallError> {
let display_cmd = format!("{cli_name} {}", args.join(" "));
log.push_str(&format!("$ {display_cmd}\n"));
let result = executor
.execute_local_command_in_login_shell(&display_cmd, None, env_vars)
.await;
match result {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
for stream in [&stdout, &stderr] {
if stream.is_empty() {
continue;
}
log.push_str(stream);
if !stream.ends_with('\n') {
log.push('\n');
}
}
if output.success() {
log.push('\n');
return Ok(());
}
Err(PluginInstallError {
message: format!("'{display_cmd}' failed"),
log: log.to_owned(),
})
}
Err(err) => {
log.push_str(&format!("error: {err}\n"));
Err(PluginInstallError {
message: format!("failed to run '{display_cmd}'"),
log: log.clone(),
})
}
}
}
/// Manages the Warp notification plugin for a specific CLI agent.
///
/// Each supported CLI agent has its own implementation that knows how to
/// check installation state and perform install/update operations.
#[async_trait]
pub(crate) trait CliAgentPluginManager: Send + Sync {
/// The minimum plugin version required by this Warp build.
fn minimum_plugin_version(&self) -> &'static str;
/// Whether this agent supports one-click auto-install/update.
/// When `false`, the footer always opens the manual instructions modal.
fn can_auto_install(&self) -> bool;
/// Whether the Warp notification plugin is installed.
/// Default returns `false` (no filesystem check).
fn is_installed(&self) -> bool {
false
}
/// Whether the on-disk plugin version is below the minimum required.
/// Default returns `false` (no filesystem check).
fn needs_update(&self) -> bool {
false
}
/// Install the Warp notification plugin.
/// Default returns an error — only agents with `can_auto_install() == true` should override.
async fn install(&self) -> Result<(), PluginInstallError> {
Err(PluginInstallError {
message: "Auto-install not supported for this agent".to_owned(),
log: String::new(),
})
}
/// Update the Warp notification plugin to the latest version.
/// Default returns an error — only agents with `can_auto_install() == true` should override.
async fn update(&self) -> Result<(), PluginInstallError> {
Err(PluginInstallError {
message: "Auto-update not supported for this agent".to_owned(),
log: String::new(),
})
}
/// Toast message shown after a successful auto-install.
fn install_success_message(&self) -> &'static str {
"Warp plugin installed. Please restart the session to activate."
}
/// Toast message shown after a successful auto-update.
fn update_success_message(&self) -> &'static str {
"Warp plugin updated. Please restart the session to activate."
}
/// Manual installation instructions for the modal UI.
fn install_instructions(&self) -> &'static PluginInstructions;
/// Whether this agent supports version-based update checking.
/// When `false`, the update chip is never shown; only the install chip appears.
fn supports_update(&self) -> bool {
true
}
/// Manual update instructions for the modal UI.
fn update_instructions(&self) -> &'static PluginInstructions;
/// Install the Oz platform plugin for this CLI agent, if one exists,
/// which provides skills that third-party harnesses can use to interact with
/// the Oz platform.
/// Default is a no-op — only agents with a platform plugin should override.
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
Ok(())
}
}
/// Returns a plugin manager for the given CLI agent, or `None` if the agent
/// doesn't have Warp notification plugin support.
pub(crate) fn plugin_manager_for(agent: CLIAgent) -> Option<Box<dyn CliAgentPluginManager>> {
plugin_manager_for_with_shell(agent, None, None, None)
}
/// Returns a plugin manager for the given CLI agent, or `None` if the agent
/// doesn't have Warp notification plugin support.
///
/// When a shell path and type are provided, plugin commands run through that shell.
/// When `path_env_var` is provided, it is set as the PATH for plugin commands
/// (needed for nvm-installed tools that are only on PATH in interactive shells).
pub(crate) fn plugin_manager_for_with_shell(
agent: CLIAgent,
shell_path: Option<PathBuf>,
shell_type: Option<ShellType>,
path_env_var: Option<String>,
) -> Option<Box<dyn CliAgentPluginManager>> {
match agent {
CLIAgent::Claude => Some(Box::new(ClaudeCodePluginManager::new(
shell_path,
shell_type,
path_env_var,
))),
CLIAgent::OpenCode
if FeatureFlag::OpenCodeNotifications.is_enabled()
&& FeatureFlag::HOANotifications.is_enabled() =>
{
Some(Box::new(OpenCodePluginManager))
}
CLIAgent::Codex
if FeatureFlag::CodexNotifications.is_enabled()
&& FeatureFlag::HOANotifications.is_enabled() =>
{
Some(Box::new(CodexPluginManager))
}
CLIAgent::Gemini
if FeatureFlag::GeminiNotifications.is_enabled()
&& FeatureFlag::HOANotifications.is_enabled() =>
{
Some(Box::new(GeminiPluginManager::new(
shell_path,
shell_type,
path_env_var,
)))
}
CLIAgent::OpenCode
| CLIAgent::Codex
| CLIAgent::Gemini
| CLIAgent::Amp
| CLIAgent::Droid
| CLIAgent::Copilot
| CLIAgent::Pi
| CLIAgent::Auggie
| CLIAgent::CursorCli
| CLIAgent::Unknown => None,
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
@@ -0,0 +1,80 @@
use std::cmp::Ordering;
use super::{compare_versions, plugin_manager_for};
use crate::terminal::CLIAgent;
#[test]
fn returns_manager_for_claude() {
assert!(plugin_manager_for(CLIAgent::Claude).is_some());
}
#[test]
fn returns_manager_for_opencode() {
let _oc_guard = crate::features::FeatureFlag::OpenCodeNotifications.override_enabled(true);
let _hoa_guard = crate::features::FeatureFlag::HOANotifications.override_enabled(true);
assert!(plugin_manager_for(CLIAgent::OpenCode).is_some());
}
#[test]
fn returns_manager_for_codex() {
let _codex_guard = crate::features::FeatureFlag::CodexNotifications.override_enabled(true);
let _hoa_guard = crate::features::FeatureFlag::HOANotifications.override_enabled(true);
assert!(plugin_manager_for(CLIAgent::Codex).is_some());
}
#[test]
fn returns_manager_for_gemini() {
let _gemini_guard = crate::features::FeatureFlag::GeminiNotifications.override_enabled(true);
let _hoa_guard = crate::features::FeatureFlag::HOANotifications.override_enabled(true);
assert!(plugin_manager_for(CLIAgent::Gemini).is_some());
}
#[test]
fn returns_none_for_unsupported_agents() {
assert!(plugin_manager_for(CLIAgent::Amp).is_none());
assert!(plugin_manager_for(CLIAgent::Droid).is_none());
assert!(plugin_manager_for(CLIAgent::Copilot).is_none());
assert!(plugin_manager_for(CLIAgent::Unknown).is_none());
}
#[test]
fn compare_versions_equal() {
assert_eq!(compare_versions("1.2.3", "1.2.3"), Ordering::Equal);
}
#[test]
fn compare_versions_less_than_major() {
assert_eq!(compare_versions("1.0.0", "2.0.0"), Ordering::Less);
}
#[test]
fn compare_versions_less_than_minor() {
assert_eq!(compare_versions("1.1.0", "1.2.0"), Ordering::Less);
}
#[test]
fn compare_versions_less_than_patch() {
assert_eq!(compare_versions("1.1.0", "1.1.1"), Ordering::Less);
}
#[test]
fn compare_versions_greater_than() {
assert_eq!(compare_versions("3.0.0", "2.0.0"), Ordering::Greater);
}
#[test]
fn compare_versions_unparseable_treated_as_zero() {
assert_eq!(compare_versions("abc", "0.0.0"), Ordering::Equal);
assert_eq!(compare_versions("abc", "1.0.0"), Ordering::Less);
}
#[test]
fn compare_versions_partial_version_string() {
assert_eq!(compare_versions("2", "2.0.0"), Ordering::Equal);
assert_eq!(compare_versions("2.1", "2.1.0"), Ordering::Equal);
}
#[test]
fn compare_versions_empty_string() {
assert_eq!(compare_versions("", "2.0.0"), Ordering::Less);
}
@@ -0,0 +1,81 @@
use std::sync::LazyLock;
use async_trait::async_trait;
use super::{CliAgentPluginManager, PluginInstructionStep, PluginInstructions};
// Keep in sync with the opencode-warp npm package version.
// This version is also hardcoded into UPDATE_INSTRUCTIONS below (so the update
// instructions tell users to pin to this specific version to force OpenCode's
// plugin cache to re-fetch). Update both together.
const MINIMUM_PLUGIN_VERSION: &str = "0.1.5";
pub(super) struct OpenCodePluginManager;
#[async_trait]
impl CliAgentPluginManager for OpenCodePluginManager {
fn minimum_plugin_version(&self) -> &'static str {
MINIMUM_PLUGIN_VERSION
}
fn can_auto_install(&self) -> bool {
false
}
fn install_instructions(&self) -> &'static PluginInstructions {
&INSTALL_INSTRUCTIONS
}
fn update_instructions(&self) -> &'static PluginInstructions {
&UPDATE_INSTRUCTIONS
}
}
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
PluginInstructions {
title: "Install Warp Plugin for OpenCode",
subtitle:
"Add the Warp plugin to your OpenCode configuration, then restart OpenCode.",
steps: &[
PluginInstructionStep {
description: "Open or create your opencode.json. This can be in your project root, or the global config path:",
command: "~/.config/opencode/opencode.json",
executable: false,
link: None,
},
PluginInstructionStep {
description: "Add \"@warp-dot-dev/opencode-warp\" to the \"plugin\" array in the top-level JSON object:",
command: "\"plugin\": [\"@warp-dot-dev/opencode-warp\"]",
executable: false,
link: None,
},
],
post_install_notes: &["Restart OpenCode to activate the plugin."],
}
});
static UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
PluginInstructions {
title: "Update Warp Plugin for OpenCode",
subtitle: "Pin the plugin to the latest version in your opencode.json. OpenCode caches plugins per version spec, so changing the pin forces it to re-fetch on restart.",
steps: &[
PluginInstructionStep {
description: "Open or create your opencode.json. This can be in your project root, or the global config path:",
command: "~/.config/opencode/opencode.json",
executable: false,
link: None,
},
PluginInstructionStep {
description: "Replace the existing \"@warp-dot-dev/opencode-warp\" entry in the \"plugin\" array with the explicit version:",
command: "\"plugin\": [\"@warp-dot-dev/opencode-warp@0.1.5\"]",
executable: false,
link: None,
},
],
post_install_notes: &["Restart OpenCode to load the updated plugin."],
}
});
#[cfg(test)]
#[path = "opencode_tests.rs"]
mod tests;
@@ -0,0 +1,21 @@
use super::OpenCodePluginManager;
use crate::terminal::cli_agent_sessions::plugin_manager::CliAgentPluginManager;
#[test]
fn can_auto_install_is_false() {
assert!(!OpenCodePluginManager.can_auto_install());
}
#[test]
fn install_instructions_has_steps() {
let instructions = OpenCodePluginManager.install_instructions();
assert!(!instructions.steps.is_empty());
assert!(!instructions.title.is_empty());
}
#[test]
fn update_instructions_has_steps() {
let instructions = OpenCodePluginManager.update_instructions();
assert!(!instructions.steps.is_empty());
assert!(!instructions.title.is_empty());
}