diff --git a/app/src/ai/coven_brand.rs b/app/src/ai/coven_brand.rs index 22e999f08..1fb1440e7 100644 --- a/app/src/ai/coven_brand.rs +++ b/app/src/ai/coven_brand.rs @@ -9,6 +9,7 @@ pub(crate) const OPENCOVEN_PURPLE: ColorU = ColorU { }; /// Brand success colour (`#22C55E`) — used for the Coven Gateway "online" pill. +#[allow(dead_code)] pub(crate) const OPENCOVEN_SUCCESS: ColorU = ColorU { r: 34, g: 197, @@ -17,6 +18,7 @@ pub(crate) const OPENCOVEN_SUCCESS: ColorU = ColorU { }; /// Brand warning colour (`#F59E0B`) — used for the Coven Gateway "degraded" pill. +#[allow(dead_code)] pub(crate) const OPENCOVEN_WARNING: ColorU = ColorU { r: 245, g: 158, diff --git a/app/src/ai_assistant/coven_stream_persist.rs b/app/src/ai_assistant/coven_stream_persist.rs deleted file mode 100644 index 252db2dab..000000000 --- a/app/src/ai_assistant/coven_stream_persist.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! On-disk persistence for the Coven Gateway stream history. -//! -//! Stores up to `COVEN_STREAM_HISTORY_MAX` completed streams as -//! `~/.coven/stream-history.json` so the agent panel can restore them -//! across CastCodes restarts. Matches the same directory convention -//! cast_agent uses for its `config.toml` and `token` (`~/.coven/`, -//! resolved via [`dirs::home_dir`]). -//! -//! Path lives outside CastCodes' workspace serialization (`.cast-codes/` -//! per `CASTCODES.md`) deliberately — stream history is conversation -//! state that follows the user across workspaces, not workspace state. -//! Putting it in `~/.coven/` also keeps it next to the Coven Gateway -//! config that produced it. -//! -//! Errors are logged but never propagated. A missing file, a parse -//! failure, or a failed write all degrade gracefully to "no history" -//! — the panel just shows an empty history list and starts fresh. - -use std::path::PathBuf; - -use super::panel::CovenStreamHistoryEntry; - -const HISTORY_FILENAME: &str = "stream-history.json"; - -fn history_path() -> Option { - dirs::home_dir().map(|h| h.join(".coven").join(HISTORY_FILENAME)) -} - -/// Read the persisted history. Returns `Vec::new()` on any error — -/// the panel treats absent history as "fresh session," not a fatal -/// startup problem. -pub(super) fn load() -> Vec { - let Some(path) = history_path() else { - log::debug!("cast_agent: stream history path unavailable (no home dir)"); - return Vec::new(); - }; - let raw = match std::fs::read_to_string(&path) { - Ok(raw) => raw, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(), - Err(err) => { - log::warn!( - "cast_agent: stream history read failed at {}: {err}", - path.display() - ); - return Vec::new(); - } - }; - match serde_json::from_str::>(&raw) { - Ok(entries) => entries, - Err(err) => { - log::warn!( - "cast_agent: stream history parse failed at {}: {err} — discarding", - path.display() - ); - Vec::new() - } - } -} - -/// Write the history to disk. Atomic-ish via write-to-temp + -/// rename so a crash partway through doesn't truncate the existing -/// file. Errors logged and dropped. -pub(super) fn save(entries: &[CovenStreamHistoryEntry]) { - let Some(path) = history_path() else { - return; - }; - if let Some(parent) = path.parent() { - if let Err(err) = std::fs::create_dir_all(parent) { - log::warn!( - "cast_agent: stream history mkdir failed at {}: {err}", - parent.display() - ); - return; - } - } - let payload = match serde_json::to_string_pretty(entries) { - Ok(p) => p, - Err(err) => { - log::warn!("cast_agent: stream history serialize failed: {err}"); - return; - } - }; - let tmp = path.with_extension("json.tmp"); - if let Err(err) = std::fs::write(&tmp, &payload) { - log::warn!( - "cast_agent: stream history write failed at {}: {err}", - tmp.display() - ); - return; - } - if let Err(err) = std::fs::rename(&tmp, &path) { - log::warn!( - "cast_agent: stream history rename failed at {} -> {}: {err}", - tmp.display(), - path.display() - ); - } -} diff --git a/app/src/ai_assistant/utils.rs b/app/src/ai_assistant/dialogue_types.rs similarity index 67% rename from app/src/ai_assistant/utils.rs rename to app/src/ai_assistant/dialogue_types.rs index 75db30a37..30f4bded2 100644 --- a/app/src/ai_assistant/utils.rs +++ b/app/src/ai_assistant/dialogue_types.rs @@ -1,28 +1,11 @@ -/// Common functionality used across different AI Assistant components. +//! UI-free AI-dialogue data types shared by the server API and auth layers. +//! +//! These types describe the question/answer transcript structure used by the +//! legacy hosted-AI dialogue path. They were extracted out of the Familiar +//! panel's `utils`/`transcript` UI knot so that `server_api`/`auth` no longer +//! depend on the (now-retired) panel view. use markdown_parser::{parse_markdown, CodeBlockText, FormattedText, FormattedTextLine}; -use pathfinder_color::ColorU; -use warpui::{ - elements::{ - ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, HighlightedHyperlink, - Icon, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Text, - }, - platform::Cursor, - ui_components::{ - button::ButtonVariant, - components::{Coords, UiComponent, UiComponentStyles}, - }, - AppContext, Element, ModelHandle, -}; - -use crate::{appearance::Appearance, ui_components::blended_colors}; - -use super::{panel::AIAssistantAction, requests::Requests, transcript::CodeBlockMouseStateHandles}; - -const PREPARED_RESPONSE_FONT_SIZE: f32 = 11.; -const REQUEST_LIMIT_INFO_FONT_SIZE: f32 = 11.; - -const SQUARE_ALERT_SVG_PATH: &str = "bundled/svg/alert-square.svg"; -const TRIANGLE_ALERT_SVG_PATH: &str = "bundled/svg/alert-triangle.svg"; +use warpui::elements::{HighlightedHyperlink, MouseStateHandle}; /// A transcript part is a question and answer _pair_. This is to enforce /// the invariant that every question has an answer. @@ -106,6 +89,19 @@ impl FormattedTranscriptMessage { } } +/// The mouse-state handles needed to render a single code block. Kept as a +/// UI-free leaf type here (a bundle of `warpui` handles) because +/// `MarkdownSegment::CodeBlock` embeds it. +#[derive(Debug, Clone, Default)] +pub struct CodeBlockMouseStateHandles { + pub play_button: MouseStateHandle, + pub play_button_tooltip: MouseStateHandle, + pub copy_button: MouseStateHandle, + pub copy_button_tooltip: MouseStateHandle, + pub save_as_workflow_button: MouseStateHandle, + pub save_as_workflow_button_tooltip: MouseStateHandle, +} + /// A MarkdownSegment differs from a FormattedText in that we intentionally /// separate out certain markdown elements. /// For now, only code blocks are rendered differently. @@ -257,146 +253,6 @@ impl CodeBlockIndex { } } -pub fn render_prepared_response_button( - appearance: &Appearance, - mouse_state_handle: MouseStateHandle, - width: Option, - right_left_padding: Option, - prompt: &'static str, -) -> Box { - let theme = appearance.theme(); - let default_button_styles = UiComponentStyles { - width, - font_size: Some(PREPARED_RESPONSE_FONT_SIZE), - font_family_id: Some(appearance.ui_font_family()), - font_color: Some( - appearance - .theme() - .main_text_color(appearance.theme().background()) - .into(), - ), - border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))), - border_color: Some(theme.accent().into()), - border_width: Some(1.), - padding: Some(Coords { - top: 5., - bottom: 5., - left: right_left_padding.unwrap_or(0.), - right: right_left_padding.unwrap_or(0.), - }), - ..Default::default() - }; - let hovered_and_clicked_styles = UiComponentStyles { - background: Some(theme.accent().into()), - font_color: Some(theme.background().into()), - ..default_button_styles - }; - appearance - .ui_builder() - .button_with_custom_styles( - ButtonVariant::Text, - mouse_state_handle, - default_button_styles, - Some(hovered_and_clicked_styles), - Some(hovered_and_clicked_styles), - Some(hovered_and_clicked_styles), - ) - .with_centered_text_label(prompt.to_string()) - .build() - .with_cursor(Cursor::PointingHand) - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(AIAssistantAction::PreparedPrompt(prompt)) - }) - .finish() -} - -pub fn render_request_limit_info( - request_model: &ModelHandle, - app: &AppContext, - appearance: &Appearance, -) -> Box { - let text_color: ColorU = - blended_colors::text_sub(appearance.theme(), appearance.theme().background()); - - let num_requests_used = request_model.as_ref(app).num_requests_used(); - let num_requests_remaining = request_model.as_ref(app).num_remaining_reqs(); - let request_limit = request_model.as_ref(app).request_limit(); - let next_refresh_time = request_model.as_ref(app).serialized_time_until_refresh(); - - // Always show the remaining requests count. - let mut row = Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::Center) - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child( - Text::new_inline( - format!("Credits used: {num_requests_used} / {request_limit}.",), - appearance.ui_font_family(), - REQUEST_LIMIT_INFO_FONT_SIZE, - ) - .with_color(text_color) - .finish(), - ); - - // Add the warning icon if necessary. - let icon = if num_requests_remaining == 0 { - Some(Icon::new( - TRIANGLE_ALERT_SVG_PATH, - appearance.theme().ui_error_color(), - )) - } else if num_requests_remaining <= 10 { - Some(Icon::new( - SQUARE_ALERT_SVG_PATH, - appearance.theme().ui_warning_color(), - )) - } else { - None - }; - - if let Some(icon) = icon { - row.add_child( - Container::new( - ConstrainedBox::new(icon.finish()) - .with_height(16.) - .with_width(16.) - .finish(), - ) - .with_margin_left(5.) - .finish(), - ); - } - - // Show the next refresh time if it's valid. - if let Some(next_refresh_time) = next_refresh_time { - row.add_child( - Container::new( - Text::new_inline( - format!("{next_refresh_time} until refresh."), - appearance.ui_font_family(), - REQUEST_LIMIT_INFO_FONT_SIZE, - ) - .with_color(text_color) - .finish(), - ) - .with_margin_left(5.) - .finish(), - ); - } - - row.finish() -} - -pub fn code_block_position_id(code_block_index: CodeBlockIndex) -> String { - format!("code_block_id_{}", code_block_index.as_id_str(),) -} - -pub fn save_as_workflow_position_id(code_block_index: CodeBlockIndex) -> String { - format!( - "{}_save_as_workflow", - code_block_position_id(code_block_index) - ) -} - pub fn markdown_segments_from_text( transcript_part_index: usize, transcript_part_type: TranscriptPartSubType, @@ -475,5 +331,5 @@ fn translate_formatted_text_into_markdown_segments( } #[cfg(test)] -#[path = "utils_tests.rs"] -mod utils_tests; +#[path = "dialogue_types_tests.rs"] +mod dialogue_types_tests; diff --git a/app/src/ai_assistant/utils_tests.rs b/app/src/ai_assistant/dialogue_types_tests.rs similarity index 97% rename from app/src/ai_assistant/utils_tests.rs rename to app/src/ai_assistant/dialogue_types_tests.rs index 268daf067..cf1de569d 100644 --- a/app/src/ai_assistant/utils_tests.rs +++ b/app/src/ai_assistant/dialogue_types_tests.rs @@ -3,9 +3,7 @@ use crate::ai_assistant::test_util::{ default_other_segment, }; -use super::{FormattedTranscriptMessage, TranscriptPart, TranscriptPartSubType}; - -use crate::ai_assistant::utils::CodeBlockIndex; +use super::{CodeBlockIndex, FormattedTranscriptMessage, TranscriptPart, TranscriptPartSubType}; // Mocked data to make it easy to test. lazy_static::lazy_static! { diff --git a/app/src/ai_assistant/mod.rs b/app/src/ai_assistant/mod.rs index b2dc70942..7107e7307 100644 --- a/app/src/ai_assistant/mod.rs +++ b/app/src/ai_assistant/mod.rs @@ -21,13 +21,9 @@ use warp_graphql::{ #[cfg(feature = "cast-agent")] pub mod coven_entry; -#[cfg(feature = "cast-agent")] -pub mod coven_stream_persist; +pub mod dialogue_types; pub mod execution_context; -pub mod panel; pub mod requests; -pub mod transcript; -pub mod utils; #[cfg(test)] mod test_util; diff --git a/app/src/ai_assistant/panel.rs b/app/src/ai_assistant/panel.rs deleted file mode 100644 index ea08b4e20..000000000 --- a/app/src/ai_assistant/panel.rs +++ /dev/null @@ -1,2315 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use chrono::Local; - -use pathfinder_geometry::vector::{vec2f, Vector2F}; -use warp_editor::editor::NavigationKey; -use warpui::clipboard::ClipboardContent; -use warpui::elements::{ - resizable_state_handle, Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, - CrossAxisAlignment, DispatchEventResult, DragBarSide, Empty, EventHandler, Fill, Flex, - HyperlinkUrl, Icon, MainAxisAlignment, MainAxisSize, OffsetPositioning, ParentAnchor, - PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, Shrinkable, - Stack, Text, -}; -use warpui::fonts::Properties; -use warpui::keymap::{EditableBinding, FixedBinding}; -use warpui::platform::Cursor; -use warpui::presenter::ChildView; -use warpui::r#async::Timer; -use warpui::ui_components::button::ButtonVariant; -use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; -use warpui::{elements::Element, AppContext, Entity, TypedActionView, View, ViewContext}; -use warpui::{FocusContext, ModelHandle, SingletonEntity, ViewHandle}; - -use crate::ai::coven_brand::{OPENCOVEN_SUCCESS, OPENCOVEN_WARNING}; -use crate::appearance::Appearance; -use crate::editor::{ - EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, TextOptions, -}; -use crate::input_suggestions::{Event as InputSuggestionsEvent, InputSuggestions}; - -use crate::server::server_api::ai::AIClient; -use crate::server::server_api::ServerApi; -use crate::terminal::resizable_data::{ModalType, ResizableData, DEFAULT_WARP_AI_WIDTH}; -use crate::ui_components::blended_colors; -use crate::workspaces::user_workspaces::UserWorkspaces; - -use crate::ui_components::buttons::icon_button; -#[cfg(feature = "cast-agent")] -use crate::workspace::WorkspaceAction; -use crate::workspace::{ActiveSession, TAB_BAR_HEIGHT}; - -use crate::util::bindings::{cmd_or_ctrl_shift, CustomAction}; -// `cmd_or_ctrl` is only referenced from inside `#[cfg(feature = "cast-agent")]` -// blocks below; importing it unconditionally trips clippy's -// `unused_imports` lint (promoted to `-D warnings` in CI) on builds -// without the feature. -#[cfg(feature = "cast-agent")] -use crate::util::bindings::cmd_or_ctrl; -use warpui::elements::MouseStateHandle; -use warpui::elements::ParentElement; -use warpui::elements::Resizable; -use warpui::elements::ResizableStateHandle; - -use super::execution_context::WarpAiExecutionContext; -use super::requests::{Event as RequestsEvent, RequestStatus, Requests}; -use super::transcript::{Transcript, TranscriptEvent}; -use super::utils::{render_prepared_response_button, render_request_limit_info, TranscriptPart}; -use super::{ - AskAIType, AI_ASSISTANT_FEATURE_NAME, AI_ASSISTANT_LOGO_COLOR, AI_ASSISTANT_SVG_PATH, - ASK_AI_ASSISTANT_TEXT, PROMPT_CHARACTER_LIMIT, -}; - -const INFO_ICON_SVG_PATH: &str = "bundled/svg/info.svg"; -pub const HEXAGON_ALERT_SVG_PATH: &str = "bundled/svg/alert-hexagon.svg"; - -const EDITOR_SAVE_POSITION_ID: &str = "ai_assistant::editor"; - -const MIN_PANEL_WIDTH: f32 = 300.; -const MIN_REMAINING_WINDOW_SIZE: f32 = 200.; -const MAX_EDITOR_HEIGHT: f32 = 300.; -const MAX_INPUT_SUGGESTIONS_HEIGHT: f32 = 200.; - -pub(super) const HEADER_HEIGHT: f32 = TAB_BAR_HEIGHT; -pub(super) const HEADER_VERTICAL_PADDING: f32 = 5.; -const PANEL_HORIZONTAL_PADDING: f32 = 6.; -const EDITOR_MARGIN: f32 = 16.; -const LOGO_SIZE: f32 = 20.; - -const BODY_FONT_SIZE: f32 = 13.; -const TITLE_FONT_SIZE: f32 = 16.; -const ZERO_STATE_HELP_TEXT_FONT_SIZE: f32 = 12.; - -const ZERO_STATE_HELP_TEXT: &str = - "Shift + ctrl + space a block or text selection to ask your Familiar."; -const SCRIPT_ZERO_STATE_PROMPT: &str = "Write a script to connect to an AWS EC2 instance."; -const GIT_ZERO_STATE_PROMPT: &str = "How do I undo the most recent commits in git?"; -const FILES_ZERO_STATE_PROMPT: &str = "How do I find all files containing specific text?"; -#[cfg(feature = "cast-agent")] -const COVEN_CODE_HARNESS: &str = "coven-code"; -#[cfg(feature = "cast-agent")] -const COVEN_CODE_OPERATION_TITLE: &str = "Coven Code operation"; -/// Shown in the stream section when Coven Code is the native agent but -/// the daemon isn't reachable. We deliberately do NOT fall back to the -/// inherited hosted agent path — coven-code is the only agent this build -/// speaks to — so we tell the user how to bring it online instead. -#[cfg(feature = "cast-agent")] -const COVEN_CODE_OFFLINE_NOTICE: &str = - "Coven Code is offline. Start the Coven daemon (`coven daemon serve`) to run the agent."; - -/// Outcome of routing a prompt to the native Coven Code agent. -#[cfg(feature = "cast-agent")] -enum CovenDispatch { - /// A daemon session (or stream) was started for the prompt. - Dispatched, - /// Nothing to send (e.g. an empty prompt); caller should no-op. - Skipped, - /// The Coven runtime/daemon is unavailable. Caller surfaces an - /// offline notice rather than routing to the inherited hosted path. - Offline, -} - -// The placeholder texts are prepended with a space to give them cushion from the cursor. -const INIT_PLACEHOLDER_TEXT: &str = " Ask a question..."; -const FOLLOWUP_PLACEHOLDER_TEXT: &str = " Type a response or click one above..."; -const RESTART_BUTTON_TEXT: &str = "Restart"; - -const ASK_AI_BLOCK_INPUT_LIMIT: usize = 100; - -#[cfg(feature = "cast-agent")] -fn coven_code_agent_message_body( - prompt: String, - cwd: Option<&std::path::Path>, - target: &::ai::cast_agent::RequestTarget, -) -> serde_json::Value { - let mut body = serde_json::json!({ - "text": prompt, - "title": COVEN_CODE_OPERATION_TITLE, - }); - match target { - ::ai::cast_agent::RequestTarget::Familiar(id) => { - body["familiarId"] = serde_json::Value::String(id.clone()); - // Also send coven-code as the harness so a daemon that doesn't - // yet route familiarId still spawns the native agent. - body["harness"] = serde_json::Value::String(COVEN_CODE_HARNESS.to_string()); - } - ::ai::cast_agent::RequestTarget::Harness(h) => { - body["harness"] = serde_json::Value::String(h.clone()); - } - } - if let Some(cwd) = cwd { - body["projectRoot"] = serde_json::Value::String(cwd.to_string_lossy().to_string()); - } - body -} - -#[cfg(all(test, feature = "cast-agent"))] -mod tests { - use super::*; - - #[test] - fn coven_code_message_body_harness_target() { - let target = ::ai::cast_agent::RequestTarget::Harness(COVEN_CODE_HARNESS.to_string()); - let body = coven_code_agent_message_body( - "Inspect this project".to_string(), - Some(std::path::Path::new("/tmp/castcodes-project")), - &target, - ); - - assert_eq!( - body.get("text").and_then(serde_json::Value::as_str), - Some("Inspect this project") - ); - assert_eq!( - body.get("harness").and_then(serde_json::Value::as_str), - Some("coven-code") - ); - assert_eq!( - body.get("title").and_then(serde_json::Value::as_str), - Some("Coven Code operation") - ); - assert_eq!( - body.get("projectRoot").and_then(serde_json::Value::as_str), - Some("/tmp/castcodes-project") - ); - assert!(body.get("familiarId").is_none()); - } - - #[test] - fn coven_code_message_body_familiar_target() { - let target = ::ai::cast_agent::RequestTarget::Familiar("nova".to_string()); - let body = coven_code_agent_message_body( - "Inspect this project".to_string(), - Some(std::path::Path::new("/tmp/castcodes-project")), - &target, - ); - - assert_eq!( - body.get("familiarId").and_then(serde_json::Value::as_str), - Some("nova") - ); - // Fallback harness is also included so old daemons still launch. - assert_eq!( - body.get("harness").and_then(serde_json::Value::as_str), - Some("coven-code") - ); - } -} - -#[derive(Default)] -struct MouseStateHandles { - close_panel_state: MouseStateHandle, - reset_context_button: MouseStateHandle, - copy_transcript_button: MouseStateHandle, - #[cfg(feature = "cast-agent")] - familiar_pill: MouseStateHandle, - - script_zero_state_prompt: MouseStateHandle, - git_zero_state_prompt: MouseStateHandle, - files_zero_state_prompt: MouseStateHandle, -} - -pub enum AIAssistantPanelEvent { - ClosePanel, - PasteInTerminalInput(Arc), - FocusTerminalInput, - OpenWorkflowModalWithCommand(String), -} - -/// Which child view is currently focused. It must be exactly one of these. -#[derive(Copy, Clone)] -enum PanelFocusState { - Editor, - Transcript, -} - -enum InputSuggestionsMode { - Open { origin_buffer_text: String }, - Closed, -} - -/// The panel view is responsible for the various components that make up the panel -/// (e.g. header, transcript, editor). -/// TODO: we should eventually refactor this and other panels into a more -/// general Panel view. -pub struct AIAssistantPanelView { - editor: ViewHandle, - transcript_view: ViewHandle, - input_suggestions_view: ViewHandle, - input_suggestions_mode: InputSuggestionsMode, - requests_model: ModelHandle, - focus_state: PanelFocusState, - - resizable_state_handle: ResizableStateHandle, - mouse_state_handles: MouseStateHandles, - - /// Shared buffer for the Coven Gateway streaming consumer. The - /// `cast_agent` tokio task pushes `MessageChunk`s into - /// `pending_chunks`; a UI-side poll loop (`poll_coven_stream`) - /// drains them on every tick and appends `Delta`s to `text` so the - /// rendered output grows live. `Arc>` (std, not tokio) so - /// both sides can lock without entering a tokio runtime. - #[cfg(feature = "cast-agent")] - coven_stream: Arc>, -} - -#[cfg(feature = "cast-agent")] -#[derive(Default)] -struct CovenStreamState { - /// Accumulated `Delta` content rendered into the panel for the - /// currently in-flight (or most recently completed) stream. - text: String, - /// Whether the cast_agent task is still producing chunks. The poll - /// loop stops scheduling when this flips to `false`. - in_flight: bool, - /// Chunks the cast_agent task has emitted since the last UI drain. - /// Used by the plain-text fallback path (bridge / non-stream mode). - pending_chunks: Vec<::ai::cast_agent::MessageChunk>, - /// Structured transcript entries for the current stream, rendered via the - /// shared `agent_transcript` views. Non-empty only when the daemon - /// `stream`-mode path is active (rich rendering); the plain-text `text` - /// field is the fallback. - entries: Vec, - /// Entries the stream task has produced since the last UI drain. - pending_entries: Vec, - /// Conversation id of the active stream (for log correlation in - /// the rendered output). - conversation_id: String, - /// `JoinHandle` for the cast_agent tokio task driving the current - /// stream. Held so a fresh invocation can `abort()` the previous - /// task and replace it cleanly — without this, back-to-back - /// `cmd+shift+m` presses produced an interleaved jumble in `text`. - /// `Option` so the initial state is "no task running." - active_task: Option>, - /// Bounded ring of completed streams. Oldest is at index 0; the - /// renderer shows newest-first above the live section. Caps at - /// [`COVEN_STREAM_HISTORY_MAX`] so the panel doesn't grow - /// unbounded over a long session. - history: std::collections::VecDeque, - /// Familiar id selected for this conversation. `None` = use the global - /// default (config `default_familiar`), else the first supported harness. - selected_familiar: Option, -} - -#[cfg(feature = "cast-agent")] -#[derive(Clone, serde::Serialize, serde::Deserialize)] -pub(super) struct CovenStreamHistoryEntry { - /// Conversation id (used as a stable disambiguator in the UI). - pub conversation_id: String, - /// Final rendered text for the completed stream. - pub text: String, -} - -/// Append an entry to a transcript, coalescing consecutive assistant text into -/// one bubble so streamed `AssistantDelta`s render as a single growing message -/// rather than one bubble per fragment. -#[cfg(feature = "cast-agent")] -fn append_entry_coalescing( - entries: &mut Vec, - entry: crate::agent_transcript::entry::ChatEntry, -) { - use crate::agent_transcript::entry::ChatEntryKind; - if let ChatEntryKind::AssistantResponse { text } = &entry.kind { - if let Some(crate::agent_transcript::entry::ChatEntry { - kind: ChatEntryKind::AssistantResponse { text: prev }, - .. - }) = entries.last_mut() - { - prev.push_str(text); - return; - } - } - entries.push(entry); -} - -/// Convert one structured `CovenAgentEvent` into a `ChatEntry` and buffer it for -/// the UI drain. Only used on the Unix daemon `stream`-mode path. -#[cfg(all(feature = "cast-agent", unix))] -fn buffer_coven_agent_event( - state: &mut CovenStreamState, - event: ::ai::cast_agent::CovenAgentEvent, -) { - let seq = (state.entries.len() + state.pending_entries.len()) as u64; - if let Some(entry) = - crate::ai_assistant::coven_entry::daemon_event_to_entry(event, seq, chrono::Utc::now()) - { - state.pending_entries.push(entry); - } -} - -#[cfg(feature = "cast-agent")] -const COVEN_STREAM_HISTORY_MAX: usize = 5; - -#[derive(Debug, Clone)] -pub enum AIAssistantAction { - ClosePanel, - ResetContext, - CopyTranscript, - PreparedPrompt(&'static str), - ClickedUrl(HyperlinkUrl), - CopyAnswerToClipboard(Arc), - FocusTerminalInput, - FocusEditor, - /// Send the panel's current editor buffer through the Cast Agent - /// streaming endpoint (`/v1/messages/stream` on the Coven Gateway). - /// First UI consumer of the streaming primitive shipped in PR #29. - /// This first cut logs each delta via `log::info!`; rendering the - /// streamed text into the transcript is a follow-up that needs a - /// cross-thread bridge between the cast_agent tokio runtime and - /// the GPUI render loop. - #[cfg(feature = "cast-agent")] - SendViaCovenGateway, - /// Advance the per-conversation Familiar selection to the next entry in - /// the runtime catalog (or the supported-harness fallback list) and - /// re-render the picker chip. - #[cfg(feature = "cast-agent")] - CycleFamiliar, -} - -pub fn init(app: &mut AppContext) { - use warpui::keymap::macros::*; - - app.register_fixed_bindings([FixedBinding::custom( - CustomAction::CloseCurrentSession, - AIAssistantAction::ClosePanel, - "Close Familiar", - id!("AIAssistantPanel"), - )]); - - app.register_editable_bindings([ - EditableBinding::new( - "ai_assistant_panel:focus_terminal_input", - "Focus Terminal Input From Familiar", - AIAssistantAction::FocusTerminalInput, - ) - .with_context_predicate(id!("AIAssistantPanel")) - .with_key_binding(cmd_or_ctrl_shift("l")), - EditableBinding::new( - "ai_assistant_panel:reset_context", - "Restart Familiar", - AIAssistantAction::ResetContext, - ) - .with_context_predicate(id!("AIAssistantPanel")) - .with_key_binding("ctrl-l"), - EditableBinding::new( - "ai_assistant_panel:reset_context", - "Restart Familiar", - AIAssistantAction::ResetContext, - ) - .with_context_predicate(id!("AIAssistantPanel")) - .with_key_binding(cmd_or_ctrl_shift("k")), - ]); - - // Two bindings, same action: Cmd+Enter / Ctrl+Enter is the default - // chat-send chord (standard across most chat UIs), with the older - // Cmd+Shift+M kept as a non-conflicting alternative for muscle - // memory. The AIAssistantPanel context predicate makes both override - // the EditorView's generic `cmd-enter` -> `EditorAction::CmdEnter` - // binding when focus is inside the panel's editor. - #[cfg(feature = "cast-agent")] - app.register_editable_bindings([ - EditableBinding::new( - "ai_assistant_panel:send_via_coven_gateway", - "Run native Coven Code operation", - AIAssistantAction::SendViaCovenGateway, - ) - .with_context_predicate(id!("AIAssistantPanel")) - .with_key_binding(cmd_or_ctrl("enter")), - EditableBinding::new( - "ai_assistant_panel:send_via_coven_gateway:alt", - "Run native Coven Code operation (alternative)", - AIAssistantAction::SendViaCovenGateway, - ) - .with_context_predicate(id!("AIAssistantPanel")) - .with_key_binding(cmd_or_ctrl_shift("m")), - ]); -} - -impl AIAssistantPanelView { - pub fn new( - server_api: Arc, - ai_client: Arc, - ctx: &mut ViewContext, - ) -> Self { - let editor = { - ctx.add_typed_action_view(|ctx| { - let appearance = Appearance::as_ref(ctx); - let options = EditorOptions { - text: TextOptions::ui_text(Some(BODY_FONT_SIZE), appearance), - propagate_and_no_op_vertical_navigation_keys: - PropagateAndNoOpNavigationKeys::Always, - autogrow: true, - soft_wrap: true, - supports_vim_mode: true, - ..Default::default() - }; - - EditorView::new(options, ctx) - }) - }; - editor.update(ctx, |editor, ctx| { - editor.set_placeholder_text(INIT_PLACEHOLDER_TEXT, ctx) - }); - ctx.subscribe_to_view(&editor, |me, _, event, ctx| { - me.handle_editor_event(event, ctx); - }); - - let active_session_model = ActiveSession::handle(ctx); - ctx.observe(&active_session_model, Self::on_active_session_change); - - let requests_model = - ctx.add_model(|ctx| Requests::new(server_api.clone(), ai_client.clone(), ctx)); - ctx.subscribe_to_model(&requests_model, move |me, _, event, ctx| { - me.handle_requests_model_event(event, ctx); - }); - ctx.observe(&requests_model, |_, _, ctx| ctx.notify()); - - let transcript_view = - ctx.add_typed_action_view(|ctx| Transcript::new(&requests_model, ctx)); - ctx.subscribe_to_view(&transcript_view, |me, _, event, ctx| { - me.handle_transcript_event(event, ctx); - }); - - let input_suggestions_view = ctx.add_typed_action_view(InputSuggestions::new); - ctx.subscribe_to_view(&input_suggestions_view, move |me, _, event, ctx| { - me.handle_input_suggestions_event(event, ctx); - }); - - let resizable_data_handle = ResizableData::handle(ctx); - let resizable_state_handle = match resizable_data_handle - .as_ref(ctx) - .get_handle(ctx.window_id(), ModalType::WarpAIWidth) - { - Some(handle) => handle, - None => { - log::error!("Couldn't retrieve warp ai resizable state handle."); - resizable_state_handle(DEFAULT_WARP_AI_WIDTH) - } - }; - - let mut panel = Self { - editor, - transcript_view, - input_suggestions_view, - input_suggestions_mode: InputSuggestionsMode::Closed, - requests_model, - focus_state: PanelFocusState::Editor, - - resizable_state_handle, - mouse_state_handles: Default::default(), - #[cfg(feature = "cast-agent")] - coven_stream: { - let mut state = CovenStreamState::default(); - // Load any persisted history from `~/.coven/stream-history.json` - // so completed streams survive a restart. Live `text` and - // `in_flight` always start fresh — we don't persist - // mid-flight state. - state.history.extend( - super::coven_stream_persist::load() - .into_iter() - .take(COVEN_STREAM_HISTORY_MAX), - ); - Arc::new(std::sync::Mutex::new(state)) - }, - }; - - panel.tick(ctx); - panel.on_active_session_change(active_session_model, ctx); - panel - } - - fn on_active_session_change( - &mut self, - active_session_handle: ModelHandle, - ctx: &mut ViewContext, - ) { - let window_id = ctx.window_id(); - let ai_execution_context = active_session_handle - .as_ref(ctx) - .session(window_id) - .as_ref() - .map(WarpAiExecutionContext::new); - self.requests_model.update(ctx, |requests, _| { - requests.update_ai_execution_context(ai_execution_context); - }); - } - - // Every minute, we re-render to make the next refresh time tick. - fn tick(&self, ctx: &mut ViewContext) { - ctx.spawn( - async move { Timer::after(Duration::from_secs(60)).await }, - |view, _, ctx| { - view.transcript_view.update(ctx, |_, ctx| { - ctx.notify(); - }); - ctx.notify(); - view.tick(ctx); - }, - ); - } - - fn format_as_code_block(&self, content: &str) -> String { - // Intentionally choose a language that won't be interpreted as a shell language - // i.e. (*sh) - format!("```warp\n{}\n```", content.trim()) - } - - // TODO: reconsider if we should be doing all the formatting in here as opposed - // to doing the formatting at source and passing down the prompt to render as is. - pub fn ask_ai(&mut self, ask_type: &AskAIType, ctx: &mut ViewContext) { - match ask_type { - AskAIType::FromTextSelection { - text, - populate_input_box, - } => { - if *populate_input_box { - let prefix = "Explain the following:\n"; - let code_block_formatting_len = self.format_as_code_block("").len(); - let truncated = - if text.chars().count() + prefix.len() + code_block_formatting_len - > PROMPT_CHARACTER_LIMIT - { - // Take the first k characters of the text selection, where k is the - // remaining length after we limit the prompt and add formatting to it. - let truncated: String = text - .chars() - // Take 3 for the ellipsis - .take( - PROMPT_CHARACTER_LIMIT - - prefix.len() - - code_block_formatting_len - - 3, - ) - .collect(); - format!("{truncated}...") - } else { - text.to_string() - }; - - self.editor.update(ctx, |editor, ctx| { - editor.set_buffer_text( - &format!( - "{}{}", - prefix, - self.format_as_code_block(truncated.as_str()) - ), - ctx, - ); - }); - ctx.notify(); - } - } - AskAIType::FromBlock { - input, - output, - exit_code, - .. - } => { - let block_successful = exit_code.was_successful(); - - // Formatting strings. - let question = if block_successful { - "\nWhat should I do next?" - } else { - "\nHow do I fix this?" - }; - let prefix = "I ran the command: `"; - let suffix = "` and got the following output:\n"; - let code_block_formatting_len = self.format_as_code_block("").len(); - let non_input_output_len = - prefix.len() + suffix.len() + question.len() + code_block_formatting_len; - - let input_len = input.chars().count(); - let output_len = output.chars().count(); - - // If the input and output are longer than can be and the input is particularly large, try to - // shave the input down to a fixed number of chars. - let truncated_input = if input_len + output_len + non_input_output_len - > PROMPT_CHARACTER_LIMIT - && input_len > ASK_AI_BLOCK_INPUT_LIMIT - { - let truncated: String = input.chars().take(ASK_AI_BLOCK_INPUT_LIMIT).collect(); - format!("{truncated}...") - } else { - input.to_string() - }; - let truncated_input_len = truncated_input.chars().count(); - - // If the truncated input and raw output are still longer than - // the allowed size, trim down the output. - let truncated_output = if truncated_input_len + output_len + non_input_output_len - > PROMPT_CHARACTER_LIMIT - { - // Take the last k characters of the block's output, where k is the - // remaining length after we limit the prompt and add formatting to it. - // + 3 for the ellipsis. - let output_starting_index = - output_len + truncated_input_len + non_input_output_len + 3 - - PROMPT_CHARACTER_LIMIT; - let truncated: String = output.chars().skip(output_starting_index).collect(); - format!("...{truncated}") - } else { - output.to_string() - }; - - // Insert the truncated strings (with the formatting around them) into the editor. - self.editor.update(ctx, |editor, ctx| { - editor.set_buffer_text( - &format!( - "{prefix}{}{suffix}{}{question}", - truncated_input, - self.format_as_code_block(truncated_output.as_str()) - ), - ctx, - ); - }); - ctx.notify(); - } - AskAIType::FromAICommandSearch { query } => { - let truncated = if query.chars().count() > PROMPT_CHARACTER_LIMIT { - // Reserve 3 for the ellpisis - let truncated: String = - query.chars().take(PROMPT_CHARACTER_LIMIT - 3).collect(); - format!("{truncated}...") - } else { - query.to_string() - }; - self.editor.update(ctx, |editor, ctx| { - editor.set_buffer_text(truncated.as_str(), ctx); - }); - } - // Not supported by the AI Assistant. Only supported by blocklist AI. - AskAIType::FromBlocks { .. } => (), - } - } - - fn is_prompt_too_long(&self, prompt: &str) -> bool { - prompt.chars().count() > PROMPT_CHARACTER_LIMIT - } - - fn is_prompt_empty(&self, prompt: &str) -> bool { - prompt.chars().count() == 0 - } - - fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext) { - match event { - EditorEvent::Enter => { - self.input_suggestions_mode = InputSuggestionsMode::Closed; - let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx); - if !self.is_prompt_too_long(buffer_text.as_str()) - && !self.is_prompt_empty(buffer_text.as_str()) - { - self.issue_primary_request(buffer_text, ctx); - } - ctx.notify(); - } - EditorEvent::Edited(_) => { - // Force a re-render so we can show the character limit warning. - ctx.notify(); - - if let Some(selected_text) = self - .input_suggestions_view - .as_ref(ctx) - .get_selected_item_text() - { - if selected_text == self.editor.as_ref(ctx).buffer_text(ctx) { - return; - } - } - self.input_suggestions_mode = InputSuggestionsMode::Closed; - } - EditorEvent::CmdUpOnFirstRow => { - self.transcript_view.update(ctx, |transcript_view, ctx| { - transcript_view.select_last_code_block(ctx); - }); - ctx.notify(); - } - EditorEvent::Activate => { - self.focus_state = PanelFocusState::Editor; - ctx.focus_self(); - } - EditorEvent::Escape => { - self.input_suggestions_view - .update(ctx, |input_suggestions, ctx| { - input_suggestions.exit(true, ctx); - }); - ctx.notify(); - } - EditorEvent::Navigate(NavigationKey::Up) => { - if matches!(self.input_suggestions_mode, InputSuggestionsMode::Closed) { - let editor = self.editor.as_ref(ctx); - if editor.single_cursor_on_first_row(ctx) { - let buffer_text = editor.buffer_text(ctx); - let all_past_prompts = self - .requests_model - .as_ref(ctx) - .all_past_transcript_prompts(); - self.input_suggestions_view - .update(ctx, |input_suggestions, ctx| { - input_suggestions.fuzzy_substring_search( - buffer_text.clone(), - all_past_prompts, - ctx, - ); - }); - self.input_suggestions_mode = InputSuggestionsMode::Open { - origin_buffer_text: buffer_text, - }; - } else { - self.editor.update(ctx, |editor, ctx| editor.move_up(ctx)); - } - } else { - self.input_suggestions_view - .update(ctx, |input_suggestions, ctx| { - input_suggestions.select_prev(ctx); - }); - } - ctx.notify(); - } - EditorEvent::Navigate(NavigationKey::Down) => { - if matches!( - self.input_suggestions_mode, - InputSuggestionsMode::Open { .. } - ) { - self.input_suggestions_view - .update(ctx, |input_suggestions, ctx| { - if input_suggestions.is_empty() { - input_suggestions.exit(true, ctx); - } else { - input_suggestions.select_next(ctx); - } - }); - } else { - self.editor.update(ctx, |editor, ctx| editor.move_down(ctx)); - } - ctx.notify(); - } - _ => {} - } - } - - fn handle_transcript_event(&mut self, event: &TranscriptEvent, ctx: &mut ViewContext) { - match event { - TranscriptEvent::PasteInTerminalInput { code_block_index } => { - let code = self.transcript_view.read(ctx, |transcript_view, ctx| { - transcript_view.code_for_index(*code_block_index, ctx) - }); - if let Some(code) = code { - ctx.emit(AIAssistantPanelEvent::PasteInTerminalInput(Arc::new(code))); - } - } - TranscriptEvent::FocusEditor => { - self.focus_state = PanelFocusState::Editor; - ctx.focus_self(); - } - TranscriptEvent::ClickedCodeBlock | TranscriptEvent::FocusTranscript => { - self.focus_state = PanelFocusState::Transcript; - ctx.focus_self(); - } - TranscriptEvent::OpenWorkflowModalWithCommand(command) => { - ctx.emit(AIAssistantPanelEvent::OpenWorkflowModalWithCommand( - command.clone(), - )); - } - } - } - - fn handle_requests_model_event(&mut self, event: &RequestsEvent, ctx: &mut ViewContext) { - match event { - RequestsEvent::RequestFinished { .. } => { - self.editor.update(ctx, |editor, ctx| { - editor.clear_buffer_and_reset_undo_stack(ctx); - editor.set_placeholder_text(FOLLOWUP_PLACEHOLDER_TEXT, ctx); - }); - self.transcript_view.update(ctx, |transcript_view, ctx| { - transcript_view.scroll_to_bottom_of_transcript(ctx); - }); - ctx.notify(); - } - } - } - - fn handle_input_suggestions_event( - &mut self, - event: &InputSuggestionsEvent, - ctx: &mut ViewContext, - ) { - match event { - InputSuggestionsEvent::CloseSuggestion { .. } => { - if let InputSuggestionsMode::Open { origin_buffer_text } = - &self.input_suggestions_mode - { - self.editor.update(ctx, |editor, ctx| { - editor.set_buffer_text(origin_buffer_text, ctx); - }); - } - self.input_suggestions_mode = InputSuggestionsMode::Closed; - } - InputSuggestionsEvent::Select(item) => { - self.editor.update(ctx, |editor, ctx| { - editor.set_buffer_text(item.text(), ctx); - }); - } - InputSuggestionsEvent::ConfirmSuggestion { suggestion, .. } => { - self.editor.update(ctx, |editor, ctx| { - editor.set_buffer_text(suggestion, ctx); - }); - self.input_suggestions_mode = InputSuggestionsMode::Closed; - } - InputSuggestionsEvent::ConfirmAndExecuteSuggestion { suggestion, .. } => { - self.issue_primary_request(suggestion.to_string(), ctx); - self.input_suggestions_mode = InputSuggestionsMode::Closed; - } - InputSuggestionsEvent::IgnoreItem { .. } => { - // not worth implementing; feature is hardly used - } - } - ctx.notify(); - } - - fn issue_primary_request(&mut self, request: String, ctx: &mut ViewContext) { - // When the cast-agent feature is compiled in, Coven Code is the - // native — and only — agent this build talks to. We route every - // primary request to it and never fall back to the inherited - // hosted agent path; if the daemon is offline we say so in-band. - #[cfg(feature = "cast-agent")] - match self.send_via_coven_gateway_with_prompt(request, ctx) { - CovenDispatch::Dispatched => { - self.editor.update(ctx, |editor, ctx| { - editor.clear_buffer_and_reset_undo_stack(ctx); - editor.set_placeholder_text(FOLLOWUP_PLACEHOLDER_TEXT, ctx); - }); - ctx.notify(); - } - CovenDispatch::Offline => { - self.surface_coven_offline_notice(ctx); - self.editor.update(ctx, |editor, ctx| { - editor.clear_buffer_and_reset_undo_stack(ctx); - editor.set_placeholder_text(FOLLOWUP_PLACEHOLDER_TEXT, ctx); - }); - ctx.notify(); - } - CovenDispatch::Skipped => {} - } - - #[cfg(not(feature = "cast-agent"))] - self.issue_request(request, ctx); - } - - #[cfg(not(feature = "cast-agent"))] - fn issue_request(&mut self, request: String, ctx: &mut ViewContext) { - self.requests_model.update(ctx, |requests_model, ctx| { - requests_model.issue_request(request, ctx); - }); - self.transcript_view.update(ctx, |transcript_view, ctx| { - transcript_view.clear_selected_block(ctx); - transcript_view.scroll_to_bottom_of_transcript(ctx); - }); - ctx.notify(); - } - - fn reset_context(&mut self, ctx: &mut ViewContext) { - let request_status = self.requests_model.as_ref(ctx).request_status(); - if matches!(request_status, &RequestStatus::InFlight { .. }) { - self.editor.update(ctx, |editor, ctx| { - editor.clear_buffer_and_reset_undo_stack(ctx); - }); - } - - self.editor.update(ctx, |editor, ctx| { - editor.set_placeholder_text(INIT_PLACEHOLDER_TEXT, ctx); - }); - - self.requests_model.update(ctx, |requests_model, ctx| { - requests_model.reset(ctx); - }); - - self.transcript_view.update(ctx, |transcript_view, ctx| { - transcript_view.reset(ctx); - }); - - self.reset_coven_stream(); - self.focus_state = PanelFocusState::Editor; - - ctx.focus_self(); - ctx.notify(); - } - - #[cfg(feature = "cast-agent")] - fn reset_coven_stream(&self) { - let mut state = self.coven_stream.lock().unwrap_or_else(|p| p.into_inner()); - if let Some(task) = state.active_task.take() { - task.abort(); - } - state.text.clear(); - state.pending_chunks.clear(); - state.entries.clear(); - state.pending_entries.clear(); - state.in_flight = false; - state.conversation_id.clear(); - state.history.clear(); - super::coven_stream_persist::save(&[]); - } - - #[cfg(not(feature = "cast-agent"))] - fn reset_coven_stream(&self) {} - - fn copy_transcript(&mut self, ctx: &mut ViewContext) { - let transcript = self.transcript(ctx); - let mut result = String::new(); - let time_now = Local::now(); - - result.push_str(&format!( - "## Familiar Transcript ({})\n\n", - time_now.format("%x %l:%M %p") - )); - - for part in transcript { - result.push_str(&format!("Prompt: {}\n\n", part.raw_user_prompt().trim())); - result.push_str(&format!( - "Familiar: {}\n\n", - part.raw_assistant_answer().trim() - )); - } - - ctx.clipboard() - .write(ClipboardContent::plain_text(result.trim().to_string())); - } - - fn should_render_zero_state(&self, app: &AppContext) -> bool { - self.transcript(app).is_empty() - && matches!(self.request_status(app), RequestStatus::NotInFlight) - && !self.has_coven_stream_content() - } - - #[cfg(feature = "cast-agent")] - fn has_coven_stream_content(&self) -> bool { - let state = self.coven_stream.lock().unwrap_or_else(|p| p.into_inner()); - state.in_flight - || !state.text.is_empty() - || !state.entries.is_empty() - || !state.history.is_empty() - } - - #[cfg(not(feature = "cast-agent"))] - fn has_coven_stream_content(&self) -> bool { - false - } - - fn transcript<'a>(&self, app: &'a AppContext) -> &'a [TranscriptPart] { - self.requests_model.as_ref(app).transcript() - } - - fn request_status<'a>(&self, app: &'a AppContext) -> &'a RequestStatus { - self.requests_model.as_ref(app).request_status() - } - - fn num_remaining_reqs(&self, app: &AppContext) -> usize { - self.requests_model.as_ref(app).num_remaining_reqs() - } - - #[cfg(feature = "integration_tests")] - pub fn editor(&self) -> &ViewHandle { - &self.editor - } -} - -/// All rendering related capabilities. -impl AIAssistantPanelView { - fn render_title_bar(&self, appearance: &Appearance, app: &AppContext) -> Box { - let mut header = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child( - Container::new( - ConstrainedBox::new( - Icon::new(AI_ASSISTANT_SVG_PATH, *AI_ASSISTANT_LOGO_COLOR).finish(), - ) - .with_height(LOGO_SIZE) - .with_width(LOGO_SIZE) - .finish(), - ) - .with_padding_right(4.) - .finish(), - ) - .with_child( - Container::new( - appearance - .ui_builder() - .wrappable_text(AI_ASSISTANT_FEATURE_NAME.to_string(), false) - .with_style(UiComponentStyles { - font_family_id: Some(appearance.ui_font_family()), - font_size: Some(TITLE_FONT_SIZE), - font_weight: Some(warpui::fonts::Weight::Semibold), - font_color: Some(appearance.theme().active_ui_text_color().into()), - ..Default::default() - }) - .build() - .finish(), - ) - .finish(), - ) - .with_child(self.render_gateway_status_pill()) - .with_child(self.render_familiar_pill(appearance)) - .with_child(Shrinkable::new(1., Empty::new().finish()).finish()); - - // Add the copy and restart buttons iff the transcript is non-empty or there's a request in flight; - if !self.transcript(app).is_empty() - || matches!(self.request_status(app), RequestStatus::InFlight { .. }) - { - header.add_child( - Container::new(Align::new(self.render_restart_button(appearance)).finish()) - .with_margin_right(4.) - .finish(), - ); - - header.add_child( - Container::new(self.render_copy_transcript_button(appearance)) - .with_margin_right(4.) - .finish(), - ); - } - - // Add the close button - header.add_child( - Container::new( - icon_button( - appearance, - crate::ui_components::icons::Icon::X, - false, - self.mouse_state_handles.close_panel_state.clone(), - ) - .build() - .on_click(|ctx, _, _| ctx.dispatch_typed_action(AIAssistantAction::ClosePanel)) - .with_cursor(Cursor::PointingHand) - .finish(), - ) - .finish(), - ); - - header.finish() - } - - /// Stream a prompt through the Cast Agent `stream_messages` primitive - /// and render the streamed chunks live in the agent panel. - /// - /// Cross-thread plumbing: the cast_agent tokio task pushes chunks - /// into `self.coven_stream.pending_chunks` (a shared - /// `Arc>`). A UI-side poll loop - /// (`poll_coven_stream`) drains the buffer every 100ms via - /// `ctx.spawn` + `Timer::after`, appends `Delta` content to - /// `text`, calls `ctx.notify()`, and reschedules itself while - /// `in_flight` is true. - /// - /// Logs each chunk too — useful for debugging when the rendered - /// output looks wrong and you want raw protocol detail. - /// - /// Returns [`CovenDispatch`] describing whether the prompt was sent, - /// skipped, or could not be sent because the Coven runtime/daemon is - /// offline. Callers never fall back to the inherited hosted path. - #[cfg(feature = "cast-agent")] - fn send_via_coven_gateway_with_prompt( - &self, - prompt: String, - ctx: &mut ViewContext, - ) -> CovenDispatch { - use futures::StreamExt; - - if prompt.trim().is_empty() { - log::debug!("cast_agent: SendViaCovenGateway invoked with empty prompt; skipping"); - return CovenDispatch::Skipped; - } - let Some(runtime) = ::ai::cast_agent::global() else { - log::warn!("cast_agent: runtime unavailable, cannot stream"); - return CovenDispatch::Offline; - }; - let direct_socket_path = if runtime.is_available() { - None - } else { - ::ai::cast_agent::CastAgentConfig::default_socket_path() - .filter(|path| path.as_path().exists()) - }; - if direct_socket_path.is_none() && !runtime.is_available() { - log::info!("cast_agent: gateway offline and Coven daemon socket unavailable"); - return CovenDispatch::Offline; - } - - let agent = runtime.agent().clone(); - // Lightweight per-invocation id: monotonic wall-clock nanos. Good - // enough to disambiguate concurrent streams in logs. - let conversation_id = format!( - "coven-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - ); - // Carry the user's working directory across the wire so the - // bridge can launch the daemon session against the active - // workspace rather than falling back to $HOME. Falls back to - // letting the bridge default (CASTCODES_BRIDGE_PROJECT_ROOT - // or $HOME) when current_dir() fails. - let cwd = match std::env::current_dir() { - Ok(cwd) => Some(cwd), - Err(err) => { - log::debug!( - "cast_agent: current_dir() failed ({err}); bridge will default projectRoot" - ); - None - } - }; - let selected_familiar = { - let state = self.coven_stream.lock().unwrap_or_else(|p| p.into_inner()); - state.selected_familiar.clone() - }; - let catalog = runtime.familiars(); - let default_familiar = runtime.agent().config().default_familiar.clone(); - let target = ::ai::cast_agent::resolve( - selected_familiar.as_deref(), - default_familiar.as_deref(), - &catalog, - ); - let body = coven_code_agent_message_body(prompt, cwd.as_deref(), &target); - let msg = ::ai::cast_agent::AgentMessage { - conversation_id: conversation_id.clone(), - body, - }; - - // Reset shared state for the new stream. If a previous stream - // is still running (back-to-back keybind presses), abort its - // tokio task and archive whatever text it produced into history - // before clearing — so the user sees their old streams as a - // newest-first ring above the live section, not as interleaved - // garbage in the active text. - { - let mut state = self.coven_stream.lock().unwrap_or_else(|p| p.into_inner()); - if let Some(previous) = state.active_task.take() { - previous.abort(); - log::info!( - "cast_agent: aborting previous stream {} for new conversation {conversation_id}", - state.conversation_id - ); - } - if !state.text.is_empty() { - let archived = CovenStreamHistoryEntry { - conversation_id: state.conversation_id.clone(), - text: std::mem::take(&mut state.text), - }; - state.history.push_back(archived); - while state.history.len() > COVEN_STREAM_HISTORY_MAX { - state.history.pop_front(); - } - // Persist the updated history so it survives restarts. - // We snapshot inside the lock then write while still - // holding it — the write is a few KB to local disk, - // microseconds in practice, and avoids the bookkeeping - // of dropping + re-locking around the rest of the - // reset path. - let snapshot: Vec = - state.history.iter().cloned().collect(); - super::coven_stream_persist::save(&snapshot); - } else { - state.text.clear(); - } - state.pending_chunks.clear(); - // Structured entries are live-only in Phase 1 (not archived to the - // text history); a new stream starts with a fresh transcript. - state.entries.clear(); - state.pending_entries.clear(); - state.in_flight = true; - state.conversation_id = conversation_id.clone(); - } - ctx.notify(); - - log::info!("cast_agent: opening stream for conversation {conversation_id}"); - let buffer = self.coven_stream.clone(); - let conversation_id_for_task = conversation_id.clone(); - let join = runtime.handle().spawn(async move { - // Prefer the daemon `stream`-mode structured path (rich rendering) - // on Unix. Falls through to the plain-text `stream_messages` path - // below when unavailable (non-unix, TCP bridge, or open error), so - // the change is strictly additive. - #[cfg(unix)] - { - match agent.stream_agent_events(msg.clone()).await { - Ok(mut ev_stream) => { - while let Some(event) = ev_stream.next().await { - let terminal = matches!( - event, - ::ai::cast_agent::CovenAgentEvent::Done - | ::ai::cast_agent::CovenAgentEvent::Error { .. } - ); - { - let mut state = - buffer.lock().unwrap_or_else(|p| p.into_inner()); - buffer_coven_agent_event(&mut state, event); - } - if terminal { - break; - } - } - let mut state = buffer.lock().unwrap_or_else(|p| p.into_inner()); - state.in_flight = false; - return; - } - Err(err) => { - log::info!( - "cast_agent[{conversation_id_for_task}] stream_agent_events unavailable ({err}); using plain streaming" - ); - } - } - } - - let fallback_msg = msg.clone(); - let stream = match agent.stream_messages(msg).await { - Ok(stream) => stream, - Err(err) => { - log::warn!( - "cast_agent: stream_messages open failed for {conversation_id_for_task}: {err}" - ); - let fallback_agent = if let Some(socket_path) = direct_socket_path { - let mut config = ::ai::cast_agent::CastAgentConfig::load(); - config.socket_path = Some(socket_path); - Some(::ai::cast_agent::CastAgent::new(Some(config)).await) - } else { - None - }; - let fallback_result = match fallback_agent.as_ref() { - Some(agent) => { - use ::ai::cast_agent::AgentBackend as _; - agent.send_message(fallback_msg).await - } - None => { - use ::ai::cast_agent::AgentBackend as _; - agent.send_message(fallback_msg).await - } - }; - let mut state = buffer.lock().unwrap_or_else(|p| p.into_inner()); - match fallback_result { - Ok(response) => { - let content = response - .body - .get("text") - .and_then(serde_json::Value::as_str) - .map(str::to_owned) - .unwrap_or_else(|| response.body.to_string()); - if !content.is_empty() { - state.pending_chunks.push(::ai::cast_agent::MessageChunk::Delta { - conversation_id: conversation_id_for_task.clone(), - content, - }); - } - state.pending_chunks.push(::ai::cast_agent::MessageChunk::Done { - conversation_id: conversation_id_for_task.clone(), - }); - } - Err(fallback_err) => { - log::warn!( - "cast_agent: fallback send_message failed for {conversation_id_for_task}: {fallback_err}" - ); - state.pending_chunks.push(::ai::cast_agent::MessageChunk::Error { - conversation_id: conversation_id_for_task.clone(), - message: "Coven Code operation failed to start.".to_string(), - }); - } - } - state.in_flight = false; - return; - } - }; - let mut stream = stream; - while let Some(chunk_result) = stream.next().await { - match chunk_result { - Ok(chunk) => { - match &chunk { - ::ai::cast_agent::MessageChunk::Delta { content, .. } => { - log::info!( - "cast_agent[{conversation_id_for_task}] delta: {content}" - ); - } - ::ai::cast_agent::MessageChunk::Done { .. } => { - log::info!("cast_agent[{conversation_id_for_task}] done"); - } - ::ai::cast_agent::MessageChunk::Error { message, .. } => { - log::warn!( - "cast_agent[{conversation_id_for_task}] error: {message}" - ); - } - } - let mut state = buffer.lock().unwrap_or_else(|p| p.into_inner()); - state.pending_chunks.push(chunk); - } - Err(err) => { - log::warn!( - "cast_agent[{conversation_id_for_task}] transport: {err}" - ); - break; - } - } - } - let mut state = buffer.lock().unwrap_or_else(|p| p.into_inner()); - state.in_flight = false; - }); - - // Stash the join handle so a subsequent invocation can abort it. - { - let mut state = self.coven_stream.lock().unwrap_or_else(|p| p.into_inner()); - state.active_task = Some(join); - } - - // Kick off the UI drain loop. - self.poll_coven_stream(ctx); - CovenDispatch::Dispatched - } - - /// Surface an in-band notice that Coven Code is offline. Mirrors the - /// stream-reset path: any live text is archived into history first so - /// the notice replaces the live section without clobbering prior - /// output, then the notice is rendered as the current stream text. - #[cfg(feature = "cast-agent")] - fn surface_coven_offline_notice(&self, ctx: &mut ViewContext) { - { - let mut state = self.coven_stream.lock().unwrap_or_else(|p| p.into_inner()); - if let Some(previous) = state.active_task.take() { - previous.abort(); - } - if !state.text.is_empty() { - let archived = CovenStreamHistoryEntry { - conversation_id: state.conversation_id.clone(), - text: std::mem::take(&mut state.text), - }; - state.history.push_back(archived); - while state.history.len() > COVEN_STREAM_HISTORY_MAX { - state.history.pop_front(); - } - let snapshot: Vec = - state.history.iter().cloned().collect(); - super::coven_stream_persist::save(&snapshot); - } - state.pending_chunks.clear(); - state.entries.clear(); - state.pending_entries.clear(); - state.in_flight = false; - state.conversation_id.clear(); - state.text = COVEN_CODE_OFFLINE_NOTICE.to_string(); - } - ctx.notify(); - } - - #[cfg(feature = "cast-agent")] - fn send_via_coven_gateway(&self, ctx: &mut ViewContext) { - let prompt = self.editor.as_ref(ctx).buffer_text(ctx).to_string(); - match self.send_via_coven_gateway_with_prompt(prompt, ctx) { - CovenDispatch::Dispatched => { - self.editor.update(ctx, |editor, ctx| { - editor.clear_buffer_and_reset_undo_stack(ctx); - editor.set_placeholder_text(FOLLOWUP_PLACEHOLDER_TEXT, ctx); - }); - } - CovenDispatch::Offline => { - self.surface_coven_offline_notice(ctx); - self.editor.update(ctx, |editor, ctx| { - editor.clear_buffer_and_reset_undo_stack(ctx); - editor.set_placeholder_text(FOLLOWUP_PLACEHOLDER_TEXT, ctx); - }); - } - CovenDispatch::Skipped => {} - } - } - - /// UI-side tick that drains `pending_chunks` into rendered `text` - /// and reschedules itself while the stream is in flight. Lives on - /// the GPUI executor (`ctx.spawn` + `Timer::after`) so each tick - /// runs on the UI thread with full `view + ctx` access — no - /// cross-thread notify primitive needed. - #[cfg(feature = "cast-agent")] - fn poll_coven_stream(&self, ctx: &mut ViewContext) { - ctx.spawn( - async move { Timer::after(Duration::from_millis(100)).await }, - |view, _, ctx| { - let still_in_flight = { - let mut state = view.coven_stream.lock().unwrap_or_else(|p| p.into_inner()); - let drained: Vec<_> = state.pending_chunks.drain(..).collect(); - for chunk in drained { - match chunk { - ::ai::cast_agent::MessageChunk::Delta { content, .. } => { - state.text.push_str(&content); - } - ::ai::cast_agent::MessageChunk::Error { message, .. } => { - if !state.text.is_empty() { - state.text.push('\n'); - } - state.text.push_str(&message); - } - ::ai::cast_agent::MessageChunk::Done { .. } => {} - } - } - // Drain structured entries (daemon stream-mode path), - // coalescing consecutive assistant text into one bubble. - let drained_entries: Vec<_> = state.pending_entries.drain(..).collect(); - for entry in drained_entries { - append_entry_coalescing(&mut state.entries, entry); - } - state.in_flight - }; - ctx.notify(); - if still_in_flight { - view.poll_coven_stream(ctx); - } - }, - ); - } - - /// Render the Coven Gateway stream section below the transcript. - /// Shows up to [`COVEN_STREAM_HISTORY_MAX`] completed streams above - /// the live (or most recent) one, newest-first. Hidden entirely - /// when there's nothing — neither history nor live text — so the - /// panel doesn't grow chrome on idle. - #[cfg(feature = "cast-agent")] - fn render_coven_stream_section(&self, appearance: &Appearance) -> Box { - const SECTION_HEADER_FONT_SIZE: f32 = 10.; - const BODY_FONT_SIZE: f32 = 13.; - - let (text, in_flight, history, entries) = { - let state = self.coven_stream.lock().unwrap_or_else(|p| p.into_inner()); - ( - state.text.clone(), - state.in_flight, - state.history.clone(), - state.entries.clone(), - ) - }; - if text.is_empty() && entries.is_empty() && !in_flight && history.is_empty() { - return Empty::new().finish(); - } - - let theme = appearance.theme(); - let primary: pathfinder_color::ColorU = theme.active_ui_text_color().into(); - let ui_builder = appearance.ui_builder(); - let ui_font = appearance.ui_font_family(); - - let make_entry = |label: String, body_text: String, dim: bool| -> Box { - let body_color = if dim { - theme.muted_foreground() - } else { - primary - }; - let header = Container::new( - ui_builder - .wrappable_text(label, false) - .with_style(UiComponentStyles { - font_family_id: Some(ui_font), - font_size: Some(SECTION_HEADER_FONT_SIZE), - font_weight: Some(warpui::fonts::Weight::Semibold), - font_color: Some(theme.muted_foreground()), - ..Default::default() - }) - .build() - .finish(), - ) - .with_padding_bottom(4.) - .finish(); - let body = Container::new( - ui_builder - .wrappable_text(body_text, true) - .with_style(UiComponentStyles { - font_family_id: Some(ui_font), - font_size: Some(BODY_FONT_SIZE), - font_color: Some(body_color), - ..Default::default() - }) - .build() - .finish(), - ) - .finish(); - Container::new( - Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(header) - .with_child(body) - .finish(), - ) - .with_padding_bottom(8.) - .finish() - }; - - let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Start); - - // History: newest-first (history is push_back'd so the back is - // most recent), dimmed to distinguish from the live section. - for entry in history.iter().rev() { - let label = format!("COVEN STREAM • {}", entry.conversation_id); - column.add_child(make_entry(label, entry.text.clone(), true)); - } - - // Active stream (live or most recently completed). When structured - // entries are present (daemon stream-mode), render them richly via the - // shared agent_transcript views; otherwise fall back to plain text. - if !entries.is_empty() { - let label = if in_flight { - "COVEN STREAM • LIVE".to_string() - } else { - "COVEN STREAM".to_string() - }; - let header = Container::new( - ui_builder - .wrappable_text(label, false) - .with_style(UiComponentStyles { - font_family_id: Some(ui_font), - font_size: Some(SECTION_HEADER_FONT_SIZE), - font_weight: Some(warpui::fonts::Weight::Semibold), - font_color: Some(theme.muted_foreground()), - ..Default::default() - }) - .build() - .finish(), - ) - .with_padding_bottom(4.) - .finish(); - let body = crate::agent_transcript::view::transcript::render_entries( - &entries, - ui_font, - BODY_FONT_SIZE, - ); - column.add_child( - Container::new( - Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(header) - .with_child(body) - .finish(), - ) - .with_padding_bottom(8.) - .finish(), - ); - } else if !text.is_empty() || in_flight { - let label = if in_flight { - "COVEN STREAM • LIVE".to_string() - } else { - "COVEN STREAM".to_string() - }; - let body_text = if text.is_empty() && in_flight { - "Awaiting first chunk…".to_string() - } else { - text - }; - column.add_child(make_entry(label, body_text, false)); - } - - Container::new(column.finish()) - .with_padding_left(PANEL_HORIZONTAL_PADDING) - .with_padding_right(PANEL_HORIZONTAL_PADDING) - .with_padding_top(8.) - .with_padding_bottom(8.) - .with_border(Border::top(1.).with_border_fill(theme.outline())) - .finish() - } - - /// Small coloured dot next to the title indicating Coven Gateway - /// reachability. Green when `is_available()`, amber otherwise. - /// Reads the cached availability bit from the cast_agent runtime - /// (refreshed by a background health-probe loop) so this is cheap - /// to call on every render. - fn render_gateway_status_pill(&self) -> Box { - const PILL_SIZE: f32 = 8.; - let online = { - #[cfg(feature = "cast-agent")] - { - ::ai::cast_agent::is_available() - } - #[cfg(not(feature = "cast-agent"))] - { - false - } - }; - let color = if online { - OPENCOVEN_SUCCESS - } else { - OPENCOVEN_WARNING - }; - Container::new( - ConstrainedBox::new(Empty::new().finish()) - .with_width(PILL_SIZE) - .with_height(PILL_SIZE) - .finish(), - ) - .with_background(Fill::Solid(color)) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(PILL_SIZE / 2.))) - .with_margin_left(8.) - .with_margin_right(4.) - .finish() - } - - /// Advance this conversation's Familiar selection to the next entry in - /// the runtime catalog (or the supported-harness fallback list when the - /// daemon has no catalog). Wraps around; a `None` (default) selection - /// jumps to the first option. - #[cfg(feature = "cast-agent")] - fn cycle_familiar(&mut self, _ctx: &mut ViewContext) { - let catalog = ::ai::cast_agent::global() - .map(|r| r.familiars()) - .unwrap_or_default(); - let options: Vec = if catalog.is_empty() { - ::ai::cast_agent::SUPPORTED_HARNESSES - .iter() - .map(|s| s.to_string()) - .collect() - } else { - catalog.iter().map(|f| f.id.clone()).collect() - }; - if options.is_empty() { - return; - } - let mut state = self.coven_stream.lock().unwrap_or_else(|p| p.into_inner()); - let current = state.selected_familiar.clone(); - let next_idx = current - .as_ref() - .and_then(|c| options.iter().position(|o| o == c)) - .map(|i| (i + 1) % options.len()) - .unwrap_or(0); - state.selected_familiar = Some(options[next_idx].clone()); - } - - /// Human-readable label for this conversation's resolved Familiar/harness, - /// used on the picker chip. Prefers the catalog `emoji + label` when the - /// resolved target is a catalog Familiar; otherwise shows the raw harness. - #[cfg(feature = "cast-agent")] - fn current_familiar_label(&self) -> String { - let runtime = ::ai::cast_agent::global(); - let catalog = runtime.as_ref().map(|r| r.familiars()).unwrap_or_default(); - let default_familiar = runtime - .as_ref() - .map(|r| r.agent().config().default_familiar.clone()) - .unwrap_or_default(); - let selected = { - let state = self.coven_stream.lock().unwrap_or_else(|p| p.into_inner()); - state.selected_familiar.clone() - }; - match ::ai::cast_agent::resolve(selected.as_deref(), default_familiar.as_deref(), &catalog) - { - ::ai::cast_agent::RequestTarget::Familiar(id) => catalog - .iter() - .find(|f| f.id == id) - .map(|f| { - if f.emoji.is_empty() { - f.label().to_string() - } else { - format!("{} {}", f.emoji, f.label()) - } - }) - .unwrap_or(id), - ::ai::cast_agent::RequestTarget::Harness(h) => h, - } - } - - /// Clickable text chip next to the gateway status pill. Shows the - /// current conversation's Familiar/harness and cycles to the next - /// option on click via [`AIAssistantAction::CycleFamiliar`]. - #[cfg(feature = "cast-agent")] - fn render_familiar_pill(&self, appearance: &Appearance) -> Box { - let default_styles = UiComponentStyles { - border_width: None, - font_color: Some(appearance.theme().muted_foreground()), - font_size: Some(11.), - font_family_id: Some(appearance.ui_font_family()), - padding: Some(Coords { - top: 2., - bottom: 2., - left: 6., - right: 6., - }), - border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), - ..Default::default() - }; - - let hover_style = UiComponentStyles { - background: Some(appearance.theme().surface_3().into()), - ..default_styles - }; - - appearance - .ui_builder() - .button_with_custom_styles( - ButtonVariant::Text, - self.mouse_state_handles.familiar_pill.clone(), - default_styles, - Some(hover_style), - Some(hover_style), - Some(hover_style), - ) - .with_text_label(self.current_familiar_label()) - .build() - .on_click(move |ctx, _, _| ctx.dispatch_typed_action(AIAssistantAction::CycleFamiliar)) - .with_cursor(Cursor::PointingHand) - .finish() - } - - /// Empty fallback when the cast-agent backend is compiled out so - /// `render_title_bar` stays a single code path. - #[cfg(not(feature = "cast-agent"))] - fn render_familiar_pill(&self, _appearance: &Appearance) -> Box { - Empty::new().finish() - } - - /// Render the "Coven Sessions" section between the transcript and the - /// editor area. Hidden when the session list is empty so the panel - /// doesn't gain dead chrome before the gateway has answered. - /// - /// No click handler in this first cut — the click-through (open a - /// terminal pane at the session CWD) requires touching the workspace - /// pane API and is intentionally deferred. See `CAST-AGENT.md`. - #[cfg(not(feature = "cast-agent"))] - fn render_sessions_section(&self, _: &Appearance) -> Box { - Empty::new().finish() - } - - #[cfg(feature = "cast-agent")] - fn render_sessions_section(&self, appearance: &Appearance) -> Box { - const SECTION_HEADER_FONT_SIZE: f32 = 10.; - const ROW_FONT_SIZE: f32 = 12.; - const STATUS_DOT_SIZE: f32 = 6.; - const ROW_VERTICAL_PADDING: f32 = 2.; - - let sessions: Vec<::ai::cast_agent::CovenSession> = { - #[cfg(feature = "cast-agent")] - { - ::ai::cast_agent::sessions() - } - #[cfg(not(feature = "cast-agent"))] - { - Vec::new() - } - }; - if sessions.is_empty() { - return Empty::new().finish(); - } - - let theme = appearance.theme(); - let primary: pathfinder_color::ColorU = theme.active_ui_text_color().into(); - let ui_builder = appearance.ui_builder(); - let ui_font = appearance.ui_font_family(); - - let mut section = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Start) - .with_child( - Container::new( - ui_builder - .wrappable_text("COVEN SESSIONS".to_string(), false) - .with_style(UiComponentStyles { - font_family_id: Some(ui_font), - font_size: Some(SECTION_HEADER_FONT_SIZE), - font_weight: Some(warpui::fonts::Weight::Semibold), - font_color: Some(theme.muted_foreground()), - ..Default::default() - }) - .build() - .finish(), - ) - .with_padding_bottom(4.) - .finish(), - ); - - for session in sessions.iter() { - let dot_color = match session.status { - ::ai::cast_agent::SessionStatus::Active => OPENCOVEN_SUCCESS, - ::ai::cast_agent::SessionStatus::Idle => OPENCOVEN_WARNING, - ::ai::cast_agent::SessionStatus::Closed => theme.muted_foreground(), - }; - let mut row = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child( - Container::new( - ConstrainedBox::new(Empty::new().finish()) - .with_width(STATUS_DOT_SIZE) - .with_height(STATUS_DOT_SIZE) - .finish(), - ) - .with_background(Fill::Solid(dot_color)) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels( - STATUS_DOT_SIZE / 2., - ))) - .with_margin_right(8.) - .finish(), - ) - .with_child( - Container::new( - ui_builder - .wrappable_text(session.name.clone(), false) - .with_style(UiComponentStyles { - font_family_id: Some(ui_font), - font_size: Some(ROW_FONT_SIZE), - font_color: Some(primary), - ..Default::default() - }) - .build() - .finish(), - ) - .with_margin_right(8.) - .finish(), - ); - if let Some(last_active) = session.last_active.as_deref() { - row.add_child(Shrinkable::new(1., Empty::new().finish()).finish()); - row.add_child( - ui_builder - .wrappable_text(last_active.to_string(), false) - .with_style(UiComponentStyles { - font_family_id: Some(ui_font), - font_size: Some(ROW_FONT_SIZE), - font_color: Some(theme.muted_foreground()), - ..Default::default() - }) - .build() - .finish(), - ); - } - // Rows whose session has a CWD become clickable — clicking - // dispatches a `WorkspaceAction::OpenCovenSessionInNewTab` which - // opens a new terminal tab whose shell starts at that CWD. Rows - // with no CWD render the same but stay inert (the gateway didn't - // tell us where the session lives, so we have nowhere to open). - let row_element: Box = if let Some(cwd) = session.cwd.clone() { - let session_name = session.name.clone(); - EventHandler::new(row.finish()) - .on_left_mouse_down(move |ctx, _, _| { - ctx.dispatch_typed_action(WorkspaceAction::OpenCovenSessionInNewTab { - name: session_name.clone(), - cwd: cwd.clone(), - }); - DispatchEventResult::StopPropagation - }) - .finish() - } else { - row.finish() - }; - section.add_child( - Container::new(row_element) - .with_padding_top(ROW_VERTICAL_PADDING) - .with_padding_bottom(ROW_VERTICAL_PADDING) - .finish(), - ); - } - - Container::new(section.finish()) - .with_padding_left(PANEL_HORIZONTAL_PADDING) - .with_padding_right(PANEL_HORIZONTAL_PADDING) - .with_padding_top(8.) - .with_padding_bottom(8.) - .with_border(Border::top(1.).with_border_fill(theme.outline())) - .finish() - } - - fn render_copy_transcript_button(&self, appearance: &Appearance) -> Box { - let tooltip_background = appearance.theme().surface_1().into_solid(); - let ui_builder = appearance.ui_builder().clone(); - icon_button( - appearance, - crate::ui_components::icons::Icon::Copy, - false, - self.mouse_state_handles.copy_transcript_button.clone(), - ) - .with_tooltip(move || { - let tool_tip_style = UiComponentStyles { - background: Some(Fill::Solid(tooltip_background)), - ..Default::default() - }; - ui_builder - .tool_tip("Copy transcript to clipboard".to_owned()) - .with_style(tool_tip_style) - .build() - .finish() - }) - .build() - .on_click(move |ctx, _, _| ctx.dispatch_typed_action(AIAssistantAction::CopyTranscript)) - .with_cursor(Cursor::PointingHand) - .finish() - } - - fn render_restart_button(&self, appearance: &Appearance) -> Box { - let default_styles = UiComponentStyles { - border_width: None, - font_color: Some(appearance.theme().active_ui_text_color().into()), - font_size: Some(12.), - font_family_id: Some(appearance.ui_font_family()), - padding: Some(Coords { - top: 4., - bottom: 4., - left: 8., - right: 8., - }), - border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), - ..Default::default() - }; - - let hover_style = UiComponentStyles { - background: Some(appearance.theme().surface_3().into()), - ..default_styles - }; - - appearance - .ui_builder() - .button_with_custom_styles( - ButtonVariant::Text, - self.mouse_state_handles.reset_context_button.clone(), - default_styles, - Some(hover_style), - Some(hover_style), - Some(hover_style), - ) - .with_text_label(RESTART_BUTTON_TEXT.to_owned()) - .build() - .on_click(move |ctx, _, _| ctx.dispatch_typed_action(AIAssistantAction::ResetContext)) - .with_cursor(Cursor::PointingHand) - .finish() - } - - fn render_editor_size_warning( - &self, - appearance: &Appearance, - buffer_len: usize, - ) -> Box { - Flex::row() - .with_children([ - Container::new( - Text::new_inline( - "Character limit exceeded.", - appearance.ui_font_family(), - BODY_FONT_SIZE, - ) - .with_style(Properties { - weight: warpui::fonts::Weight::Bold, - ..Default::default() - }) - .with_color(appearance.theme().ui_error_color()) - .finish(), - ) - .with_margin_right(10.) - .finish(), - Text::new_inline( - format!("{buffer_len} / {PROMPT_CHARACTER_LIMIT}"), - appearance.ui_font_family(), - BODY_FONT_SIZE, - ) - .with_color(appearance.theme().ui_error_color()) - .finish(), - ]) - .finish() - } - - fn render_editor(&self) -> Box { - SavePosition::new( - ConstrainedBox::new(ChildView::new(&self.editor).finish()) - .with_max_height(MAX_EDITOR_HEIGHT) - .finish(), - EDITOR_SAVE_POSITION_ID, - ) - .finish() - } - - fn render_input_suggestions_menu(&self, appearance: &Appearance) -> Box { - ConstrainedBox::new( - Container::new(ChildView::new(&self.input_suggestions_view).finish()) - .with_uniform_margin(10.) - .with_background(appearance.theme().surface_2()) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) - .with_border(Border::all(1.0).with_border_fill(appearance.theme().outline())) - .finish(), - ) - .with_max_height(MAX_INPUT_SUGGESTIONS_HEIGHT) - .finish() - } - - fn render_zero_state(&self, appearance: &Appearance, app: &AppContext) -> Box { - let theme = appearance.theme(); - - // TODO: fill should be Copy. it's cheap (a few ColorU's at most). - let sub_text_color = blended_colors::text_sub(theme, theme.surface_2()); - let thick_overlay_color = theme.surface_3(); - - let mut column = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child( - ConstrainedBox::new(Icon::new(AI_ASSISTANT_SVG_PATH, thick_overlay_color).finish()) - .with_height(44.) - .with_width(44.) - .finish(), - ) - .with_child( - Container::new( - Text::new_inline(ASK_AI_ASSISTANT_TEXT, appearance.ui_font_family(), 14.) - .with_color(sub_text_color) - .finish(), - ) - .with_margin_top(8.) - .finish(), - ); - - if self.num_remaining_reqs(app) > 0 { - column.add_children([ - Container::new(render_prepared_response_button( - appearance, - self.mouse_state_handles.git_zero_state_prompt.clone(), - Some(300.), - None, - GIT_ZERO_STATE_PROMPT, - )) - .with_margin_top(20.) - .with_margin_bottom(10.) - .finish(), - Container::new(render_prepared_response_button( - appearance, - self.mouse_state_handles.files_zero_state_prompt.clone(), - Some(300.), - None, - FILES_ZERO_STATE_PROMPT, - )) - .with_margin_bottom(10.) - .finish(), - Container::new(render_prepared_response_button( - appearance, - self.mouse_state_handles.script_zero_state_prompt.clone(), - Some(300.), - None, - SCRIPT_ZERO_STATE_PROMPT, - )) - .finish(), - ]); - } - - column.add_child( - Container::new( - Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::Center) - .with_child( - Container::new( - ConstrainedBox::new( - Icon::new(INFO_ICON_SVG_PATH, theme.active_ui_text_color()) - .finish(), - ) - .with_height(15.) - .with_width(15.) - .finish(), - ) - .with_margin_right(8.) - .finish(), - ) - .with_child( - Shrinkable::new( - 1., - appearance - .ui_builder() - .wrappable_text(ZERO_STATE_HELP_TEXT.to_string(), true) - .with_style(UiComponentStyles { - font_family_id: Some(appearance.ui_font_family()), - font_size: Some(ZERO_STATE_HELP_TEXT_FONT_SIZE), - font_color: Some(theme.active_ui_text_color().into()), - ..Default::default() - }) - .build() - .finish(), - ) - .finish(), - ) - .finish(), - ) - .with_margin_top(25.) - .finish(), - ); - - let is_custom_llm_enabled: bool = UserWorkspaces::as_ref(app) - .current_team() - .is_some_and(|team| team.is_custom_llm_enabled()); - - if !is_custom_llm_enabled { - column.add_child( - Container::new(render_request_limit_info( - &self.requests_model, - app, - appearance, - )) - .with_margin_top(18.) - .finish(), - ); - } - - Container::new(column.finish()) - .with_margin_left(12.) - .with_margin_right(12.) - .with_padding_top(50.) - .finish() - } -} - -impl Entity for AIAssistantPanelView { - type Event = AIAssistantPanelEvent; -} - -impl TypedActionView for AIAssistantPanelView { - type Action = AIAssistantAction; - - fn handle_action(&mut self, action: &AIAssistantAction, ctx: &mut ViewContext) { - use AIAssistantAction::*; - - match action { - ResetContext => { - self.reset_context(ctx); - } - CopyTranscript => { - self.copy_transcript(ctx); - } - ClosePanel => { - ctx.emit(AIAssistantPanelEvent::ClosePanel); - } - PreparedPrompt(prompt) => { - self.issue_primary_request(prompt.to_string(), ctx); - } - ClickedUrl(url) => { - ctx.open_url(&url.url); - } - CopyAnswerToClipboard(content) => { - ctx.clipboard() - .write(ClipboardContent::plain_text(content.to_string())); - } - #[cfg(feature = "cast-agent")] - SendViaCovenGateway => self.send_via_coven_gateway(ctx), - #[cfg(feature = "cast-agent")] - CycleFamiliar => { - self.cycle_familiar(ctx); - ctx.notify(); - } - FocusTerminalInput => ctx.emit(AIAssistantPanelEvent::FocusTerminalInput), - FocusEditor => { - self.focus_state = PanelFocusState::Editor; - ctx.focus_self(); - } - } - } -} - -impl View for AIAssistantPanelView { - fn ui_name() -> &'static str { - "AIAssistantPanel" - } - - fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { - if focus_ctx.is_self_focused() { - match &self.focus_state { - PanelFocusState::Editor => { - self.transcript_view.update(ctx, |transcript_view, ctx| { - transcript_view.clear_selected_block(ctx) - }); - ctx.focus(&self.editor); - } - PanelFocusState::Transcript => ctx.focus(&self.transcript_view), - } - ctx.notify(); - } - } - - fn render(&self, app: &AppContext) -> Box { - let appearance = Appearance::as_ref(app); - let mut panel = Flex::column().with_main_axis_size(MainAxisSize::Max); - - let should_render_zero_state = self.should_render_zero_state(app); - let body = if should_render_zero_state { - Align::new(self.render_zero_state(appearance, app)).finish() - } else { - Align::new(ChildView::new(&self.transcript_view).finish()) - .top_center() - .finish() - }; - panel.add_child(Shrinkable::new(1., body).finish()); - - panel.add_child(self.render_sessions_section(appearance)); - // When the unified agent panel is enabled it owns daemon conversations, - // so the Familiar panel's coven-stream section is suppressed to avoid a - // duplicate surface. Reversible via the flag; removed outright once the - // unified panel reaches parity (staged 2d deletion). - #[cfg(feature = "cast-agent")] - if !warp_core::features::FeatureFlag::UnifiedAgentPanel.is_enabled() { - panel.add_child(self.render_coven_stream_section(appearance)); - } - - if matches!(self.request_status(app), RequestStatus::NotInFlight) { - let buffer_text = self.editor.as_ref(app).buffer_text(app); - if self.is_prompt_too_long(buffer_text.as_str()) { - panel.add_child( - Container::new( - self.render_editor_size_warning(appearance, buffer_text.chars().count()), - ) - .with_padding_left(PANEL_HORIZONTAL_PADDING) - .with_padding_bottom(5.) - .with_padding_top(10.) - .finish(), - ); - } - - panel.add_child( - Container::new(self.render_editor()) - .with_uniform_padding(EDITOR_MARGIN) - .finish(), - ); - } - - let mut stack = Stack::new().with_child(panel.finish()); - - stack.add_positioned_overlay_child( - ConstrainedBox::new( - Container::new(self.render_title_bar(appearance, app)) - .with_padding_top(HEADER_VERTICAL_PADDING) - .with_padding_bottom(HEADER_VERTICAL_PADDING) - .with_padding_left(PANEL_HORIZONTAL_PADDING) - .with_padding_right(PANEL_HORIZONTAL_PADDING) - .finish(), - ) - .with_height(HEADER_HEIGHT) - .finish(), - OffsetPositioning::offset_from_parent( - vec2f(0., HEADER_HEIGHT), - warpui::elements::ParentOffsetBounds::Unbounded, - ParentAnchor::TopLeft, - ChildAnchor::BottomLeft, - ), - ); - - if matches!( - self.input_suggestions_mode, - InputSuggestionsMode::Open { .. } - ) { - stack.add_positioned_overlay_child( - self.render_input_suggestions_menu(appearance), - OffsetPositioning::offset_from_save_position_element( - EDITOR_SAVE_POSITION_ID, - Vector2F::new(0., -10.), - PositionedElementOffsetBounds::ParentByPosition, - PositionedElementAnchor::TopLeft, - ChildAnchor::BottomLeft, - ), - ); - } - - let styled_panel = Container::new(stack.finish()) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))); - - let clickable_panel = - EventHandler::new(styled_panel.finish()).on_left_mouse_down(|ctx, _, _| { - ctx.dispatch_typed_action(AIAssistantAction::FocusEditor); - DispatchEventResult::StopPropagation - }); - - Resizable::new( - self.resizable_state_handle.clone(), - clickable_panel.finish(), - ) - .on_resize(move |ctx, _| ctx.notify()) - .with_dragbar_side(DragBarSide::Left) - .with_bounds_callback(Box::new(|window_bounds| { - ( - MIN_PANEL_WIDTH, - (window_bounds.x() - MIN_REMAINING_WINDOW_SIZE).max(MIN_PANEL_WIDTH), - ) - })) - .finish() - } -} diff --git a/app/src/ai_assistant/requests.rs b/app/src/ai_assistant/requests.rs index 944e7ed66..936f46fe8 100644 --- a/app/src/ai_assistant/requests.rs +++ b/app/src/ai_assistant/requests.rs @@ -10,15 +10,15 @@ use warpui::{AppContext, Entity, ModelContext, SingletonEntity}; use crate::{ ai::{RequestLimitInfo, RequestUsageInfo}, - ai_assistant::utils::{AssistantTranscriptPart, TranscriptPartSubType}, + ai_assistant::dialogue_types::{AssistantTranscriptPart, TranscriptPartSubType}, auth::AuthStateProvider, server::server_api::{ai::AIClient, ServerApi}, workspaces::user_workspaces::UserWorkspaces, }; use super::{ + dialogue_types::{markdown_segments_from_text, FormattedTranscriptMessage, TranscriptPart}, execution_context::WarpAiExecutionContext, - utils::{markdown_segments_from_text, FormattedTranscriptMessage, TranscriptPart}, }; use anyhow::Result; diff --git a/app/src/ai_assistant/test_util.rs b/app/src/ai_assistant/test_util.rs index 0edf97f9d..ebe6f1fdb 100644 --- a/app/src/ai_assistant/test_util.rs +++ b/app/src/ai_assistant/test_util.rs @@ -1,4 +1,4 @@ -use crate::ai_assistant::utils::{ +use crate::ai_assistant::dialogue_types::{ AssistantTranscriptPart, CodeBlockIndex, FormattedTranscriptMessage, MarkdownSegment, }; use markdown_parser::{CodeBlockText, FormattedText}; diff --git a/app/src/ai_assistant/transcript.rs b/app/src/ai_assistant/transcript.rs deleted file mode 100644 index 06620e96b..000000000 --- a/app/src/ai_assistant/transcript.rs +++ /dev/null @@ -1,948 +0,0 @@ -use markdown_parser::markdown_parser::RUNNABLE_BLOCK_MARKDOWN_LANG; -use markdown_parser::CodeBlockText; -use pathfinder_color::ColorU; -use pathfinder_geometry::vector::vec2f; -use warp_core::ui::builder::AnimatedButtonOptions; -use warpui::clipboard::ClipboardContent; -use warpui::elements::{DispatchEventResult, Stack}; -use warpui::units::Pixels; -use warpui::{ - elements::{ - Align, Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, - Container, CornerRadius, CrossAxisAlignment, EventHandler, Fill, Flex, - FormattedTextElement, HyperlinkUrl, Icon, MainAxisAlignment, MainAxisSize, - MouseStateHandle, ParentAnchor, ParentElement, Radius, SavePosition, ScrollbarWidth, - Shrinkable, Text, Wrap, - }, - keymap::Keystroke, - platform::Cursor, - ui_components::components::{UiComponent, UiComponentStyles}, - units::IntoPixels, - AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, - WeakViewHandle, -}; -use warpui::{BlurContext, FocusContext}; - -use crate::workspaces::user_workspaces::UserWorkspaces; -use crate::{appearance::Appearance, ui_components::blended_colors}; - -use super::panel::HEADER_HEIGHT; -use super::{ - panel::HEXAGON_ALERT_SVG_PATH, - requests::{RequestStatus, Requests}, - utils::{ - code_block_position_id, markdown_segments_from_text, render_prepared_response_button, - render_request_limit_info, save_as_workflow_position_id, AssistantTranscriptPart, - CodeBlockIndex, FormattedTranscriptMessage, MarkdownSegment, TranscriptPartSubType, - }, - AI_ASSISTANT_SVG_PATH, -}; - -const TRANSCRIPT_POSITION_ID: &str = "ai_assistant::transcript"; - -const TERMINAL_INPUT_SVG_PATH: &str = "bundled/svg/terminal-input.svg"; -const USER_ICON_SVG_PATH: &str = "bundled/svg/user.svg"; -const SAVE_WORKFLOW_ICON_PATH: &str = "bundled/svg/workflow.svg"; - -const BODY_FONT_SIZE: f32 = 13.; -const CODE_FONT_SIZE: f32 = 12.; -const WARNING_MESSAGE_FONT_SIZE: f32 = 10.; - -const PANEL_LEFT_MARGIN: f32 = 15.; -const DETAILS_BOTTOM_MARGIN: f32 = 12.; - -const COPY_BUTTON_SIZE: f32 = 14.; -const TERMINAL_INPUT_BUTTON_SIZE: f32 = 20.; -const SAVE_AS_WORKFLOW_BUTTON_SIZE: f32 = 20.; - -const HOW_DO_I_FIX_PROMPT: &str = "How do I fix this?"; -const SHOW_EXAMPLES_PROMPT: &str = "Show examples."; -const WHAT_TO_DO_NEXT_PROMPT: &str = "What should I do next?"; -const IN_FLIGHT_REQUEST_TEXT: &str = "Generating answer..."; -const ACCURACY_NOTICE_TEXT: &str = "AI responses can be inaccurate."; -const MISSING_CONTEXT_NOTICE_TEXT: &str = - "Your Familiar might forget earlier answers as conversations get long."; - -lazy_static::lazy_static! { - static ref SCROLL_BUFFER_OFFSET_PX: Pixels = (10.).into_pixels(); -} - -#[derive(Debug, Clone, Default)] -pub struct CodeBlockMouseStateHandles { - pub play_button: MouseStateHandle, - pub play_button_tooltip: MouseStateHandle, - pub copy_button: MouseStateHandle, - pub copy_button_tooltip: MouseStateHandle, - pub save_as_workflow_button: MouseStateHandle, - pub save_as_workflow_button_tooltip: MouseStateHandle, -} - -#[derive(Default)] -struct MouseStateHandles { - show_examples_button: MouseStateHandle, - what_to_do_next_button: MouseStateHandle, - how_do_i_fix_button: MouseStateHandle, -} - -/// A view to render a Q/A style transcript. -pub struct Transcript { - view_handle: WeakViewHandle, - - requests_model: ModelHandle, - selected_code_block: Option, - - clipped_scroll_state: ClippedScrollStateHandle, - mouse_state_handles: MouseStateHandles, -} - -#[derive(Debug, Clone)] -pub enum TranscriptAction { - CopyAnswerToClipboard { - transcript_part_index: usize, - }, - CopyCodeToClipboard { - code_block_index: CodeBlockIndex, - }, - PasteInTerminalInput { - code_block_index: CodeBlockIndex, - }, - OpenWorkflowModal(CodeBlockIndex), - ClickedCodeBlock { - code_block_index: CodeBlockIndex, - }, - ClickedUrl(HyperlinkUrl), - Keydown(Keystroke), - /// A mouse down event outside of the other clickable elements (e.g. buttons, code blocks, etc.) - MouseDown, -} - -pub enum TranscriptEvent { - PasteInTerminalInput { code_block_index: CodeBlockIndex }, - FocusEditor, - FocusTranscript, - ClickedCodeBlock, - OpenWorkflowModalWithCommand(String), -} - -impl Entity for Transcript { - type Event = TranscriptEvent; -} - -impl TypedActionView for Transcript { - type Action = TranscriptAction; - - fn handle_action(&mut self, action: &TranscriptAction, ctx: &mut ViewContext) { - use TranscriptAction::*; - - match action { - CopyAnswerToClipboard { - transcript_part_index, - } => { - let answer = self - .requests_model - .as_ref(ctx) - .transcript() - .get(*transcript_part_index) - .map(|p| p.assistant.formatted_message.raw.clone()); - - if let Some(answer) = answer { - ctx.clipboard().write(ClipboardContent::plain_text(answer)); - } - } - CopyCodeToClipboard { code_block_index } => { - self.copy_code_to_clipboard(*code_block_index, ctx); - } - PasteInTerminalInput { code_block_index } => { - self.paste_in_terminal_input(*code_block_index, ctx); - } - OpenWorkflowModal(code_block_index) => self.open_workflow_modal(*code_block_index, ctx), - ClickedUrl(url) => { - ctx.open_url(&url.url); - } - ClickedCodeBlock { code_block_index } => { - self.selected_code_block = Some(*code_block_index); - ctx.emit(TranscriptEvent::ClickedCodeBlock); - ctx.notify(); - } - Keydown(keystroke) => self.handle_keydown(keystroke, ctx), - MouseDown => { - if self.selected_code_block.is_none() { - ctx.emit(TranscriptEvent::FocusEditor); - } else { - ctx.emit(TranscriptEvent::FocusTranscript); - } - ctx.notify(); - } - } - } -} - -impl Transcript { - pub fn new(requests_model: &ModelHandle, ctx: &mut ViewContext) -> Self { - ctx.observe(requests_model, |_, _, ctx| ctx.notify()); - - Self { - view_handle: ctx.handle(), - requests_model: requests_model.to_owned(), - selected_code_block: None, - clipped_scroll_state: Default::default(), - mouse_state_handles: Default::default(), - } - } - - fn copy_code_to_clipboard( - &mut self, - code_block_index: CodeBlockIndex, - ctx: &mut ViewContext, - ) { - if let Some(code) = self.code_for_index(code_block_index, ctx) { - ctx.clipboard().write(ClipboardContent::plain_text(code)); - } - } - - fn paste_in_terminal_input( - &mut self, - code_block_index: CodeBlockIndex, - ctx: &mut ViewContext, - ) { - ctx.emit(TranscriptEvent::PasteInTerminalInput { code_block_index }); - } - - fn open_workflow_modal( - &mut self, - code_block_index: CodeBlockIndex, - ctx: &mut ViewContext, - ) { - if let Some(code) = self.code_for_index(code_block_index, ctx) { - ctx.emit(TranscriptEvent::OpenWorkflowModalWithCommand(code)); - } - } - - fn handle_keydown(&mut self, keystroke: &Keystroke, ctx: &mut ViewContext) { - let Some(selected_block_index) = self.selected_code_block else { - return; - }; - - if keystroke.key == "down" { - let new_index = self.next_code_block_index(ctx); - if new_index.is_some() { - self.selected_code_block = new_index; - } else if keystroke.cmd { - self.selected_code_block = None; - self.scroll_to_bottom_of_transcript(ctx); - ctx.emit(TranscriptEvent::FocusEditor); - } - ctx.notify(); - } else if keystroke.key == "up" { - let new_index = self.previous_code_block_index(ctx); - if new_index.is_some() { - self.selected_code_block = new_index; - ctx.notify(); - } - } else if keystroke.cmd && keystroke.key == "c" { - self.copy_code_to_clipboard(selected_block_index, ctx); - } else if keystroke.cmd && keystroke.key == "enter" { - self.paste_in_terminal_input(selected_block_index, ctx); - } else if keystroke.cmd && keystroke.key == "s" { - self.open_workflow_modal(selected_block_index, ctx); - } else if keystroke.key == "escape" { - self.selected_code_block = None; - ctx.emit(TranscriptEvent::FocusEditor); - ctx.notify(); - } - - // If we took an action on a code block or changed code blocks, let's scroll to it - // so the user knows what's going on. - if let Some(selected_code_block) = self.selected_code_block { - self.scroll_to_code_block(selected_code_block, ctx); - } - } - - /// Only scrolls to the code block if it isn't already in the viewport. - fn scroll_to_code_block( - &mut self, - code_block_index: CodeBlockIndex, - ctx: &mut ViewContext, - ) { - let Some(transcript_pos) = ctx.element_position_by_id(TRANSCRIPT_POSITION_ID) else { - return; - }; - let Some(code_block_pos) = - ctx.element_position_by_id(code_block_position_id(code_block_index)) - else { - return; - }; - - let current_scroll_top_px = self.clipped_scroll_state.scroll_start(); - let viewable_transcript_height_px = transcript_pos.height().into_pixels(); - let code_block_start_y_px = - code_block_pos.origin_y().into_pixels() - transcript_pos.origin_y().into_pixels(); - let code_block_end_y_px = - code_block_pos.origin_y().into_pixels() + code_block_pos.height().into_pixels(); - - // We only need to scroll if either the start of the code block is cut off or the end is cut off. - if code_block_start_y_px < Pixels::zero() - || code_block_end_y_px > viewable_transcript_height_px - { - // In the case that the new scroll top exceeds the max scroll top, the after_layout - // of clipped scrollable will re-adjust accordingly, so this is safe. - self.clipped_scroll_state.scroll_to( - current_scroll_top_px + code_block_start_y_px - *SCROLL_BUFFER_OFFSET_PX, - ); - ctx.notify(); - } - } - - pub fn scroll_to_bottom_of_transcript(&mut self, ctx: &mut ViewContext) { - // This relies on the fact that the clipped scrollable will recompute - // the scroll_top in after_layout if it exceeds the true max. - self.clipped_scroll_state.scroll_to(f32::MAX.into_pixels()); - ctx.notify(); - } - - fn previous_code_block_index(&self, ctx: &mut ViewContext) -> Option { - let transcript = self.requests_model.as_ref(ctx).transcript(); - let selected_code_block_index = self.selected_code_block?; - let transcript_index = selected_code_block_index.transcript_index(); - - // Try to find the prev code block in the current part. - let found = transcript - .get(transcript_index) - .and_then(|p| p.prev_code_block_index(selected_code_block_index)); - - // If it's not in the current part, then take the last code block from the closest - // transcript part to this one (in reverse). - found.or_else(|| { - transcript - .get(..transcript_index)? - .iter() - .rev() - .find_map(|part| part.last_code_block_index()) - }) - } - - fn next_code_block_index(&self, ctx: &mut ViewContext) -> Option { - let transcript = self.requests_model.as_ref(ctx).transcript(); - let selected_code_block_index = self.selected_code_block?; - let transcript_index = selected_code_block_index.transcript_index(); - - // Try to find the next code block in the current part. - let found = transcript - .get(transcript_index) - .and_then(|p| p.next_code_block_index(selected_code_block_index)); - - // If it's not in the current part, then take the first code block from the closest - // transcript part to this one (in sequence). - found.or_else(|| { - transcript - .get(transcript_index + 1..)? - .iter() - .find_map(|part| part.first_code_block_index()) - }) - } - - pub fn select_last_code_block(&mut self, ctx: &mut ViewContext) { - let transcript = self.requests_model.as_ref(ctx).transcript(); - // The last code block will be the last code block in the first transcript part starting from the end. - let code_block_index = transcript - .iter() - .rev() - .find_map(|p| p.last_code_block_index()); - self.selected_code_block = code_block_index; - - if let Some(new_code_block) = self.selected_code_block { - self.scroll_to_code_block(new_code_block, ctx); - ctx.emit(TranscriptEvent::ClickedCodeBlock); - } - ctx.notify(); - } - - pub fn clear_selected_block(&mut self, ctx: &mut ViewContext) { - self.selected_code_block = None; - ctx.notify(); - } - - pub fn code_for_index( - &self, - code_block_index: CodeBlockIndex, - app: &AppContext, - ) -> Option { - let transcript = self.requests_model.as_ref(app).transcript(); - transcript - .get(code_block_index.transcript_index()) - .and_then(|p| p.code_for_block(code_block_index).map(ToOwned::to_owned)) - } - - pub fn reset(&mut self, ctx: &mut ViewContext) { - self.selected_code_block = None; - ctx.notify(); - } -} - -/// Rendering-related implementation. -impl Transcript { - fn render_code_block_actions( - &self, - code_block_index: CodeBlockIndex, - appearance: &Appearance, - code_block_info: &CodeBlockText, - mouse_state_handles: &CodeBlockMouseStateHandles, - ) -> Box { - let mut buttons = Flex::row() - .with_main_axis_alignment(MainAxisAlignment::End) - .with_cross_axis_alignment(CrossAxisAlignment::End) - .with_main_axis_size(MainAxisSize::Max); - - let copy_button = appearance - .ui_builder() - .copy_button(COPY_BUTTON_SIZE, mouse_state_handles.copy_button.clone()) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(TranscriptAction::CopyCodeToClipboard { - code_block_index, - }); - }) - .with_cursor(Cursor::PointingHand) - .finish(); - - buttons.add_child(appearance.ui_builder().tool_tip_on_element( - "Copy code to clipboard [Cmd + C]".to_string(), - mouse_state_handles.copy_button_tooltip.clone(), - copy_button, - ParentAnchor::TopRight, - ChildAnchor::BottomRight, - vec2f(0., -5.), - )); - - if code_block_info.lang.ends_with("sh") - || code_block_info.lang == RUNNABLE_BLOCK_MARKDOWN_LANG - { - let insert_button = appearance - .ui_builder() - .animated_button( - mouse_state_handles.play_button.clone(), - TERMINAL_INPUT_SVG_PATH, - AnimatedButtonOptions { - size: TERMINAL_INPUT_BUTTON_SIZE, - padding: Some(4.), - color: None, - with_accent_animations: true, - circular: true, - }, - ) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(TranscriptAction::PasteInTerminalInput { - code_block_index, - }); - }) - .with_cursor(Cursor::PointingHand) - .finish(); - - buttons.add_child( - Container::new(appearance.ui_builder().tool_tip_on_element( - "Insert code into terminal input [Cmd + Enter]".to_string(), - mouse_state_handles.play_button_tooltip.clone(), - insert_button, - ParentAnchor::TopRight, - ChildAnchor::BottomRight, - vec2f(0., -5.), - )) - .with_margin_left(10.) - .with_margin_bottom(-4.) - .finish(), - ); - - let save_as_workflow_button = appearance - .ui_builder() - .animated_button( - mouse_state_handles.save_as_workflow_button.clone(), - SAVE_WORKFLOW_ICON_PATH, - AnimatedButtonOptions { - size: SAVE_AS_WORKFLOW_BUTTON_SIZE, - padding: Some(4.), - color: None, - with_accent_animations: true, - circular: true, - }, - ) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(TranscriptAction::OpenWorkflowModal(code_block_index)) - }) - .with_cursor(Cursor::PointingHand) - .finish(); - - buttons.add_child( - SavePosition::new( - Container::new(appearance.ui_builder().tool_tip_on_element( - "Save as workflow [Cmd + S]".to_string(), - mouse_state_handles.save_as_workflow_button_tooltip.clone(), - save_as_workflow_button, - ParentAnchor::TopRight, - ChildAnchor::BottomRight, - vec2f(0., -5.), - )) - .with_margin_left(2.) - .with_margin_bottom(-4.) - .finish(), - &save_as_workflow_position_id(code_block_index), - ) - .finish(), - ); - } - buttons.finish() - } - - fn render_assistant_answer( - &self, - transcript_part_index: usize, - part: &AssistantTranscriptPart, - appearance: &Appearance, - ) -> Box { - let theme = appearance.theme(); - - let background_color = theme.surface_2().into_solid(); - let icon = if part.is_error { - ConstrainedBox::new( - Icon::new(HEXAGON_ALERT_SVG_PATH, appearance.theme().ui_error_color()).finish(), - ) - .with_height(18.) - .with_width(18.) - .finish() - } else { - ConstrainedBox::new( - Icon::new( - AI_ASSISTANT_SVG_PATH, - theme.main_text_color(background_color.into()), - ) - .finish(), - ) - .with_height(16.) - .with_width(16.) - .finish() - }; - - let bottom_right_element = part.copy_all_tooltip_and_button_mouse_handles.clone().map( - |(tooltip_handle, button_handle)| { - let copy_button = appearance - .ui_builder() - .copy_button(16., button_handle) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(TranscriptAction::CopyAnswerToClipboard { - transcript_part_index, - }) - }) - .with_cursor(Cursor::PointingHand) - .finish(); - - appearance.ui_builder().tool_tip_on_element( - "Copy answer to clipboard".to_string(), - tooltip_handle, - copy_button, - ParentAnchor::TopRight, - ChildAnchor::BottomRight, - vec2f(0., -5.), - ) - }, - ); - - self.render_message( - &part.formatted_message, - background_color, - icon, - bottom_right_element, - appearance, - ) - } - - fn render_user_prompt( - &self, - dialogue: &FormattedTranscriptMessage, - appearance: &Appearance, - ) -> Box { - let theme = appearance.theme(); - let background_color = theme.surface_1().into_solid(); - let icon = ConstrainedBox::new( - Icon::new( - USER_ICON_SVG_PATH, - theme.main_text_color(background_color.into()), - ) - .finish(), - ) - .with_height(16.) - .with_width(16.) - .finish(); - self.render_message(dialogue, background_color, icon, None, appearance) - } - - /// Renders a single message (whether that be a user's prompt or assistant's answer). - fn render_message( - &self, - dialogue: &FormattedTranscriptMessage, - background_color: ColorU, - icon: Box, - bottom_right_element: Option>, - appearance: &Appearance, - ) -> Box { - let theme = appearance.theme(); - let inline_code_bg_color = appearance.theme().surface_3().into_solid(); - - let body = if let Some(parts) = &dialogue.markdown { - let mut column = Flex::column(); - for part in parts { - let column_part = match part { - MarkdownSegment::Other { - formatted_text, - highlighted_hyperlink, - } => FormattedTextElement::new( - formatted_text.to_owned(), - BODY_FONT_SIZE, - appearance.ui_font_family(), - appearance.monospace_font_family(), - theme.main_text_color(theme.surface_2()).into_solid(), - highlighted_hyperlink.clone(), - ) - .with_inline_code_properties( - Some(theme.nonactive_ui_text_color().into()), - Some(inline_code_bg_color), - ) - .register_default_click_handlers(move |url, ctx, _| { - ctx.dispatch_typed_action(TranscriptAction::ClickedUrl(url)); - }) - .finish(), - MarkdownSegment::CodeBlock { - index, - code, - mouse_state_handles, - } => { - let actions = self.render_code_block_actions( - *index, - appearance, - code, - mouse_state_handles, - ); - let code = code.code.clone(); - - let (border_fill, border_width, padding) = - if self.selected_code_block == Some(*index) { - (appearance.theme().accent(), 1.5, 11.5) - } else { - (appearance.theme().outline(), 1., 12.) - }; - - let code_block_index = *index; - - EventHandler::new( - Container::new( - SavePosition::new( - Container::new( - Flex::column() - .with_child( - appearance - .ui_builder() - .wrappable_text(code, true) - .with_style(UiComponentStyles { - font_family_id: Some( - appearance.monospace_font_family(), - ), - font_size: Some(CODE_FONT_SIZE), - ..Default::default() - }) - .build() - .with_margin_bottom(10.) - .finish(), - ) - .with_child(actions) - .finish(), - ) - .with_uniform_padding(padding) - .with_border( - Border::all(border_width).with_border_fill(border_fill), - ) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) - .finish(), - &code_block_position_id(code_block_index), - ) - .finish(), - ) - .with_margin_top(10.) - .with_margin_bottom(10.) - .finish(), - ) - .on_left_mouse_down(move |ctx, _, _| { - ctx.dispatch_typed_action(TranscriptAction::ClickedCodeBlock { - code_block_index, - }); - DispatchEventResult::StopPropagation - }) - .finish() - } - }; - - column.add_child(column_part); - } - - column.finish() - } else { - // If we don't have the markdown representation, just render it as basic text. - appearance - .ui_builder() - .wrappable_text(dialogue.raw.to_owned(), true) - .with_style(UiComponentStyles { - font_size: Some(BODY_FONT_SIZE), - ..Default::default() - }) - .build() - .finish() - }; - - let mut final_col = Flex::column().with_child(body); - - if let Some(bottom_right_element) = bottom_right_element { - final_col.add_child( - Align::new( - Container::new(bottom_right_element) - .with_margin_top(16.) - .finish(), - ) - .right() - .finish(), - ); - } - - let row = Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_child( - Container::new(icon) - .with_margin_right(12.) - .with_margin_top(3.) - .finish(), - ) - .with_child(Shrinkable::new(1., Container::new(final_col.finish()).finish()).finish()); - - Container::new(row.finish()) - .with_background_color(background_color) - .with_padding_left(PANEL_LEFT_MARGIN) - .with_padding_top(16.) - .with_padding_bottom(16.) - .with_padding_right(20.) - .finish() - } - - fn render_prepared_responses(&self, appearance: &Appearance) -> Box { - Wrap::row() - .with_run_spacing(10.) - .with_main_axis_alignment(MainAxisAlignment::Center) - .with_child(render_prepared_response_button( - appearance, - self.mouse_state_handles.what_to_do_next_button.clone(), - None, - Some(8.), - WHAT_TO_DO_NEXT_PROMPT, - )) - .with_child( - Container::new(render_prepared_response_button( - appearance, - self.mouse_state_handles.show_examples_button.clone(), - None, - Some(8.), - SHOW_EXAMPLES_PROMPT, - )) - .with_margin_left(10.) - .with_margin_right(10.) - .finish(), - ) - .with_child(render_prepared_response_button( - appearance, - self.mouse_state_handles.how_do_i_fix_button.clone(), - None, - Some(8.), - HOW_DO_I_FIX_PROMPT, - )) - .finish() - } - - fn render_warning_message(&self, message: String, appearance: &Appearance) -> Box { - Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::Center) - .with_child( - Text::new_inline( - message, - appearance.ui_font_family(), - WARNING_MESSAGE_FONT_SIZE, - ) - .with_color(blended_colors::text_sub( - appearance.theme(), - appearance.theme().background(), - )) - .finish(), - ) - .finish() - } -} - -impl View for Transcript { - fn ui_name() -> &'static str { - "AIAssistantTranscript" - } - - fn render(&self, app: &AppContext) -> Box { - let appearance = Appearance::as_ref(app); - let theme = appearance.theme(); - let transcript = self.requests_model.as_ref(app).transcript(); - let request_status = self.requests_model.as_ref(app).request_status(); - let num_remaining_reqs = self.requests_model.as_ref(app).num_remaining_reqs(); - - let mut blocks = Flex::column(); - for (index, part) in transcript.iter().enumerate() { - blocks.add_child(self.render_user_prompt(&part.user, appearance)); - blocks.add_child(self.render_assistant_answer(index, &part.assistant, appearance)); - } - - if let RequestStatus::InFlight { request, .. } = request_status { - blocks.add_child(self.render_user_prompt(request, appearance)); - - let transcript_part_index = transcript.len(); - let in_flight_request_markdown = markdown_segments_from_text( - transcript_part_index, - TranscriptPartSubType::Answer, - IN_FLIGHT_REQUEST_TEXT, - ); - blocks.add_child(self.render_assistant_answer( - transcript_part_index, - &AssistantTranscriptPart { - is_error: false, - copy_all_tooltip_and_button_mouse_handles: None, - formatted_message: FormattedTranscriptMessage { - markdown: in_flight_request_markdown, - raw: IN_FLIGHT_REQUEST_TEXT.to_owned(), - }, - }, - appearance, - )); - } - - if !transcript.is_empty() && matches!(request_status, RequestStatus::NotInFlight) { - // Only show the prepared responses if the last response wasn't an error - // and the user still has remaining requests. - if !transcript.last().is_none_or(|p| p.assistant.is_error) && num_remaining_reqs > 0 { - blocks.add_child( - Container::new(self.render_prepared_responses(appearance)) - .with_margin_top(15.) - .finish(), - ); - } - - let is_custom_llm_enabled: bool = UserWorkspaces::as_ref(app) - .current_team() - .is_some_and(|team| team.is_custom_llm_enabled()); - - if !is_custom_llm_enabled { - blocks.add_child( - Container::new(render_request_limit_info( - &self.requests_model, - app, - appearance, - )) - .with_margin_top(15.) - .finish(), - ); - } - - let current_transcript_summarized = self - .requests_model - .as_ref(app) - .current_transcript_summarized(); - - blocks.add_child( - Container::new( - self.render_warning_message(ACCURACY_NOTICE_TEXT.to_string(), appearance), - ) - .with_margin_top(DETAILS_BOTTOM_MARGIN) - .with_margin_bottom(if current_transcript_summarized { - DETAILS_BOTTOM_MARGIN / 2. - } else { - DETAILS_BOTTOM_MARGIN - }) - .finish(), - ); - - if current_transcript_summarized { - blocks.add_child( - Container::new(self.render_warning_message( - MISSING_CONTEXT_NOTICE_TEXT.to_string(), - appearance, - )) - .with_margin_bottom(DETAILS_BOTTOM_MARGIN) - .finish(), - ); - } - } - - // Note: we don't render a scrollbar because the gutter makes the segmented transcript - // look "broken". - let transcript = SavePosition::new( - ClippedScrollable::vertical( - self.clipped_scroll_state.clone(), - blocks.finish(), - ScrollbarWidth::None, - theme.disabled_text_color(theme.background()).into(), - theme.main_text_color(theme.background()).into(), - Fill::None, - ) - .with_padding_end(0.) - .with_padding_start(0.) - .finish(), - TRANSCRIPT_POSITION_ID, - ) - .finish(); - - let mut navigatable_transcript = - EventHandler::new(transcript).on_left_mouse_down(|ctx, _, _| { - ctx.dispatch_typed_action(TranscriptAction::MouseDown); - DispatchEventResult::StopPropagation - }); - - // Only handle keydown events when a code block is selected and the transcript is focused. - let is_focused = self - .view_handle - .upgrade(app) - .is_some_and(|v| v.is_focused(app)); - if self.selected_code_block.is_some() && is_focused { - navigatable_transcript = navigatable_transcript.on_keydown(|ctx, _, keystroke| { - ctx.dispatch_typed_action(TranscriptAction::Keydown(keystroke.to_owned())); - DispatchEventResult::StopPropagation - }); - } - - let mut stack = Stack::new(); - stack.add_child( - Container::new(navigatable_transcript.finish()) - .with_padding_top(HEADER_HEIGHT) - .finish(), - ); - - stack.finish() - } - - fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { - if focus_ctx.is_self_focused() { - // Force a re-render to reflect the fact that this view is now focused. - ctx.notify(); - } - } - - fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { - if blur_ctx.is_self_blurred() { - // Force a re-render to reflect the fact that this view is now blurred. - ctx.notify(); - } - } -} - -#[cfg(test)] -#[path = "transcript_tests.rs"] -mod transcript_tests; diff --git a/app/src/ai_assistant/transcript_tests.rs b/app/src/ai_assistant/transcript_tests.rs deleted file mode 100644 index 2a485f25c..000000000 --- a/app/src/ai_assistant/transcript_tests.rs +++ /dev/null @@ -1,164 +0,0 @@ -use crate::{ - appearance, test_util::settings::initialize_settings_for_tests, - workspaces::user_workspaces::UserWorkspaces, -}; - -use warpui::{platform::WindowStyle, App}; - -use crate::ai_assistant::{ - requests::Requests, - test_util::{ - default_assistant_transcript_part, default_code_block_segment, default_formatted_message, - default_other_segment, - }, - utils::{CodeBlockIndex, TranscriptPart, TranscriptPartSubType}, -}; - -use super::Transcript; - -// Mocked data to make it easy to test. -lazy_static::lazy_static! { - static ref TRANSCRIPT: Vec = vec![ - TranscriptPart { - user: default_formatted_message(vec![ - default_other_segment(), - default_code_block_segment(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 0)), - default_other_segment(), - default_code_block_segment(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 1)), - - ]), - assistant: default_assistant_transcript_part(default_formatted_message(vec![ - default_code_block_segment(CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 0)), - ])), - }, - TranscriptPart { - user: default_formatted_message(vec![ - default_code_block_segment(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 0)), - default_code_block_segment(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 1)), - - ]), - assistant: default_assistant_transcript_part(default_formatted_message(vec![ - default_other_segment(), - default_other_segment(), - ])), - }, - ]; -} - -fn initialize_app(app: &mut App) { - initialize_settings_for_tests(app); - appearance::register(app); - app.add_singleton_model(UserWorkspaces::default_mock); -} - -#[test] -fn test_next_code_block() { - App::test((), |mut app| async move { - initialize_app(&mut app); - let requests_model = app.add_model(|_| Requests::new_with_transcript(TRANSCRIPT.clone())); - let (_, transcript_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| { - Transcript::new(&requests_model, ctx) - }); - transcript_view.update(&mut app, |view, ctx| { - // Starting point - view.selected_code_block = - Some(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 0)); - - let next_code_block = view.next_code_block_index(ctx); - assert_eq!( - next_code_block, - Some(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 1)) - ); - view.selected_code_block = next_code_block; - - let next_code_block = view.next_code_block_index(ctx); - assert_eq!( - next_code_block, - Some(CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 0)) - ); - view.selected_code_block = next_code_block; - - let next_code_block = view.next_code_block_index(ctx); - assert_eq!( - next_code_block, - Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 0)) - ); - view.selected_code_block = next_code_block; - - let next_code_block = view.next_code_block_index(ctx); - assert_eq!( - next_code_block, - Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 1)) - ); - view.selected_code_block = next_code_block; - - let next_code_block = view.next_code_block_index(ctx); - assert_eq!(next_code_block, None) - }); - }); -} - -#[test] -fn test_prev_code_block() { - App::test((), |mut app| async move { - initialize_app(&mut app); - let requests_model = app.add_model(|_| Requests::new_with_transcript(TRANSCRIPT.clone())); - let (_, transcript_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| { - Transcript::new(&requests_model, ctx) - }); - transcript_view.update(&mut app, |view, ctx| { - // Starting point - view.selected_code_block = - Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 1)); - - let prev_code_block = view.previous_code_block_index(ctx); - assert_eq!( - prev_code_block, - Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 0)) - ); - view.selected_code_block = prev_code_block; - - let prev_code_block = view.previous_code_block_index(ctx); - assert_eq!( - prev_code_block, - Some(CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 0)) - ); - view.selected_code_block = prev_code_block; - - let prev_code_block = view.previous_code_block_index(ctx); - assert_eq!( - prev_code_block, - Some(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 1)) - ); - view.selected_code_block = prev_code_block; - - let prev_code_block = view.previous_code_block_index(ctx); - assert_eq!( - prev_code_block, - Some(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 0)) - ); - view.selected_code_block = prev_code_block; - - let prev_code_block = view.previous_code_block_index(ctx); - assert_eq!(prev_code_block, None); - }); - }); -} - -#[test] -fn test_last_code_block() { - App::test((), |mut app| async move { - initialize_app(&mut app); - let requests_model = app.add_model(|_| Requests::new_with_transcript(TRANSCRIPT.clone())); - let (_, transcript_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| { - Transcript::new(&requests_model, ctx) - }); - transcript_view.update(&mut app, |view, ctx| { - view.select_last_code_block(ctx); - assert_eq!( - view.selected_code_block, - Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 1)) - ); - }); - }); -} diff --git a/app/src/castcodes_public_surface_tests.rs b/app/src/castcodes_public_surface_tests.rs index e6c7777ba..e5e507c1b 100644 --- a/app/src/castcodes_public_surface_tests.rs +++ b/app/src/castcodes_public_surface_tests.rs @@ -4,7 +4,6 @@ const AUTH_VIEW_SHARED_HELPERS_SOURCE: &str = include_str!("auth/auth_view_share const AI_AGENT_SDK_AMBIENT_SOURCE: &str = include_str!("ai/agent_sdk/ambient.rs"); const AI_AGENT_SDK_DRIVER_SOURCE: &str = include_str!("ai/agent_sdk/driver.rs"); const AI_AGENT_TIPS_SOURCE: &str = include_str!("ai/agent_tips.rs"); -const AI_ASSISTANT_PANEL_SOURCE: &str = include_str!("ai_assistant/panel.rs"); const AI_BLOCK_COMMON_SOURCE: &str = include_str!("ai/blocklist/block/view_impl/common.rs"); const AI_BLOCK_SOURCE: &str = include_str!("ai/blocklist/block.rs"); const AI_BLOCK_STATUS_BAR_SOURCE: &str = include_str!("ai/blocklist/block/status_bar.rs"); @@ -12,6 +11,9 @@ const AI_FACT_RULE_SOURCE: &str = include_str!("ai/facts/view/rule.rs"); const AI_TELEMETRY_BANNER_SOURCE: &str = include_str!("ai/blocklist/telemetry_banner.rs"); const AGENT_VIEW_ZERO_STATE_BLOCK_SOURCE: &str = include_str!("ai/blocklist/agent_view/zero_state_block.rs"); +const AGENT_PANEL_MOD_SOURCE: &str = include_str!("agent_panel/mod.rs"); +const AGENT_PANEL_STRINGS_SOURCE: &str = include_str!("agent_panel/strings.rs"); +const AGENT_PANEL_DAEMON_TURN_SOURCE: &str = include_str!("agent_panel/daemon_turn.rs"); const CLI_BLOCK_SOURCE: &str = include_str!("ai/blocklist/block/cli.rs"); const CLI_AGENT_EVENT_SOURCE: &str = include_str!("terminal/cli_agent_sessions/event/mod.rs"); const CLI_AGENT_PLUGIN_MANAGER_SOURCE: &str = @@ -95,10 +97,14 @@ fn public_app_surfaces_use_castcodes_links_and_labels() { assert!(AI_FACT_RULE_SOURCE.contains("AGENTS.md")); assert!(AI_AGENT_SDK_DRIVER_SOURCE.contains("USER_DOCS_URL")); assert!(AI_AGENT_SDK_AMBIENT_SOURCE.contains("USER_DOCS_URL")); - assert!(AI_ASSISTANT_PANEL_SOURCE.contains("const COVEN_CODE_HARNESS: &str = \"coven-code\"")); - assert!(AI_ASSISTANT_PANEL_SOURCE.contains("Run native Coven Code operation")); - assert!(AI_ASSISTANT_PANEL_SOURCE.contains("fn issue_primary_request")); - assert!(AI_ASSISTANT_PANEL_SOURCE.contains("send_via_coven_gateway_with_prompt")); + // Unified agent panel: fork-local Coven wiring + labels. Ports the guards + // that previously lived on the retired `ai_assistant/panel.rs` (which held + // the `COVEN_CODE_HARNESS`/`send_via_coven_gateway` wiring) onto the surface + // that now owns the native daemon path. + assert!(AGENT_PANEL_MOD_SOURCE.contains("\"coven-code\"")); + assert!(AGENT_PANEL_DAEMON_TURN_SOURCE.contains("::ai::cast_agent::AgentMessage")); + assert!(AGENT_PANEL_STRINGS_SOURCE.contains("pub const PANEL_TITLE: &str = \"Agent\"")); + assert!(AGENT_PANEL_STRINGS_SOURCE.contains("pub const BADGE_DAEMON: &str = \"Coven\"")); assert!(AI_BLOCK_COMMON_SOURCE.contains("Internal CastCodes error.")); assert!(AI_BLOCK_COMMON_SOURCE.contains("Working...")); assert!(AI_BLOCK_STATUS_BAR_SOURCE.contains("Working with {name}.")); @@ -182,7 +188,6 @@ fn public_app_surfaces_use_castcodes_links_and_labels() { AUTH_VIEW_SHARED_HELPERS_SOURCE, AI_AGENT_SDK_AMBIENT_SOURCE, AI_AGENT_SDK_DRIVER_SOURCE, - AI_ASSISTANT_PANEL_SOURCE, AI_AGENT_TIPS_SOURCE, AI_BLOCK_COMMON_SOURCE, AI_BLOCK_SOURCE, @@ -190,6 +195,9 @@ fn public_app_surfaces_use_castcodes_links_and_labels() { AI_FACT_RULE_SOURCE, AI_TELEMETRY_BANNER_SOURCE, AGENT_VIEW_ZERO_STATE_BLOCK_SOURCE, + AGENT_PANEL_MOD_SOURCE, + AGENT_PANEL_STRINGS_SOURCE, + AGENT_PANEL_DAEMON_TURN_SOURCE, CLI_BLOCK_SOURCE, CLI_AGENT_EVENT_SOURCE, CODE_REVIEW_VIEW_SOURCE, diff --git a/app/src/integration_testing/view_getters.rs b/app/src/integration_testing/view_getters.rs index 671646c3b..0d434abaa 100644 --- a/app/src/integration_testing/view_getters.rs +++ b/app/src/integration_testing/view_getters.rs @@ -8,7 +8,6 @@ use crate::view_components::find::FindEvent; use crate::view_components::find::FindModel; use crate::{ - ai_assistant::panel::AIAssistantPanelView, input_suggestions::InputSuggestions, notebooks::notebook::NotebookView, pane_group::{PaneGroup, PaneView}, @@ -213,11 +212,6 @@ pub fn workflow_categories_view(app: &App, window_id: WindowId) -> ViewHandle ViewHandle { - singleton_view_of_type(app, window_id) -} - /// Panics if there isn't a single workspace view in the view hierarchy. pub fn workspace_view(app: &App, window_id: WindowId) -> ViewHandle { root_view(app, window_id).read(app, |root_view, _ctx| { diff --git a/app/src/lib.rs b/app/src/lib.rs index 94fdc17b5..8a63a9813 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1635,7 +1635,6 @@ pub(crate) fn initialize_app( ai::blocklist::init(ctx); ai::blocklist::block::status_bar::init(ctx); drive::index::init(ctx); - ai_assistant::panel::init(ctx); settings_view::import_theme_modal::init(ctx); settings_view::update_environment_form::init(ctx); env_vars::env_var_collection_block::init(ctx); diff --git a/app/src/server/server_api/ai.rs b/app/src/server/server_api/ai.rs index ca8eb512f..90a4343a1 100644 --- a/app/src/server/server_api/ai.rs +++ b/app/src/server/server_api/ai.rs @@ -49,8 +49,9 @@ use crate::{ RequestUsageInfo, }, ai_assistant::{ - execution_context::WarpAiExecutionContext, requests::GenerateDialogueResult, - utils::TranscriptPart, AIGeneratedCommand, GenerateCommandsFromNaturalLanguageError, + dialogue_types::TranscriptPart, execution_context::WarpAiExecutionContext, + requests::GenerateDialogueResult, AIGeneratedCommand, + GenerateCommandsFromNaturalLanguageError, }, drive::workflows::ai_assist::{GeneratedCommandMetadata, GeneratedCommandMetadataError}, server::graphql::{ diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index 7a9475c7a..06a0711b7 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -225,8 +225,6 @@ pub enum WorkspaceAction { DispatchToSettingsTab(SettingsTabAction), ToggleResourceCenter, ToggleUserMenu, - ToggleAIAssistant, - ClickedAIAssistantIcon, ToggleKeybindingsPage, ShowCommandSearch(CommandSearchOptions), CreatePersonalNotebook, @@ -314,11 +312,8 @@ pub enum WorkspaceAction { /// Stops the heap profiler (if one is running) and writes the profiling /// data to disk. DumpHeapProfile, - ShowAIAssistantWarmWelcome, - ClickedAIAssistantWarmWelcome, /// An action to open a new window with a view hierarchy debugger. OpenViewTreeDebugWindow, - DismissAIAssistantWarmWelcome, /// An action to either upgrade syncing status from none or just in one tab /// to syncing all tabs, or downgrade from syncing all tabs to no syncing ToggleSyncAllTerminalInputsInAllTabs, @@ -865,8 +860,6 @@ impl WorkspaceAction { | DispatchToSettingsTab { .. } | ToggleResourceCenter | ToggleUserMenu - | ClickedAIAssistantIcon - | ToggleAIAssistant | OpenCloudAgentSetupGuide | ToggleKeybindingsPage | ShowCommandSearch(_) @@ -915,9 +908,6 @@ impl WorkspaceAction { | Panic | DumpHeapProfile | OpenViewTreeDebugWindow - | ShowAIAssistantWarmWelcome - | ClickedAIAssistantWarmWelcome - | DismissAIAssistantWarmWelcome | DismissWorkspaceBanner(..) | ToggleSyncAllTerminalInputsInAllTabs | ToggleSyncTerminalInputsInTab diff --git a/app/src/workspace/mod.rs b/app/src/workspace/mod.rs index b9b76b289..e8334b6cb 100644 --- a/app/src/workspace/mod.rs +++ b/app/src/workspace/mod.rs @@ -1208,31 +1208,18 @@ pub fn init(app: &mut AppContext) { // We use the same binding name for the AI Assistant and block list AI to preserve custom // keybindings between them. - app.register_editable_bindings([ - EditableBinding::new( - "workspace:toggle_ai_assistant", - *NEW_AGENT_PANE_LABEL, - WorkspaceAction::NewPaneInAgentMode { - entrypoint: AgentModeEntrypoint::NewPaneBinding, - zero_state_prompt_suggestion_type: None, - }, - ) - .with_enabled(|| FeatureFlag::AgentMode.is_enabled()) - .with_context_predicate(id!("Workspace") & id!(flags::IS_ANY_AI_ENABLED)) - .with_group(bindings::BindingGroup::WarpAi.as_str()) - .with_custom_action(CustomAction::NewAgentModePane), - EditableBinding::new( - "workspace:toggle_ai_assistant", - "Toggle Familiar", - WorkspaceAction::ToggleAIAssistant, - ) - .with_enabled(|| !FeatureFlag::AgentMode.is_enabled()) - .with_context_predicate(id!("Workspace") & id!(flags::IS_ANY_AI_ENABLED)) - .with_group(bindings::BindingGroup::WarpAi.as_str()) - // We use the same custom action as AM so that we don't have - // two mac menu items for AM vs Warp AI since they are mutually exclusive. - .with_custom_action(CustomAction::NewAgentModePane), - ]); + app.register_editable_bindings([EditableBinding::new( + "workspace:toggle_ai_assistant", + *NEW_AGENT_PANE_LABEL, + WorkspaceAction::NewPaneInAgentMode { + entrypoint: AgentModeEntrypoint::NewPaneBinding, + zero_state_prompt_suggestion_type: None, + }, + ) + .with_enabled(|| FeatureFlag::AgentMode.is_enabled()) + .with_context_predicate(id!("Workspace") & id!(flags::IS_ANY_AI_ENABLED)) + .with_group(bindings::BindingGroup::WarpAi.as_str()) + .with_custom_action(CustomAction::NewAgentModePane)]); app.register_editable_bindings([ EditableBinding::new( diff --git a/app/src/workspace/util.rs b/app/src/workspace/util.rs index 44ee374a5..c229808d0 100644 --- a/app/src/workspace/util.rs +++ b/app/src/workspace/util.rs @@ -27,7 +27,6 @@ pub(super) struct WorkspaceMouseStates { pub(super) banner_secondary_button: MouseStateHandle, pub(super) more_info_banner_button: MouseStateHandle, pub(super) resource_center_icon: MouseStateHandle, - pub(super) ai_tab_bar_button: MouseStateHandle, pub(super) agent_management_view_button: MouseStateHandle, pub(super) tab_bar_collapse_button: MouseStateHandle, pub(super) left_panel_icon: MouseStateHandle, @@ -104,7 +103,6 @@ pub struct WorkspaceState { pub is_resource_center_open: bool, pub is_command_search_open: bool, pub is_warp_drive_open: bool, - pub is_ai_assistant_panel_open: bool, pub is_agent_management_popup_open: bool, pub is_auth_override_modal_open: bool, pub is_require_login_modal_open: bool, @@ -141,7 +139,6 @@ impl WorkspaceState { pub fn is_any_non_terminal_view_open(&self, app: &AppContext) -> bool { self.is_any_modal_open(app) || self.is_theme_chooser_open - || self.is_ai_assistant_panel_open || self.is_workflow_modal_open || self.is_warp_drive_open } @@ -213,7 +210,7 @@ impl WorkspaceState { } pub fn is_right_panel_open(&self) -> bool { - self.is_resource_center_open || self.is_ai_assistant_panel_open + self.is_resource_center_open } pub fn is_left_panel_open(&self) -> bool { diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index e0bc8c757..24d754969 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -215,19 +215,16 @@ use crate::autoupdate::{ }; use crate::banner::BannerState; use crate::changelog_model::{ChangelogModel, ChangelogRequestType, Event as ChangelogEvent}; -use crate::channel::Channel; use crate::cloud_object::toast_message::CloudObjectToastMessage; use crate::cloud_object::{ CloudObject, GenericStringObjectFormat, JsonObjectType, ObjectType, Owner, Space, }; use crate::context_chips::ChipRuntimeCapabilities; use crate::drive::import::modal::{ImportModal, ImportModalEvent}; -use crate::drive::workflows::arguments::ArgumentsState; use crate::drive::workflows::modal::{WorkflowModal, WorkflowModalEvent}; use crate::drive::{ CloudObjectTypeAndId, DriveObjectType, DrivePanel, DrivePanelEvent, OpenWarpDriveObjectSettings, }; -use crate::experiments::{BlockOnboarding, Experiment}; use crate::menu::{ Event as MenuEvent, Menu, MenuItem, MenuItemFields, MenuSelectionSource, DEFAULT_WIDTH as MENU_DEFAULT_WIDTH, @@ -314,6 +311,7 @@ use crate::{report_if_error, AgentNotificationsModel}; use ::settings::{Setting, ToggleableSetting}; use warp_core::features::FeatureFlag; +use crate::ai_assistant::AskAIType; use crate::search::{self, QueryFilter}; use crate::settings_view::import_theme_modal::{ImportThemeModal, ImportThemeModalEvent}; use crate::terminal::view::{ @@ -325,6 +323,8 @@ use crate::themes::theme_chooser::{ThemeChooser, ThemeChooserEvent, ThemeChooser use crate::themes::theme_creator_modal::{ThemeCreatorModal, ThemeCreatorModalEvent}; use crate::themes::theme_deletion_modal::{ThemeDeletionModal, ThemeDeletionModalEvent}; use crate::tips::{TipsEvent, TipsView}; +#[cfg(target_family = "wasm")] +use crate::ui_components::blended_colors; use crate::ui_components::buttons::{combo_inner_button, icon_button_with_color}; use crate::undo_close::UndoCloseStack; #[cfg(feature = "local_fs")] @@ -360,14 +360,6 @@ use crate::workspace::toast_stack::{ ToastStack as WorkspaceToastStack, ToastStackEvent as WorkspaceToastStackEvent, }; use crate::GlobalResourceHandles; -use crate::{ - ai_assistant::{ - panel::{AIAssistantPanelEvent, AIAssistantPanelView}, - AskAIType, AI_ASSISTANT_FEATURE_NAME, AI_ASSISTANT_LOGO_COLOR, - }, - settings, - ui_components::blended_colors, -}; use futures::Future; use itertools::Itertools; @@ -387,7 +379,6 @@ use warp_util::path::{user_friendly_path, LineAndColumnArg}; use warpui::fonts::Weight; use warpui::modals::{AlertDialogWithCallbacks, AppModalCallback}; -use warp_core::user_preferences::GetUserPreferences as _; use warpui::clipboard::ClipboardContent; #[cfg(target_family = "wasm")] use warpui::elements::Percentage; @@ -560,13 +551,10 @@ const TAB_CONTENT_POSITION_ID: &str = "workspace_view:tab_content"; const WELCOME_TIPS_POSITION_ID: &str = "welcome_tips_pill"; const ELLIPSE_SVG_PATH: &str = "bundled/svg/ellipse.svg"; -const AI_ASSISTANT_BUTTON_ID: &str = "workspace_view:ai_assistant_button"; - const VERSION_DEPRECATION_BANNER_TEXT: &str = "Your app is out of date and some features may not work as expected. Please update immediately."; const VERSION_DEPRECATION_WITHOUT_PERMISSIONS_BANNER_TEXT: &str = "Some CastCodes features may not work as expected without updating immediately, but CastCodes is unable to perform the update."; -const ASK_AI_ASSISTANT_KEYBINDING_NAME: &str = "workspace:toggle_ai_assistant"; const TOGGLE_RESOURCE_CENTER_KEYBINDING_NAME: &str = "workspace:toggle_resource_center"; /// Shared position ID for the new-session sidecar overlay. Used for both the @@ -615,8 +603,7 @@ pub(crate) const LEFT_PANEL_WARP_DRIVE_BINDING_NAME: &str = "workspace:left_pane pub(crate) const LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME: &str = "workspace:left_panel_agent_conversations"; -const KEYBINDINGS_TO_CACHE: [&str; 4] = [ - ASK_AI_ASSISTANT_KEYBINDING_NAME, +const KEYBINDINGS_TO_CACHE: [&str; 3] = [ TOGGLE_RESOURCE_CENTER_KEYBINDING_NAME, SHOW_SETTINGS_KEYBINDING_NAME, TOGGLE_COMMAND_PALETTE_KEYBINDING_NAME, @@ -964,9 +951,6 @@ pub struct Workspace { reauth_banner_dismissed: bool, settings_file_error: Option, settings_error_banner_dismissed: bool, - ai_assistant_panel: ViewHandle, - should_show_ai_assistant_warm_welcome: bool, - ai_assistant_close_warm_welcome_mouse_state_handle: MouseStateHandle, auth_override_warning_modal: ViewHandle, require_login_modal: ViewHandle, workflow_modal: ViewHandle, @@ -1512,21 +1496,6 @@ impl Workspace { (welcome_tips_view, welcome_tips_view_state) } - fn build_ai_assistant_panel_view( - ctx: &mut ViewContext, - server_api: Arc, - ai_client: Arc, - ) -> ViewHandle { - let ai_assistant_panel = - ctx.add_typed_action_view(|ctx| AIAssistantPanelView::new(server_api, ai_client, ctx)); - - ctx.subscribe_to_view(&ai_assistant_panel, |me, _, event, ctx| { - me.handle_ai_assistant_panel_event(event, ctx); - }); - - ai_assistant_panel - } - fn build_resource_center_view( ctx: &mut ViewContext, tips_completed: ModelHandle, @@ -2813,9 +2782,6 @@ impl Workspace { None }; - let ai_assistant_panel = - Self::build_ai_assistant_panel_view(ctx, server_api.clone(), ai_client.clone()); - ctx.observe(&tips_completed, Workspace::on_tips_model_changed); let autoupdate_handle = AutoupdateState::handle(ctx); @@ -2869,28 +2835,6 @@ impl Workspace { me.handle_window_settings_changed_event(event, ctx); }); - // Show the Warp AI warm welcome iff the user hasn't dismissed it nor interacted with Warp AI before. - // Also, avoid showing it in integration tests to prevent interaction with other tests. - let mut should_show_ai_assistant_warm_welcome: bool = !FeatureFlag::AgentMode.is_enabled() - && AISettings::as_ref(ctx).is_any_ai_enabled(ctx) - && !matches!(ChannelState::channel(), Channel::Integration) - && ctx - .private_user_preferences() - .read_value(settings::DISMISSED_AI_ASSISTANT_WELCOME_KEY) - .unwrap_or_default() - .and_then(|s| serde_json::from_str(&s).ok()) - .map(|dismissed: bool| !dismissed) - .unwrap_or(true); - - // Don't automatically show the Warp AI welcome during onboarding if the block onboarding flow is being used. - // This way, we can delay the reveal until the end of the onboarding flow so as not to overwhelm the user. - if matches!( - BlockOnboarding::get_group(ctx), - Some(BlockOnboarding::VariantOne) | Some(BlockOnboarding::VariantTwo) - ) { - should_show_ai_assistant_warm_welcome = false; - } - let tab_settings_handle = TabSettings::handle(ctx); ctx.subscribe_to_model(&tab_settings_handle, |me, _, event, ctx| { me.handle_tab_settings_change(event, ctx) @@ -3101,9 +3045,6 @@ impl Workspace { reauth_banner_dismissed: false, settings_file_error, settings_error_banner_dismissed: false, - ai_assistant_panel, - should_show_ai_assistant_warm_welcome, - ai_assistant_close_warm_welcome_mouse_state_handle: Default::default(), auth_override_warning_modal, suggested_agent_mode_workflow_modal, suggested_rule_modal, @@ -4125,15 +4066,6 @@ impl Workspace { }); } - fn dismiss_ai_assistant_warm_welcome(&mut self, ctx: &mut ViewContext) { - self.should_show_ai_assistant_warm_welcome = false; - let _ = ctx.private_user_preferences().write_value( - settings::DISMISSED_AI_ASSISTANT_WELCOME_KEY, - true.to_string(), - ); - ctx.notify(); - } - /// Add and focus a new terminal pane in AI mode in a new tab. fn add_terminal_tab_in_ai_mode( &mut self, @@ -4198,51 +4130,6 @@ impl Workspace { }); } - fn toggle_ai_assistant_panel(&mut self, ctx: &mut ViewContext) { - // Now that the user has interacted with the panel, we can close - // the dialogue and mark it as dismissed. - if self.should_show_ai_assistant_warm_welcome { - self.dismiss_ai_assistant_warm_welcome(ctx); - } - - self.tips_completed.update(ctx, |tips_completed, ctx| { - mark_feature_used_and_write_to_user_defaults( - Tip::Action(TipAction::WarpAI), - tips_completed, - ctx, - ); - ctx.notify(); - }); - - // The panel is already open and no models are open, so just refocus the panel. - // If there is a modal open, it would sit above the Warp AI panel and we would end up - // focusing the Warp AI panel _behind_ the floating modal. Instead, we opt for the normal - // toggle behavior which will close the current modal view and then toggle Warp AI. - if self.current_workspace_state.is_ai_assistant_panel_open - && !self.ai_assistant_panel.is_self_or_child_focused(ctx) - && !self.current_workspace_state.is_any_modal_open(ctx) - { - ctx.focus(&self.ai_assistant_panel); - return; - } - - // Otherwise, open / close the panel accordingly. - self.current_workspace_state.is_ai_assistant_panel_open = - !self.current_workspace_state.is_ai_assistant_panel_open; - - // Close any other modals that could be floating on top of the Warp AI panel. - self.current_workspace_state.close_all_modals(); - - if self.current_workspace_state.is_ai_assistant_panel_open { - // Close the resource center panel if we open the AI Assistant panel. - self.current_workspace_state.is_resource_center_open = false; - ctx.focus(&self.ai_assistant_panel); - } else { - self.focus_active_tab(ctx); - } - ctx.notify(); - } - /// Sets focused to the index of either the selected object or the first item in WD fn reset_focused_index_in_warp_drive( &mut self, @@ -4287,9 +4174,7 @@ impl Workspace { return FocusRegion::RightPanel; } - if self.ai_assistant_panel.is_self_or_child_focused(app) - || self.resource_center_view.is_self_or_child_focused(app) - { + if self.resource_center_view.is_self_or_child_focused(app) { return FocusRegion::RightPanel; } @@ -4339,9 +4224,7 @@ impl Workspace { ctx.focus(&self.right_panel_view); return; } - if self.current_workspace_state.is_ai_assistant_panel_open { - ctx.focus(&self.ai_assistant_panel); - } else if self.current_workspace_state.is_resource_center_open { + if self.current_workspace_state.is_resource_center_open { ctx.focus(&self.resource_center_view); } } @@ -4478,25 +4361,19 @@ impl Workspace { self.reset_focused_index_in_warp_drive(true, ctx); } else if self.is_theme_chooser_open() { ctx.focus(&self.theme_chooser_view); - } else if self.current_workspace_state.is_ai_assistant_panel_open { - ctx.focus(&self.ai_assistant_panel); } else if self.current_workspace_state.is_resource_center_open { ctx.focus(&self.resource_center_view); } } - // Starts from a right panel: AI panel, resource center (keyboard shortcuts page only) - else if self.ai_assistant_panel.is_self_or_child_focused(ctx) - || self.resource_center_view.is_self_or_child_focused(ctx) - { + // Starts from a right panel: resource center (keyboard shortcuts page only) + else if self.resource_center_view.is_self_or_child_focused(ctx) { self.focus_active_tab(ctx); } // Starts from a left panel: Warp Drive else if self.is_warp_drive_view_focused(ctx) { if self.current_workspace_state.is_right_panel_open() { self.set_selected_object(None, ctx); - if self.current_workspace_state.is_ai_assistant_panel_open { - ctx.focus(&self.ai_assistant_panel); - } else if self.current_workspace_state.is_resource_center_open { + if self.current_workspace_state.is_resource_center_open { ctx.focus(&self.resource_center_view); } } else { @@ -4506,9 +4383,7 @@ impl Workspace { // Starts from a left panel: theme chooser else if self.theme_chooser_view.is_self_or_child_focused(ctx) { if self.current_workspace_state.is_right_panel_open() { - if self.current_workspace_state.is_ai_assistant_panel_open { - ctx.focus(&self.ai_assistant_panel); - } else if self.current_workspace_state.is_resource_center_open { + if self.current_workspace_state.is_resource_center_open { ctx.focus(&self.resource_center_view); } } else { @@ -4525,9 +4400,7 @@ impl Workspace { fn focus_right_panel(&mut self, ctx: &mut ViewContext) { // Starts from terminal if self.active_tab_pane_group().is_self_or_child_focused(ctx) { - if self.current_workspace_state.is_ai_assistant_panel_open { - ctx.focus(&self.ai_assistant_panel); - } else if self.current_workspace_state.is_resource_center_open { + if self.current_workspace_state.is_resource_center_open { ctx.focus(&self.resource_center_view); } else if self.current_workspace_state.is_warp_drive_open { self.reset_focused_index_in_warp_drive(true, ctx); @@ -4541,10 +4414,8 @@ impl Workspace { { self.focus_active_tab(ctx); } - // Starts from a right panel: AI panel, resource center (keyboard shortcuts page only) - else if self.ai_assistant_panel.is_self_or_child_focused(ctx) - || self.resource_center_view.is_self_or_child_focused(ctx) - { + // Starts from a right panel: resource center (keyboard shortcuts page only) + else if self.resource_center_view.is_self_or_child_focused(ctx) { if self.current_workspace_state.is_left_panel_open() { if self.current_workspace_state.is_warp_drive_open { self.reset_focused_index_in_warp_drive(true, ctx); @@ -7641,9 +7512,7 @@ impl Workspace { } pub fn toggle_resource_center(&mut self, ctx: &mut ViewContext) { - // Close AI Assistant panel when resource center is opened if !self.current_workspace_state.is_resource_center_open { - self.current_workspace_state.is_ai_assistant_panel_open = false; self.focus_active_tab(ctx); } @@ -8282,8 +8151,6 @@ impl Workspace { resource_center_view.set_current_page(ResourceCenterPage::Keybindings, ctx) }); - // Ensure other right panels are closed - self.current_workspace_state.is_ai_assistant_panel_open = false; // Open side panel self.current_workspace_state.is_resource_center_open = true; } else if current_page != ResourceCenterPage::Keybindings @@ -11773,7 +11640,6 @@ impl Workspace { let is_tab_menu_open = self.show_tab_bar_overflow_menu || (self.show_tab_right_click_menu.is_some() && !is_vertical_tabs_active) || (self.show_new_session_dropdown_menu.is_some() && !is_vertical_tabs_active) - || (!FeatureFlag::AgentMode.is_enabled() && self.should_show_ai_assistant_warm_welcome) || self.is_user_menu_open || self.tab_bar_pinned_by_popup; @@ -12496,10 +12362,8 @@ impl Workspace { stack.add_ephemeral_toast(toast, ctx); }); } else { - // If resource center isn't already open and Warp AI isn't open, then open resource center - if !self.current_workspace_state.is_resource_center_open - && !self.current_workspace_state.is_ai_assistant_panel_open - { + // If resource center isn't already open, then open resource center + if !self.current_workspace_state.is_resource_center_open { self.open_resource_center_main_page(ctx); self.update_resource_center_action_target(ctx); ctx.notify(); @@ -12508,9 +12372,7 @@ impl Workspace { } } (_, Some(ChangelogRequestType::UserAction), _) => { - if !self.current_workspace_state.is_resource_center_open - && !self.current_workspace_state.is_ai_assistant_panel_open - { + if !self.current_workspace_state.is_resource_center_open { self.open_resource_center_main_page(ctx); self.update_resource_center_action_target(ctx); ctx.notify(); @@ -15449,8 +15311,6 @@ impl Workspace { self.focus_theme_chooser(ctx); } else if self.current_workspace_state.is_resource_center_open { ctx.focus(&self.resource_center_view); - } else if self.current_workspace_state.is_ai_assistant_panel_open { - ctx.focus(&self.ai_assistant_panel); } else if self .current_workspace_state .is_close_session_confirmation_dialog_open @@ -15697,7 +15557,6 @@ impl Workspace { self.current_workspace_state.is_palette_open = false; self.current_workspace_state.is_ctrl_tab_palette_open = false; self.previous_workspace_state = Some(self.current_workspace_state); - self.current_workspace_state.is_ai_assistant_panel_open = false; self.current_workspace_state.is_theme_chooser_open = true; self.previous_theme = Some(current_theme); @@ -15964,46 +15823,6 @@ impl Workspace { }; } - fn handle_ai_assistant_panel_event( - &mut self, - event: &AIAssistantPanelEvent, - ctx: &mut ViewContext, - ) { - match event { - AIAssistantPanelEvent::ClosePanel => { - self.current_workspace_state.is_ai_assistant_panel_open = false; - self.focus_active_tab(ctx); - ctx.notify(); - } - AIAssistantPanelEvent::PasteInTerminalInput(code) => { - let command = code.trim().to_string(); - let args_state = - ArgumentsState::for_command_workflow(&Default::default(), command.clone()); - let workflow = Workflow::new("Command from Familiar", command) - .with_arguments(args_state.arguments); - self.run_workflow_in_active_input( - &WorkflowType::AIGenerated { - workflow, - origin: AIWorkflowOrigin::LegacyWarpAI, - }, - WorkflowSource::WarpAI, - WorkflowSelectionSource::WarpAI, - None, - TerminalSessionFallbackBehavior::default(), - ctx, - ); - ctx.notify(); - } - AIAssistantPanelEvent::FocusTerminalInput => { - self.focus_active_tab(ctx); - ctx.notify(); - } - AIAssistantPanelEvent::OpenWorkflowModalWithCommand(command) => { - self.open_workflow_with_command(command.clone(), ctx); - } - } - } - fn handle_openwarp_launch_modal_event( &mut self, event: &OpenWarpLaunchModalEvent, @@ -16254,18 +16073,11 @@ impl Workspace { } } - fn ask_ai_assistant(&mut self, ask_type: &AskAIType, ctx: &mut ViewContext) { - if !self.current_workspace_state.is_ai_assistant_panel_open { - self.toggle_ai_assistant_panel(ctx); - } - - ctx.focus(&self.ai_assistant_panel); - - self.ai_assistant_panel.update(ctx, |ai_assistant, ctx| { - ai_assistant.ask_ai(ask_type, ctx); - }); - - ctx.notify(); + fn ask_ai_assistant(&mut self, _ask_type: &AskAIType, _ctx: &mut ViewContext) { + // The legacy Familiar (AI assistant) panel has been retired. The unified + // agent panel is now the default agent surface, so this legacy + // non-agent-mode entry point is a no-op. The `AskAIType` flow itself is + // preserved for the Agent Mode path handled elsewhere. } /// Determines if the changelog is currently being shown or if the changelog request is @@ -16457,101 +16269,6 @@ impl Workspace { ctx.notify(); } - fn render_ai_assistant_warm_welcome(&self, appearance: &Appearance) -> Box { - let theme = appearance.theme(); - let background_color = theme.surface_2(); - let border_color = theme.surface_3(); - let sub_text_color = blended_colors::text_sub(theme, background_color); - - let header = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child( - Container::new( - ConstrainedBox::new( - WarpUiIcon::new(icons::Icon::AiAssistant.into(), *AI_ASSISTANT_LOGO_COLOR) - .finish(), - ) - .with_width(16.) - .with_height(16.) - .finish(), - ) - .with_margin_right(4.) - .finish(), - ) - .with_child( - Text::new_inline(AI_ASSISTANT_FEATURE_NAME, appearance.ui_font_family(), 14.) - .with_style(Properties { - weight: warpui::fonts::Weight::Bold, - ..Default::default() - }) - .finish(), - ) - .with_child( - Shrinkable::new( - 1., - Align::new( - appearance - .ui_builder() - .close_button( - 20., - self.ai_assistant_close_warm_welcome_mouse_state_handle - .clone(), - ) - .build() - .on_click(|ctx, _, _| { - ctx.dispatch_typed_action( - WorkspaceAction::DismissAIAssistantWarmWelcome, - ) - }) - .finish(), - ) - .right() - .finish(), - ) - .finish(), - ) - .finish(); - - let body = appearance - .ui_builder() - .wrappable_text( - "Ask your Familiar to explain errors, suggest commands or write scripts." - .to_owned(), - true, - ) - .with_style(UiComponentStyles { - font_size: Some(12.), - font_color: Some(sub_text_color), - ..Default::default() - }) - .build() - .finish(); - - ConstrainedBox::new( - EventHandler::new( - Container::new( - Flex::column() - .with_child(header) - .with_child(Container::new(body).with_margin_top(5.).finish()) - .finish(), - ) - .with_background(background_color) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) - .with_uniform_padding(10.) - .with_border(Border::all(1.).with_border_color(border_color.into())) - .finish(), - ) - .on_left_mouse_down(|ctx, _, _| { - ctx.dispatch_typed_action(WorkspaceAction::ClickedAIAssistantWarmWelcome); - DispatchEventResult::StopPropagation - }) - .finish(), - ) - .with_height(85.) - .with_width(210.) - .finish() - } - fn render_tab_in_tab_bar( &self, tab_index: usize, @@ -17558,25 +17275,6 @@ impl Workspace { } } - // Legacy AI assistant button (non-agent-mode only) - if is_online - && !FeatureFlag::AgentMode.is_enabled() - && !is_web_anonymous_user - && !self.current_workspace_state.is_ai_assistant_panel_open - { - target.add_child( - Container::new( - SavePosition::new( - self.render_legacy_warp_ai_entrypoint_button(appearance), - AI_ASSISTANT_BUTTON_ID, - ) - .finish(), - ) - .with_margin_left(TAB_BAR_PADDING_LEFT) - .finish(), - ); - } - if FeatureFlag::AvatarInTabBar.is_enabled() { target.add_child( Container::new(self.render_avatar_button(appearance, ctx)) @@ -18307,29 +18005,6 @@ impl Workspace { Align::new(hoverable.finish()).finish() } - fn render_legacy_warp_ai_entrypoint_button(&self, appearance: &Appearance) -> Box { - let (icon, action, label) = ( - icons::Icon::AiAssistant, - WorkspaceAction::ClickedAIAssistantIcon, - AI_ASSISTANT_FEATURE_NAME.to_owned(), - ); - - Align::new( - self.render_tab_bar_icon_button( - appearance, - icon, - &self.mouse_states.ai_tab_bar_button, - action, - label, - self.cached_keybindings[ASK_AI_ASSISTANT_KEYBINDING_NAME].clone(), - false, - false, - ) - .finish(), - ) - .finish() - } - fn render_tab_bar_icon_button_tooltip( &self, appearance: &Appearance, @@ -19226,21 +18901,14 @@ impl Workspace { } } - // Resource center and AI assistant are workspace-level panels, not configurable. + // Resource center is a workspace-level panel, not configurable. #[cfg(not(target_family = "wasm"))] if self.current_workspace_state.is_right_panel_open() { let right_panel_content = if self.current_workspace_state.is_resource_center_open { Some(self.render_panel(app, self.render_resource_center(), &PanelPosition::Right)) - } else if self.current_workspace_state.is_ai_assistant_panel_open { - Some(self.render_panel( - app, - ChildView::new(&self.ai_assistant_panel).finish(), - &PanelPosition::Right, - )) } else { log::warn!( - "is_right_panel_open() returned true, but neither the resource center nor AI \ - assistant are open" + "is_right_panel_open() returned true, but the resource center is not open" ); None }; @@ -21087,21 +20755,6 @@ impl TypedActionView for Workspace { ctx.notify(); } } - ToggleAIAssistant => { - self.toggle_ai_assistant_panel(ctx); - } - ClickedAIAssistantIcon => { - if !FeatureFlag::AgentMode.is_enabled() { - self.toggle_ai_assistant_panel(ctx); - } - } - ShowAIAssistantWarmWelcome => { - self.should_show_ai_assistant_warm_welcome = true; - ctx.notify(); - } - ClickedAIAssistantWarmWelcome => { - self.toggle_ai_assistant_panel(ctx); - } DragTab { tab_index, tab_position, @@ -21153,9 +20806,6 @@ impl TypedActionView for Workspace { .write(ClipboardContent::plain_text(text.to_string())); } DismissWorkspaceBanner(banner_type) => self.dismiss_workspace_banner(ctx, banner_type), - DismissAIAssistantWarmWelcome => { - self.dismiss_ai_assistant_warm_welcome(ctx); - } Crash => { #[cfg(feature = "crash_reporting")] crate::crash_reporting::crash(); @@ -23225,26 +22875,6 @@ impl View for Workspace { } } - if !FeatureFlag::AgentMode.is_enabled() - && AISettings::as_ref(app).is_any_ai_enabled(app) - && self.should_show_ai_assistant_warm_welcome - && !self.current_workspace_state.is_changelog_modal_open - && !self.current_workspace_state.is_resource_center_open - && !self.current_workspace_state.is_ai_assistant_panel_open - && tab_bar_mode.has_tab_bar() - { - stack.add_positioned_child( - self.render_ai_assistant_warm_welcome(appearance), - OffsetPositioning::offset_from_save_position_element( - AI_ASSISTANT_BUTTON_ID, - vec2f(0., 10.), - PositionedElementOffsetBounds::Unbounded, - PositionedElementAnchor::BottomRight, - ChildAnchor::TopRight, - ), - ); - } - // Cross-window ghost drag: floating chip that follows the cursor in the target window. // Added last so it renders on top of all other content. if FeatureFlag::DragTabsToWindows.is_enabled() { diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index 0e84008a4..73f733f28 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -1170,41 +1170,6 @@ fn test_switch_focus_panels() { ); }); - // Shift focus from WD to left panel when AI panel is open - workspace.update(&mut app, |view, ctx| { - view.current_workspace_state.is_ai_assistant_panel_open = true; - view.focus_left_panel(ctx); - }); - workspace.update(&mut app, |view, ctx| { - assert!( - view.ai_assistant_panel.is_self_or_child_focused(ctx), - "Expected AI panel to be focused" - ); - }); - - // Shift focus from AI panel to left panel (terminal) - workspace.update(&mut app, |view, ctx| { - view.focus_left_panel(ctx); - }); - workspace.update(&mut app, |_view, ctx| { - assert!( - workspace.is_self_or_child_focused(ctx), - "Expected terminal to be focused" - ); - }); - - // Shift focus from workspace to right panel when AI assistant is open - workspace.update(&mut app, |view, ctx| { - view.current_workspace_state.is_ai_assistant_panel_open = true; - view.focus_right_panel(ctx); - }); - workspace.update(&mut app, |view, ctx| { - assert!( - view.ai_assistant_panel.is_self_or_child_focused(ctx), - "Expected AI panel to be focused" - ); - }); - // Shift focus from WD to right panel (terminal) workspace.update(&mut app, |view, ctx| { view.focus_right_panel(ctx); @@ -2260,7 +2225,6 @@ fn test_vertical_tabs_context_menu_does_not_show_hover_only_tab_bar() { .set_value(WorkspaceDecorationVisibility::OnHover, ctx)); report_if_error!(settings.use_vertical_tabs.set_value(true, ctx)); }); - workspace.should_show_ai_assistant_warm_welcome = false; workspace.vertical_tabs_panel_open = true; workspace.show_tab_right_click_menu = @@ -2286,8 +2250,6 @@ fn test_standard_tab_context_menu_shows_hover_only_tab_bar() { .workspace_decoration_visibility .set_value(WorkspaceDecorationVisibility::OnHover, ctx)); }); - workspace.should_show_ai_assistant_warm_welcome = false; - workspace.show_tab_right_click_menu = Some((0, TabContextMenuAnchor::Pointer(Vector2F::zero()))); diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index 0ecda51f4..69ce77d00 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -307,7 +307,6 @@ fn register_tests() -> HashMap<&'static str, BoxedBuilderFn> { register_test!(test_can_auto_bootstrap); - register_test!(test_ask_warp_ai_keybinding_for_selected_block); register_test!(test_create_folder_from_command_palette); register_test!(test_tab_behavior_setting); diff --git a/crates/integration/src/test.rs b/crates/integration/src/test.rs index 43d027981..c1d802ccc 100644 --- a/crates/integration/src/test.rs +++ b/crates/integration/src/test.rs @@ -3,7 +3,6 @@ //! to be run. mod agent_mode; -mod ai_assistant; mod block_filtering; mod bootstrapping; mod code_review; @@ -35,7 +34,6 @@ mod workflows; mod workspace; pub use agent_mode::*; -pub use ai_assistant::*; pub use block_filtering::*; pub use bootstrapping::*; pub use code_review::*; diff --git a/crates/integration/src/test/ai_assistant.rs b/crates/integration/src/test/ai_assistant.rs deleted file mode 100644 index b969cb91f..000000000 --- a/crates/integration/src/test/ai_assistant.rs +++ /dev/null @@ -1,45 +0,0 @@ -use crate::Builder; -use warp::integration_testing::{ - step::new_step_with_default_assertions, - terminal::{ - assert_selected_block_index_is_last_renderable, execute_command_for_single_terminal_in_tab, - util::ExpectedExitStatus, wait_until_bootstrapped_single_pane_for_tab, - }, - view_getters::ai_assistant_panel_view, -}; -use warpui::async_assert; - -use super::new_builder; - -/// Checks if the Ask Warp AI keybinding works correctly when a block is selected. -/// This is a regression test: https://linear.app/warpdotdev/issue/WAR-6758/warp-ai-ask-from-block-keybinding-doesnt-work-as-expected. -pub fn test_ask_warp_ai_keybinding_for_selected_block() -> Builder { - new_builder() - .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) - .with_step(execute_command_for_single_terminal_in_tab( - 0, - String::from("echo foo"), - ExpectedExitStatus::Success, - "foo", - )) - .with_step( - new_step_with_default_assertions("select block") - .with_keystrokes(&["cmdorctrl-up"]) - .add_named_assertion( - "ensure block is selected", - assert_selected_block_index_is_last_renderable(), - ), - ) - .with_step( - new_step_with_default_assertions("select block") - .with_keystrokes(&["ctrl-shift-space"]) - .add_named_assertion("ask warp ai from selected block", |app, window_id| { - let ai_assistant_panel = ai_assistant_panel_view(app, window_id); - ai_assistant_panel.read(app, |view, ctx| { - let expected_code_block = "```warp\nfoo\n```"; - let editor_content = view.editor().as_ref(ctx).buffer_text(ctx); - async_assert!(editor_content.contains(expected_code_block)) - }) - }), - ) -} diff --git a/crates/integration/tests/integration/ui_tests.rs b/crates/integration/tests/integration/ui_tests.rs index cc7344054..cc44fa7c5 100644 --- a/crates/integration/tests/integration/ui_tests.rs +++ b/crates/integration/tests/integration/ui_tests.rs @@ -183,11 +183,6 @@ integration_tests! { #[ignore] test_create_session_with_split_pane_while_bootstrapping, - // For some reason, disabling the `AgentMode` flag does not actually disable Agent Mode in the test - // run. Ignore for now. - #[ignore] - test_ask_warp_ai_keybinding_for_selected_block, - test_create_folder_from_command_palette, test_tab_behavior_setting,