Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user