first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -1,12 +1,9 @@
use crate::context_chips::{
display_chip::{DisplayChip, GitLineChanges, PromptDisplayChipEvent},
git_line_changes_from_chips,
prompt_type::PromptType,
ChipResult,
};
use galaxyui::{ModelHandle, ViewContext, ViewHandle};
use super::{AgentInputFooter, AgentInputFooterEvent};
use crate::context_chips::display_chip::{DisplayChip, GitLineChanges, PromptDisplayChipEvent};
use crate::context_chips::prompt_type::PromptType;
use crate::context_chips::{git_line_changes_from_chips, ChipResult};
impl AgentInputFooter {
/// Returns `true` if `DisplayChip`s should be recreated based on updated metadata values.
@@ -22,14 +19,9 @@ impl AgentInputFooter {
|| new_chips.iter().enumerate().any(|(i, chip_result)| {
let existing_chip = &existing_chips[i];
existing_chip.read(ctx, |chip, _| {
chip.text()
!= chip_result
.value()
.map(|v| v.to_string())
.unwrap_or_default()
chip.value() != chip_result.value()
|| chip.chip_kind() != chip_result.kind()
// For parity with PromptDisplay: compare the first on-click value only.
|| chip.first_on_click_value() != chip_result.on_click_values().first()
|| chip.on_click_values() != chip_result.on_click_values()
})
})
}
@@ -3,25 +3,22 @@
//! Uses the shared [`ChipConfigurator`] with `LeftRightZones` layout to let users
//! drag/drop chips between left, right, and unused banks.
use settings::Setting as _;
use galaxyui::keymap::FixedBinding;
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use super::toolbar_item::AgentToolbarItemKind;
use crate::appearance::AppearanceEvent;
use crate::chip_configurator::{
render_chip_editor_modal, render_chip_editor_sections, ChipConfigurator,
ChipConfiguratorAction, ChipConfiguratorLayout, ChipEditorModalConfig, ChipEditorMouseHandles,
ChipEditorSectionsConfig,
};
use crate::report_if_error;
use crate::terminal::session_settings::{
AgentToolbarChipSelection, CLIAgentToolbarChipSelection, SessionSettings,
SessionSettingsChangedEvent, ToolbarChipSelection,
};
use crate::Appearance;
use settings::Setting as _;
use super::toolbar_item::AgentToolbarItemKind;
use crate::{report_if_error, Appearance};
const AGENT_MODAL_TITLE: &str = "Edit agent toolbelt";
const CLI_MODAL_TITLE: &str = "Edit CLI agent toolbelt";
@@ -93,6 +90,25 @@ fn open_toolbar_items_from_settings<V: View>(
)
}
};
// Filter out items that are unavailable due to runtime state (user settings,
// workspace config, etc.) on top of the feature-flag checks in all_available().
let available: Vec<AgentToolbarItemKind> = available
.into_iter()
.filter(|item| item.is_available(ctx))
.collect();
// Drop saved items that are no longer available (e.g. their feature flag was disabled
// or a setting was turned off).
let filter_unavailable = |items: Vec<AgentToolbarItemKind>| -> Vec<AgentToolbarItemKind> {
items
.into_iter()
.filter(|item| available.contains(item))
.collect()
};
let current_left = filter_unavailable(current_left);
let current_right = filter_unavailable(current_right);
chip_configurator.open_left_right_zones_with_items(
current_left,
current_right,
@@ -108,6 +124,15 @@ fn open_default_toolbar_items<V: View>(
) {
let appearance = Appearance::as_ref(ctx);
let (left, right, available) = AgentToolbarItemKind::defaults_for_mode(mode);
let filter_runtime = |items: Vec<AgentToolbarItemKind>| -> Vec<AgentToolbarItemKind> {
items
.into_iter()
.filter(|item| item.is_available(ctx))
.collect()
};
let left = filter_runtime(left);
let right = filter_runtime(right);
let available = filter_runtime(available);
chip_configurator.open_left_right_zones_with_items(left, right, available, appearance);
}
@@ -156,6 +181,19 @@ impl AgentToolbarInlineEditor {
}
});
// Chip colors are derived from the theme, so rebuild the chips from
// settings when the theme changes to keep an open editor readable after a
// theme switch (this inline editor persists its arrangement on every
// edit, so reloading preserves the user's layout).
ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, event, ctx| {
if matches!(event, AppearanceEvent::ThemeChanged)
&& me.chip_configurator.current_dragging_state.is_none()
{
me.reset_from_settings(ctx);
ctx.notify();
}
});
editor
}
@@ -270,7 +308,21 @@ fn save_toolbar_selection<V: View>(
}
impl AgentToolbarEditorModal {
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
// Chip colors are derived from the theme, so rebuild the chips when the
// theme changes to keep an open editor readable after a theme switch.
// Only rebuild while the modal is actually open (has chips) and not
// mid-drag.
ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, event, ctx| {
if matches!(event, AppearanceEvent::ThemeChanged)
&& me.chip_configurator.current_dragging_state.is_none()
&& me.chip_configurator.has_items()
{
let mode = me.mode;
open_toolbar_items_from_settings(&mut me.chip_configurator, mode, ctx);
ctx.notify();
}
});
Self {
mouse_handles: Default::default(),
chip_configurator: ChipConfigurator::new(ChipConfiguratorLayout::LeftRightZones),
@@ -1,38 +1,101 @@
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::Fill;
use galaxyui::{
elements::{
ChildAnchor, ChildView, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Stack,
},
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use std::sync::Arc;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use settings::Setting;
use std::sync::Arc;
use crate::{
ai::ambient_agents::telemetry::CloudAgentTelemetryEvent,
ai::{
cloud_agent_settings::CloudAgentSettings, cloud_environments::CloudAmbientAgentEnvironment,
},
appearance::Appearance,
cloud_object::model::{generic_string_model::StringModel, persistence::CloudModel},
context_chips::display_menu::{
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
},
report_if_error,
server::ids::SyncId,
terminal::input::{MenuPositioning, MenuPositioningProvider},
ui_components::icons::Icon,
view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize},
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
ChildAnchor, ChildView, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Stack,
};
use galaxyui::{
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use super::{AgentInputButtonTheme, AmbientAgentViewModel};
use crate::ai::ambient_agents::telemetry::CloudAgentTelemetryEvent;
use crate::ai::cloud_agent_settings::CloudAgentSettings;
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
use crate::appearance::Appearance;
use crate::cloud_object::model::generic_string_model::StringModel;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::CloudObjectLookup as _;
use crate::context_chips::display_menu::{
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
};
use crate::report_if_error;
use crate::server::ids::SyncId;
use crate::terminal::input::{
HandoffComposeState, HandoffComposeStateEvent, MenuPositioning, MenuPositioningProvider,
};
use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize};
/// Normalizes ambient-agent and handoff environment selection state behind one API.
#[derive(Clone)]
pub(crate) enum EnvironmentSelectorTarget {
CloudPane(ModelHandle<AmbientAgentViewModel>),
Handoff(ModelHandle<HandoffComposeState>),
}
impl EnvironmentSelectorTarget {
fn selected_environment_id(&self, ctx: &AppContext) -> Option<SyncId> {
match self {
Self::CloudPane(model) => model.as_ref(ctx).selected_environment_id().cloned(),
Self::Handoff(state) => state.as_ref(ctx).selected_environment_id().cloned(),
}
}
fn set_environment_id(
&self,
environment_id: Option<SyncId>,
is_explicit: bool,
ctx: &mut ViewContext<EnvironmentSelector>,
) {
match self {
Self::CloudPane(model) => {
model.update(ctx, |model, ctx| {
model.set_environment_id(environment_id, ctx);
});
}
Self::Handoff(state) => {
state.update(ctx, |state, ctx| {
state.set_environment_id(environment_id, is_explicit, ctx);
});
}
}
}
fn ensure_default_environment_id(
&self,
environment_id: SyncId,
ctx: &mut ViewContext<EnvironmentSelector>,
) {
match self {
Self::CloudPane(model) => {
model.update(ctx, |model, ctx| {
model.set_environment_id(Some(environment_id), ctx);
});
}
Self::Handoff(state) => {
state.update(ctx, |state, ctx| {
state.ensure_default_environment_id(environment_id, ctx);
});
}
}
}
fn is_configuring(&self, ctx: &AppContext) -> bool {
match self {
Self::CloudPane(model) => model.as_ref(ctx).is_configuring_ambient_agent(),
Self::Handoff(state) => state.as_ref(ctx).is_active(),
}
}
}
/// A selector component for choosing an ambient agent environment.
pub struct EnvironmentSelector {
@@ -40,7 +103,7 @@ pub struct EnvironmentSelector {
dropdown: ViewHandle<DisplayChipMenu>,
is_menu_open: bool,
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
target: EnvironmentSelectorTarget,
}
pub enum EnvironmentSelectorEvent {
@@ -117,7 +180,7 @@ impl GenericMenuItem for NewEnvironmentMenuItem {
}
}
fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment]) {
pub(crate) fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment]) {
environments.sort_by(|a, b| {
// Sort by last-used timestamp descending (most recent first), then by display name ascending
b.metadata
@@ -136,7 +199,7 @@ fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment
impl EnvironmentSelector {
pub fn new(
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
target: EnvironmentSelectorTarget,
ctx: &mut ViewContext<Self>,
) -> Self {
let button = ctx.add_typed_action_view(|_ctx| {
@@ -190,9 +253,7 @@ impl EnvironmentSelector {
ctx
);
if me.is_configuring(ctx) {
me.ambient_agent_model.update(ctx, |model, ctx| {
model.set_environment_id(Some(env_item.id), ctx);
});
me.target.set_environment_id(Some(env_item.id), true, ctx);
// Persist the selection to settings for next time.
me.save_selected_environment_to_settings(env_item.id, ctx);
}
@@ -212,19 +273,39 @@ impl EnvironmentSelector {
ctx.notify();
});
ctx.subscribe_to_model(&ambient_agent_model, |me, _, event, ctx| {
use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent;
if let AmbientAgentViewModelEvent::EnvironmentSelected = event {
me.refresh_menu(ctx);
match &target {
EnvironmentSelectorTarget::CloudPane(model) => {
ctx.subscribe_to_model(model, |me, _, event, ctx| {
if let AmbientAgentViewModelEvent::EnvironmentSelected = event {
me.refresh_menu(ctx);
}
me.refresh_button(ctx);
});
}
me.refresh_button(ctx);
});
EnvironmentSelectorTarget::Handoff(state) => {
ctx.subscribe_to_model(state, |me, _, event, ctx| {
match event {
HandoffComposeStateEvent::ActiveChanged => {
if !me.is_configuring(ctx) {
me.set_menu_visibility(false, ctx);
}
me.ensure_default_selection(ctx);
me.refresh_menu(ctx);
}
HandoffComposeStateEvent::EnvironmentSelected => {
me.refresh_menu(ctx);
}
}
me.refresh_button(ctx);
});
}
}
let mut me = Self {
button,
dropdown,
is_menu_open: false,
menu_positioning_provider,
ambient_agent_model,
target,
};
me.refresh_menu(ctx);
me.refresh_button(ctx);
@@ -236,13 +317,34 @@ impl EnvironmentSelector {
self.is_menu_open
}
fn is_configuring(&self, ctx: &AppContext) -> bool {
self.ambient_agent_model
.as_ref(ctx)
.is_configuring_ambient_agent()
pub fn open_menu(&mut self, ctx: &mut ViewContext<Self>) {
if !self.is_configuring(ctx) {
return;
}
self.set_menu_visibility(true, ctx);
}
fn set_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext<Self>) {
fn is_configuring(&self, ctx: &AppContext) -> bool {
self.target.is_configuring(ctx)
}
fn highlight_selected_environment(&mut self, ctx: &mut ViewContext<Self>) {
let Some(selected_id) = self.target.selected_environment_id(ctx) else {
return;
};
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
sort_environments_by_recency(&mut environments);
let Some(index) = environments.iter().position(|env| env.id == selected_id) else {
return;
};
self.dropdown.update(ctx, |menu, ctx| {
menu.select_index(index, ctx);
});
}
pub(super) fn set_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext<Self>) {
if self.is_menu_open == is_open {
return;
}
@@ -251,6 +353,7 @@ impl EnvironmentSelector {
if is_open {
send_telemetry_from_ctx!(CloudAgentTelemetryEvent::EnvironmentSelectorOpened, ctx);
ctx.focus(&self.dropdown);
self.highlight_selected_environment(ctx);
}
ctx.emit(EnvironmentSelectorEvent::MenuVisibilityChanged { open: is_open });
ctx.notify();
@@ -258,10 +361,7 @@ impl EnvironmentSelector {
/// Ensures a default environment is selected if none is currently selected.
fn ensure_default_selection(&mut self, ctx: &mut ViewContext<Self>) {
let current_selection = self
.ambient_agent_model
.as_ref(ctx)
.selected_environment_id();
let current_selection = self.target.selected_environment_id(ctx);
if current_selection.is_some() {
return;
}
@@ -270,9 +370,7 @@ impl EnvironmentSelector {
if let Some(env_id) = self.get_saved_environment_from_settings(ctx) {
// Verify the environment still exists.
if CloudAmbientAgentEnvironment::get_by_id(&env_id, ctx).is_some() {
self.ambient_agent_model.update(ctx, |model, ctx| {
model.set_environment_id(Some(env_id), ctx);
});
self.target.ensure_default_environment_id(env_id, ctx);
return;
}
}
@@ -281,9 +379,7 @@ impl EnvironmentSelector {
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
sort_environments_by_recency(&mut environments);
if let Some(first_env) = environments.first() {
self.ambient_agent_model.update(ctx, |model, ctx| {
model.set_environment_id(Some(first_env.id), ctx);
});
self.target.ensure_default_environment_id(first_env.id, ctx);
}
}
@@ -307,11 +403,7 @@ impl EnvironmentSelector {
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
sort_environments_by_recency(&mut environments);
let selected_id = self
.ambient_agent_model
.as_ref(ctx)
.selected_environment_id()
.cloned();
let selected_id = self.target.selected_environment_id(ctx);
let menu_items: Vec<EnvironmentMenuItem> = environments
.iter()
@@ -328,16 +420,20 @@ impl EnvironmentSelector {
self.dropdown.update(ctx, |menu, ctx| {
menu.update_menu_items(menu_items, ctx);
});
if self.is_menu_open {
self.highlight_selected_environment(ctx);
}
}
fn refresh_button(&mut self, ctx: &mut ViewContext<Self>) {
let label = self
.ambient_agent_model
.as_ref(ctx)
.selected_environment_id()
.and_then(|id| CloudAmbientAgentEnvironment::get_by_id(id, ctx))
.map(|env| env.model().string_model.display_name())
.unwrap_or_else(|| "New environment".to_string());
let label = if let Some(id) = self.target.selected_environment_id(ctx) {
CloudAmbientAgentEnvironment::get_by_id(&id, ctx)
.map(|env| env.model().string_model.display_name())
.unwrap_or_else(|| "New environment".to_string())
} else {
"New environment".to_string()
};
let is_configuring = self.is_configuring(ctx);
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,12 @@
use serde::{Deserialize, Serialize};
use crate::context_chips::{agent_footer_available_chips, available_chips, ContextChipKind};
use crate::features::FeatureFlag;
use crate::terminal::shared_session::SharedSessionStatus;
use crate::ui_components::icons::Icon;
use warpui::SingletonEntity;
use super::editor::AgentToolbarEditorMode;
use crate::context_chips::{agent_footer_available_chips, available_chips, ContextChipKind};
use crate::features::FeatureFlag;
use crate::settings::AISettings;
use crate::terminal::shared_session::SharedSessionStatus;
use crate::ui_components::icons::Icon;
/// Declares which footer(s) a toolbar item is available in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -67,6 +68,9 @@ pub enum AgentToolbarItemKind {
// Agent view only – shows fast-forward (auto-approve) toggle in the footer
FastForwardToggle,
// Agent view only – "Hand off to cloud" chip.
HandoffToCloud,
}
impl AgentToolbarItemKind {
@@ -76,7 +80,8 @@ impl AgentToolbarItemKind {
Self::ModelSelector
| Self::NLDToggle
| Self::ContextWindowUsage
| Self::FastForwardToggle => ToolbarAvailability::AgentViewOnly,
| Self::FastForwardToggle
| Self::HandoffToCloud => ToolbarAvailability::AgentViewOnly,
Self::FileExplorer | Self::RichInput | Self::Settings => {
ToolbarAvailability::CLIAgentOnly
}
@@ -95,6 +100,8 @@ impl AgentToolbarItemKind {
Self::Settings | Self::FileExplorer => !status.is_viewer(),
Self::FileAttach => !status.is_viewer() || is_cloud_mode,
Self::FastForwardToggle => !status.is_viewer() || status.is_executor(),
// Handoff is host-initiated; viewers cannot hand off another user's conversation.
Self::HandoffToCloud => !status.is_viewer(),
Self::ContextChip(_)
| Self::ModelSelector
| Self::NLDToggle
@@ -116,6 +123,7 @@ impl AgentToolbarItemKind {
Self::RichInput => "Rich Input",
Self::Settings => "Settings",
Self::FastForwardToggle => "Fast Forward",
Self::HandoffToCloud => "Hand off to cloud",
}
}
@@ -126,11 +134,44 @@ impl AgentToolbarItemKind {
Self::NLDToggle => Some(Icon::NLD),
Self::VoiceInput => Some(Icon::Microphone),
Self::FileAttach => Some(Icon::Plus),
Self::ContextWindowUsage => Some(Icon::ConversationContext0),
Self::ContextWindowUsage => Some(Icon::ContextRemaining100),
Self::FileExplorer => Some(Icon::FileCopy),
Self::RichInput => Some(Icon::TextInput),
Self::Settings => Some(Icon::Settings),
Self::FastForwardToggle => Some(Icon::FastForward),
// The bundled `upload-cloud-01.svg` (cloud-with-upward-arrow) is the
// closest fit among the existing icons for V0; design may swap it later.
Self::HandoffToCloud => Some(Icon::UploadCloud),
}
}
/// Whether this item should remain visible during `&` handoff-compose mode.
/// Only items relevant to composing a cloud run are shown.
pub(super) fn is_available_during_handoff_compose(&self) -> bool {
match self {
Self::ContextChip(
ContextChipKind::ShellGitBranch | ContextChipKind::GitBranchStatus,
) => true,
Self::ModelSelector | Self::VoiceInput | Self::FileAttach => true,
Self::ContextChip(_)
| Self::NLDToggle
| Self::ContextWindowUsage
| Self::FastForwardToggle
| Self::HandoffToCloud
| Self::ShareSession
| Self::FileExplorer
| Self::RichInput
| Self::Settings => false,
}
}
/// Whether this item should be included in the toolbar given the current app state.
/// Feature-flag checks live in `all_available()` / `default_*()`. This method
/// handles runtime conditions that depend on user settings or workspace state.
pub fn is_available(&self, app: &warpui::AppContext) -> bool {
match self {
Self::HandoffToCloud => AISettings::as_ref(app).is_cloud_handoff_enabled(app),
_ => true,
}
}
@@ -167,6 +208,17 @@ impl AgentToolbarItemKind {
Self::ContextWindowUsage,
Self::ModelSelector,
];
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
if FeatureFlag::OzHandoff.is_enabled()
&& FeatureFlag::HandoffLocalCloud.is_enabled()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
{
items.push(Self::HandoffToCloud);
}
items.push(Self::VoiceInput);
items.push(Self::FileAttach);
items
@@ -188,6 +240,17 @@ impl AgentToolbarItemKind {
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
items.push(Self::FastForwardToggle);
}
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
if FeatureFlag::OzHandoff.is_enabled()
&& FeatureFlag::HandoffLocalCloud.is_enabled()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
{
items.push(Self::HandoffToCloud);
}
items
}
@@ -9,6 +9,9 @@ use galaxyui::keymap::Keystroke;
use galaxyui::platform::OperatingSystem;
use galaxyui::{AppContext, Entity, ModelHandle, SingletonEntity, View, ViewContext};
use parking_lot::FairMutex;
use galaxyui::{
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use super::{AgentViewState, EphemeralMessageModel, EphemeralMessageModelEvent};
use crate::ai::agent::conversation::AIConversation;
@@ -18,19 +21,20 @@ use crate::ai::agent::{
use crate::ai::blocklist::agent_view::shortcuts::AgentShortcutViewModel;
use crate::ai::blocklist::agent_view::zero_state_block::render_ambient_credits_banner;
use crate::ai::blocklist::agent_view::{
agent_view_bg_fill, AgentViewController, AgentViewControllerEvent,
agent_view_bg_fill, is_in_cloud_context, AgentViewController, AgentViewControllerEvent,
};
use crate::ai::blocklist::{
BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent,
ai_brand_color, BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent,
BlocklistAIInputEvent, BlocklistAIInputModel,
};
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentModelEvent};
use crate::ai::mcp::{
templatable_manager::{FigmaMcpStatus, TemplatableMCPServerManagerEvent},
TemplatableMCPServerManager,
use crate::ai::mcp::templatable_manager::{FigmaMcpStatus, TemplatableMCPServerManagerEvent};
use crate::ai::mcp::TemplatableMCPServerManager;
use crate::ai::request_usage_model::{
AIRequestUsageModel, AIRequestUsageModelEvent, AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD,
};
use crate::ai::request_usage_model::{AIRequestUsageModel, AIRequestUsageModelEvent};
use crate::search::slash_command_menu::static_commands::commands;
use crate::settings::AISettings;
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
use crate::terminal::input::message_bar::attached_context::{
AttachedBlocksMessageProducer, AttachedContextArgs, AttachedTextSelectionMessageProducer,
@@ -45,7 +49,7 @@ use crate::terminal::input::slash_command_model::{SlashCommandEntryState, SlashC
use crate::terminal::input::suggestions_mode_model::{
InputSuggestionsModeEvent, InputSuggestionsModeModel,
};
use crate::terminal::input::{InputAction, SET_INPUT_MODE_AGENT_ACTION_NAME};
use crate::terminal::input::{HandoffComposeState, InputAction, SET_INPUT_MODE_AGENT_ACTION_NAME};
use crate::terminal::model::TerminalModel;
use crate::terminal::view::TerminalAction;
use crate::ui_components::blended_colors;
@@ -66,11 +70,14 @@ pub struct AgentMessageBarMouseStates {
pub toggle_plan: MouseStateHandle,
pub toggle_conversation_menu: MouseStateHandle,
pub toggle_code_review: MouseStateHandle,
pub handoff_to_cloud: MouseStateHandle,
pub clear_attached_context: MouseStateHandle,
/// Mouse state handle for the "Get Figma MCP" contextual button.
pub figma_install_button: MouseStateHandle,
/// Mouse state handle for the "Enable Figma MCP" contextual button.
pub figma_enable_button: MouseStateHandle,
/// Mouse state handle for dismissing the ambient credits banner.
pub ambient_credits_banner_close: MouseStateHandle,
}
/// Renders contextual hint text at the bottom of the agent view status bar.
@@ -83,6 +90,7 @@ pub struct AgentMessageBar {
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
slash_command_model: ModelHandle<SlashCommandModel>,
context_model: ModelHandle<BlocklistAIContextModel>,
handoff_compose_state: ModelHandle<HandoffComposeState>,
terminal_model: Arc<FairMutex<TerminalModel>>,
mouse_states: AgentMessageBarMouseStates,
/// Whether the word "figma" has been detected in the current input buffer or attached images.
@@ -93,6 +101,10 @@ pub struct AgentMessageBar {
impl Entity for AgentMessageBar {
type Event = ();
}
#[derive(Clone, Debug)]
pub enum AgentMessageBarAction {
DismissAmbientCreditsBanner,
}
impl AgentMessageBar {
#[allow(clippy::too_many_arguments)]
@@ -105,6 +117,7 @@ impl AgentMessageBar {
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
slash_command_model: ModelHandle<SlashCommandModel>,
context_model: ModelHandle<BlocklistAIContextModel>,
handoff_compose_state: ModelHandle<HandoffComposeState>,
terminal_model: Arc<FairMutex<TerminalModel>>,
ctx: &mut ViewContext<Self>,
) -> Self {
@@ -166,6 +179,9 @@ impl AgentMessageBar {
ctx.subscribe_to_model(&slash_command_model, |_, _, _, ctx| {
ctx.notify();
});
ctx.subscribe_to_model(&handoff_compose_state, |_, _, _, ctx| {
ctx.notify();
});
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, |_, _, event, ctx| {
@@ -215,7 +231,11 @@ impl AgentMessageBar {
}
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |_, _, event, ctx| {
if matches!(event, AIRequestUsageModelEvent::RequestUsageUpdated) {
if matches!(
event,
AIRequestUsageModelEvent::RequestUsageUpdated
| AIRequestUsageModelEvent::AmbientCreditsBannerDismissed
) {
ctx.notify();
}
});
@@ -229,6 +249,7 @@ impl AgentMessageBar {
input_suggestions_model,
slash_command_model,
context_model,
handoff_compose_state,
terminal_model,
mouse_states: AgentMessageBarMouseStates::default(),
figma_detected: false,
@@ -293,6 +314,7 @@ impl View for AgentMessageBar {
let agent_view_controller = self.agent_view_controller.as_ref(app);
let context_model = self.context_model.as_ref(app);
let slash_command_model = self.slash_command_model.as_ref(app);
let handoff_compose_state = self.handoff_compose_state.as_ref(app);
let terminal_model = self.terminal_model.lock();
let appearance = Appearance::as_ref(app);
@@ -317,6 +339,7 @@ impl View for AgentMessageBar {
input_model,
slash_command_model,
context_model,
handoff_compose_state,
terminal_model: &terminal_model,
appearance,
app,
@@ -331,6 +354,7 @@ impl View for AgentMessageBar {
.or_else(|| AttachedBlocksMessageProducer.produce_message(args))
.or_else(|| AttachedTextSelectionMessageProducer.produce_message(args))
.or_else(|| AutodetectedBashModeMessageProducer.produce_message(args))
.or_else(|| ExitCloudHandoffModeMessageProducer.produce_message(args))
.or_else(|| ExitBashModeMessageProducer.produce_message(args))
.or_else(|| HideShortcutsMessageProducer.produce_message(args))
.or_else(|| ZeroStateMessageProducer.produce_message(args))
@@ -340,19 +364,26 @@ impl View for AgentMessageBar {
};
// Show credits banner when user has ambient credits remaining.
use crate::ai::request_usage_model::AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD;
let right_element = if cfg!(target_family = "wasm") {
None
} else if let Some(credits) =
AIRequestUsageModel::as_ref(app).ambient_only_credits_remaining()
{
if credits >= AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD {
Some(render_ambient_credits_banner(credits, app))
} else {
let request_usage_model = AIRequestUsageModel::as_ref(app);
if let Some(credits) = request_usage_model.ambient_only_credits_remaining() {
if credits >= AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD
&& !request_usage_model.is_ambient_credits_banner_dismissed()
{
Some(render_ambient_credits_banner(
credits,
self.mouse_states.ambient_credits_banner_close.clone(),
AgentMessageBarAction::DismissAmbientCreditsBanner,
app,
))
} else {
None
}
} else {
None
}
} else {
None
};
// Append a Figma MCP chip to the message if applicable.
@@ -395,6 +426,19 @@ impl View for AgentMessageBar {
}
}
impl TypedActionView for AgentMessageBar {
type Action = AgentMessageBarAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AgentMessageBarAction::DismissAmbientCreditsBanner => {
AIRequestUsageModel::handle(ctx).update(ctx, |model, ctx| {
model.dismiss_ambient_credits_banner(ctx);
});
}
}
}
}
/// Arguments for agent message producers.
#[derive(Copy, Clone)]
pub struct AgentMessageArgs<'a> {
@@ -406,6 +450,7 @@ pub struct AgentMessageArgs<'a> {
pub input_model: &'a BlocklistAIInputModel,
pub slash_command_model: &'a SlashCommandModel,
pub context_model: &'a BlocklistAIContextModel,
pub handoff_compose_state: &'a HandoffComposeState,
pub terminal_model: &'a TerminalModel,
pub appearance: &'a Appearance,
pub app: &'a AppContext,
@@ -565,10 +610,36 @@ impl MessageProvider<AgentMessageArgs<'_>> for ZeroStateMessageProducer {
.with_is_disabled(!is_buffer_empty),
);
let is_cloud_agent = matches!(
agent_view_controller.agent_view_state(),
AgentViewState::Active { origin, .. } if origin.is_cloud_agent()
);
let is_cloud_agent =
is_in_cloud_context(agent_view_controller.agent_view_state(), terminal_model);
let ai_settings = AISettings::as_ref(app);
// Handoff to cloud only available for local agents.
if !is_cloud_agent && ai_settings.is_ampersand_handoff_enabled(app) {
items.push(
MessageItem::clickable(
vec![
MessageItem::Keystroke {
keystroke: Keystroke {
key: "&".to_owned(),
..Default::default()
},
color: color_override_for_shortcuts_and_commands,
background_color: bg_color_override_for_shortcuts_and_commands,
},
MessageItem::Text {
content: "send task to the cloud".into(),
color: color_override_for_shortcuts_and_commands,
},
],
|ctx| {
ctx.dispatch_typed_action(InputAction::ActivateCloudHandoff);
},
mouse_states.handoff_to_cloud.clone(),
)
.with_is_disabled(!is_buffer_empty),
);
}
let plan_count = AIDocumentModel::as_ref(app)
.get_all_documents_for_conversation(active_conversation.id())
@@ -596,7 +667,10 @@ impl MessageProvider<AgentMessageArgs<'_>> for ZeroStateMessageProducer {
// Code review only works locally.
#[cfg(not(target_family = "wasm"))]
if !is_cloud_agent && *TabSettings::as_ref(app).show_code_review_button {
if !is_cloud_agent
&& !ai_settings.is_cloud_handoff_enabled(app)
&& *TabSettings::as_ref(app).show_code_review_button
{
let code_review_keystroke = if OperatingSystem::get().is_mac() {
Keystroke::parse("cmd-shift-+").expect("keystroke should parse")
} else {
@@ -711,15 +785,21 @@ fn should_fork_from_last_known_good_state(
};
match error {
RenderableAIError::QuotaLimit
RenderableAIError::QuotaLimit { .. }
| RenderableAIError::ServerOverloaded
| RenderableAIError::ContextWindowExceeded(_)
| RenderableAIError::InvalidApiKey { .. }
| RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid { .. } => false,
RenderableAIError::InternalWarpError => true,
// A shell-exit failure can't resume in this (now-dead) pane, but the user
// can fork from the last known good state to continue in a fresh one.
RenderableAIError::InternalWarpError | RenderableAIError::AgentExitedShell => true,
RenderableAIError::Other {
will_attempt_resume,
..
}
| RenderableAIError::TransientNetworkError {
will_attempt_resume,
..
} => !will_attempt_resume,
}
}
@@ -734,10 +814,14 @@ impl MessageProvider<AgentMessageArgs<'_>> for ForkSlashCommandMessageProducer {
return None;
};
let command_name = detected_command.command.name;
if command_name != commands::FORK.name
&& command_name != commands::FORK_FROM.name
&& command_name != commands::FORK_AND_COMPACT.name
{
let is_fork_family = command_name == commands::FORK.name
|| command_name == commands::FORK_FROM.name
|| command_name == commands::FORK_AND_COMPACT.name;
#[cfg(not(target_family = "wasm"))]
let is_continue_locally = command_name == commands::CONTINUE_LOCALLY.name;
#[cfg(target_family = "wasm")]
let is_continue_locally = false;
if !is_fork_family && !is_continue_locally {
return None;
}
let modifier_keystroke = if cfg!(target_os = "macos") {
@@ -755,23 +839,15 @@ impl MessageProvider<AgentMessageArgs<'_>> for ForkSlashCommandMessageProducer {
}
};
// `/fork` opens in a new pane with Enter and a new tab with Cmd/Ctrl+Enter.
// Other fork-like commands open in the current pane with Enter and a new pane
// with Cmd/Ctrl+Enter.
let (primary_label, secondary_label) = if command_name == commands::FORK.name {
(" new pane", " new tab")
} else {
(" current pane", " new pane")
};
// All fork-style commands open a new pane on Enter and a new tab on Cmd/Ctrl+Enter.
Some(Message::new(vec![
MessageItem::keystroke(Keystroke {
key: "enter".to_owned(),
..Default::default()
}),
MessageItem::text(primary_label),
MessageItem::text(" new pane"),
MessageItem::keystroke(modifier_keystroke),
MessageItem::text(secondary_label),
MessageItem::text(" new tab"),
]))
}
}
@@ -808,8 +884,8 @@ impl MessageProvider<AgentMessageArgs<'_>> for AutodetectedBashModeMessageProduc
input_buffer_model,
input_model,
appearance,
slash_command_model,
app,
slash_command_model,
..
} = args;
if input_model.is_ai_input_enabled()
@@ -834,47 +910,90 @@ impl MessageProvider<AgentMessageArgs<'_>> for AutodetectedBashModeMessageProduc
}
}
struct ExitCloudHandoffModeMessageProducer;
impl MessageProvider<AgentMessageArgs<'_>> for ExitCloudHandoffModeMessageProducer {
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
let AgentMessageArgs {
input_buffer_model,
handoff_compose_state,
appearance,
..
} = args;
if !handoff_compose_state.is_active() {
return None;
}
let active_color = ai_brand_color(appearance.theme());
let is_buffer_empty = input_buffer_model.current_value().is_empty();
let (dismiss_text_color, dismiss_key_color, dismiss_key_bg) = if is_buffer_empty {
(active_color, None, None)
} else {
(
Fill::from(active_color).with_opacity(60).into_solid(),
Some(
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into_solid(),
),
Some(blended_colors::neutral_1(appearance.theme())),
)
};
Some(Message::new(vec![
MessageItem::Keystroke {
keystroke: Keystroke {
key: "enter".to_owned(),
..Default::default()
},
color: None,
background_color: None,
},
MessageItem::Text {
content: "to hand off to cloud".into(),
color: Some(active_color),
},
MessageItem::Keystroke {
keystroke: Keystroke {
key: "backspace".to_owned(),
..Default::default()
},
color: dismiss_key_color,
background_color: dismiss_key_bg,
},
MessageItem::Text {
content: "to dismiss".into(),
color: Some(dismiss_text_color),
},
]))
}
}
struct ExitBashModeMessageProducer;
impl MessageProvider<AgentMessageArgs<'_>> for ExitBashModeMessageProducer {
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
let AgentMessageArgs {
input_buffer_model,
input_model,
appearance,
app,
..
} = args;
if input_model.is_ai_input_enabled() || !input_model.is_input_type_locked() {
return None;
}
let set_input_mode_agent_keystroke =
keybinding_name_to_keystroke(SET_INPUT_MODE_AGENT_ACTION_NAME, app)?;
let (text_color, keystroke_color_override, keystroke_bg_color_override) =
if input_buffer_model.current_value().is_empty() {
(appearance.theme().ansi_fg_blue(), None, None)
} else {
(
Fill::from(appearance.theme().ansi_fg_blue())
.with_opacity(60)
.into_solid(),
Some(
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into_solid(),
),
Some(blended_colors::neutral_1(appearance.theme())),
)
};
let text_color = appearance.theme().ansi_fg_blue();
Some(
Message::new(vec![
MessageItem::Keystroke {
keystroke: Keystroke {
key: "backspace".to_owned(),
..Default::default()
},
color: keystroke_color_override,
background_color: keystroke_bg_color_override,
keystroke: set_input_mode_agent_keystroke,
color: None,
background_color: None,
},
MessageItem::text("to exit shell mode"),
])
@@ -1,36 +1,38 @@
use galaxy_core::ui::{appearance::Appearance, Icon};
use galaxyui::{
elements::{
ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex, Hoverable, MainAxisSize,
MouseStateHandle, ParentElement, Shrinkable, Text,
},
fonts::{Properties, Style, Weight::Bold},
platform::Cursor,
prelude::{Border, CornerRadius, Radius},
text_layout::ClipConfig,
Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::Vector2F;
use settings::Setting;
use crate::{
ai::{
active_agent_views_model::ActiveAgentViewsModel,
agent::conversation::{AIConversationId, ConversationStatus},
blocklist::BlocklistAIHistoryEvent,
},
terminal::BlockListSettings,
ui_components::blended_colors,
view_components::DismissibleToast,
workspace::{ToastStack, WorkspaceAction},
BlocklistAIHistoryModel,
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::Icon;
use galaxyui::elements::{
ConstrainedBox, Container, CrossAxisAlignment, Empty, Expanded, Flex, Hoverable, MainAxisSize,
MouseStateHandle, ParentElement, SavePosition, Shrinkable, Text,
};
use galaxyui::fonts::Weight::Bold;
use galaxyui::fonts::{Properties, Style};
use galaxyui::platform::Cursor;
use galaxyui::prelude::{Border, CornerRadius, Radius};
use galaxyui::text_layout::ClipConfig;
use galaxyui::{
AppContext, Element, Entity, EntityId, EventContext, ModelHandle, SingletonEntity,
TypedActionView, View, ViewContext,
};
use super::{AgentViewController, AgentViewEntryOrigin};
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::BlocklistAIHistoryEvent;
use crate::terminal::BlockListSettings;
use crate::ui_components::blended_colors;
use crate::ui_components::icon_with_status::{render_icon_with_status, IconWithStatusVariant};
use crate::view_components::DismissibleToast;
use crate::workspace::{ToastStack, WorkspaceAction};
use crate::BlocklistAIHistoryModel;
#[derive(Default)]
struct StateHandles {
block: MouseStateHandle,
fork_button: MouseStateHandle,
open_conversation_button: MouseStateHandle,
}
pub struct AgentViewEntryBlockParams {
@@ -52,6 +54,8 @@ pub struct AgentViewEntryBlock {
/// Cached title for rendering when conversation no longer exists (i.e. after deletion).
cached_title: Option<String>,
state_handles: StateHandles,
/// UI instance anchor for positioning the entry context menu.
view_id: EntityId,
}
impl AgentViewEntryBlock {
@@ -84,7 +88,6 @@ impl AgentViewEntryBlock {
_ => (),
});
ctx.subscribe_to_model(&agent_view_controller, |_, _, _, ctx| ctx.notify());
let active_agent_views_model = ActiveAgentViewsModel::handle(ctx);
ctx.subscribe_to_model(&active_agent_views_model, |_, _, _, ctx| ctx.notify());
@@ -96,10 +99,15 @@ impl AgentViewEntryBlock {
origin,
cached_title: Default::default(),
state_handles: Default::default(),
view_id: ctx.view_id(),
}
}
}
pub fn get_agent_view_entry_block_position_id(view_id: EntityId) -> String {
format!("agent_view_entry_block_{view_id}")
}
pub fn render_block_container(
origin: AgentViewEntryOrigin,
content: Box<dyn Element>,
@@ -155,6 +163,45 @@ fn render_subtext(text: String, appearance: &Appearance) -> Box<dyn Element> {
.finish()
}
/// Renders a custom button with icon and hover/click states for the Agent View Block.
///
/// This button component is currently used for the "fork" and "open agent conversation" actions
/// within the Agent View. Future actions re-using this pattern should use this component for
/// visually consistent icon-based interaction.
fn render_agent_view_block_button<F>(
icon: Icon,
icon_color: ColorU,
mouse_state: MouseStateHandle,
appearance: &Appearance,
on_click: F,
) -> Box<dyn Element>
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
{
Hoverable::new(mouse_state, move |state| {
let container = Container::new(
ConstrainedBox::new(icon.to_warpui_icon(icon_color.into()).finish())
.with_height(20.)
.with_width(20.)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(5.)));
let container = if state.is_clicked() {
container.with_background(blended_colors::fg_overlay_4(appearance.theme()))
} else if state.is_hovered() {
container.with_background(blended_colors::fg_overlay_3(appearance.theme()))
} else {
container
};
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(on_click)
.finish()
}
fn render_deleted_state(
origin: AgentViewEntryOrigin,
cached_title: Option<String>,
@@ -203,6 +250,7 @@ impl View for AgentViewEntryBlock {
}
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let are_block_dividers_enabled =
*BlockListSettings::as_ref(app).show_block_dividers.value();
@@ -211,7 +259,7 @@ impl View for AgentViewEntryBlock {
// If the agent_view_block's conversation no longer exists,
// we assume that it has been deleted.
return render_deleted_state(
self.origin,
self.origin.clone(),
self.cached_title.clone(),
appearance,
are_block_dividers_enabled,
@@ -222,42 +270,46 @@ impl View for AgentViewEntryBlock {
return Empty::new().finish();
}
fn with_opacity(mut color: ColorU, opacity: u8) -> ColorU {
color.a = opacity;
color
}
let agent_icon = render_icon_with_status(
IconWithStatusVariant::OzAgent {
status: Some(conversation.status().clone()),
is_ambient: false,
},
24.,
0.,
theme,
theme.background(),
);
let status_icon = conversation.status().render_icon(appearance);
let status_icon_bg = match conversation.status() {
ConversationStatus::InProgress => {
with_opacity(appearance.theme().ansi_fg_magenta(), 25)
}
ConversationStatus::Success => with_opacity(appearance.theme().ansi_fg_green(), 25),
ConversationStatus::Error => with_opacity(appearance.theme().ansi_fg_red(), 25),
ConversationStatus::Cancelled => {
with_opacity(blended_colors::neutral_5(appearance.theme()), 25)
}
ConversationStatus::Blocked { .. } => {
with_opacity(appearance.theme().ansi_fg_yellow(), 25)
}
let is_active =
ActiveAgentViewsModel::as_ref(app).is_conversation_open(self.conversation_id, app);
let is_active_in_this_pane = self
.agent_view_controller
.as_ref(app)
.agent_view_state()
.active_conversation_id()
== Some(self.conversation_id);
let is_open_elsewhere = is_active && !is_active_in_this_pane;
let subtext = if is_open_elsewhere {
Some("Open in different pane")
} else if self.is_restored {
Some("Restored")
} else if !self.is_new
&& !matches!(
&self.origin,
AgentViewEntryOrigin::LongRunningCommand
| AgentViewEntryOrigin::AgentRequestedNewConversation
)
{
Some("Continued")
} else {
None
};
let mut row = Flex::row()
// Build the title+subtext section.
let mut title_section = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Container::new(
ConstrainedBox::new(status_icon.finish())
.with_height(16.)
.with_width(16.)
.finish(),
)
.with_uniform_padding(2.)
.with_background_color(status_icon_bg)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_margin_right(8.)
.finish(),
)
.with_child(
Shrinkable::new(
1.,
@@ -282,86 +334,114 @@ impl View for AgentViewEntryBlock {
)
.finish(),
);
let is_active =
ActiveAgentViewsModel::as_ref(app).is_conversation_open(self.conversation_id, app);
let is_active_in_this_pane = self
.agent_view_controller
.as_ref(app)
.agent_view_state()
.active_conversation_id()
== Some(self.conversation_id);
let is_open_elsewhere = is_active && !is_active_in_this_pane;
let subtext = if is_open_elsewhere {
Some("Open in different pane")
} else if self.is_restored {
Some("Restored")
} else if !self.is_new
&& !matches!(
self.origin,
AgentViewEntryOrigin::LongRunningCommand
| AgentViewEntryOrigin::AgentRequestedNewConversation
)
{
Some("Continued")
} else {
None
};
if let Some(subtext) = subtext {
row.add_child(render_subtext(subtext.to_string(), appearance));
title_section.add_child(render_subtext(subtext.to_string(), appearance));
}
row.add_child(
Container::new(Empty::new().finish())
.with_margin_right(8.)
.finish(),
);
row.add_child(
ConstrainedBox::new(
Icon::ChevronRight
.to_galaxyui_icon(
blended_colors::text_sub(
appearance.theme(),
appearance.theme().background(),
)
.into(),
)
.finish(),
)
.with_height(20.)
.with_width(20.)
.finish(),
);
let conversation_id = self.conversation_id;
let origin = self.origin;
Hoverable::new(self.state_handles.block.clone(), move |hoverable_state| {
let background = if hoverable_state.is_hovered() {
blended_colors::fg_overlay_2(appearance.theme())
} else {
blended_colors::fg_overlay_1(appearance.theme())
};
render_block_container(
origin,
row.finish(),
background.into(),
appearance,
are_block_dividers_enabled,
)
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode { conversation_id });
})
let icon_color = blended_colors::text_sub(theme, theme.background());
let fork_button = ConstrainedBox::new(render_agent_view_block_button(
Icon::ArrowSplit,
icon_color,
self.state_handles.fork_button.clone(),
appearance,
move |ctx, _, _| {
ctx.dispatch_typed_action(EnterAgentBlockAction::ForkConversation {
conversation_id,
});
},
))
.with_height(20.)
.with_width(20.)
.finish();
let open_conversation_button = ConstrainedBox::new(render_agent_view_block_button(
Icon::ChevronRight,
icon_color,
self.state_handles.open_conversation_button.clone(),
appearance,
move |ctx, _, _| {
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode {
conversation_id,
});
},
))
.with_height(20.)
.with_width(20.)
.finish();
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_child(Container::new(agent_icon).with_margin_right(8.).finish())
// Expanded fills all remaining space, pushing buttons to the right.
.with_child(Expanded::new(1., title_section.finish()).finish())
.with_child(Container::new(fork_button).with_margin_left(8.).finish())
.with_child(open_conversation_button);
let origin = self.origin.clone();
let entry_block_id = self.view_id;
let entry_block_position_id = get_agent_view_entry_block_position_id(entry_block_id);
SavePosition::new(
Hoverable::new(self.state_handles.block.clone(), move |hoverable_state| {
let background = if hoverable_state.is_hovered() {
blended_colors::fg_overlay_2(appearance.theme())
} else {
blended_colors::fg_overlay_1(appearance.theme())
};
render_block_container(
origin.clone(),
row.finish(),
background.into(),
appearance,
are_block_dividers_enabled,
)
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode {
conversation_id,
});
})
.on_right_click(move |ctx, _, position| {
let Some(entry_bounds) = ctx.element_position_by_id(&entry_block_position_id)
else {
log::warn!(
"Could not retrieve the position of the agent view entry block for context menu display."
);
return;
};
// Subtract the block's origin so the position is expressed relative
// to the block element. OffsetPositioning::offset_from_save_position_element
// then offsets the menu from the saved block position, placing it at
// the exact mouse cursor location.
ctx.dispatch_typed_action(EnterAgentBlockAction::OpenConversationContextMenu {
conversation_id,
agent_view_entry_block_id: entry_block_id,
position: position - entry_bounds.origin(),
});
})
.with_defer_events_to_children()
.finish(),
&get_agent_view_entry_block_position_id(entry_block_id),
)
.finish()
}
}
#[derive(Debug, Clone)]
pub enum AgentViewEntryBlockEvent {
EnterAgentView { conversation_id: AIConversationId },
EnterAgentView {
conversation_id: AIConversationId,
},
OpenConversationContextMenu {
conversation_id: AIConversationId,
agent_view_entry_block_id: EntityId,
position: Vector2F,
},
ForkConversation {
conversation_id: AIConversationId,
},
}
impl Entity for AgentViewEntryBlock {
@@ -370,7 +450,17 @@ impl Entity for AgentViewEntryBlock {
#[derive(Debug, Clone)]
pub enum EnterAgentBlockAction {
EnterAgentMode { conversation_id: AIConversationId },
EnterAgentMode {
conversation_id: AIConversationId,
},
OpenConversationContextMenu {
conversation_id: AIConversationId,
agent_view_entry_block_id: EntityId,
position: Vector2F,
},
ForkConversation {
conversation_id: AIConversationId,
},
}
impl TypedActionView for AgentViewEntryBlock {
@@ -416,6 +506,22 @@ impl TypedActionView for AgentViewEntryBlock {
});
}
}
EnterAgentBlockAction::OpenConversationContextMenu {
conversation_id,
agent_view_entry_block_id,
position,
} => {
ctx.emit(AgentViewEntryBlockEvent::OpenConversationContextMenu {
conversation_id: *conversation_id,
agent_view_entry_block_id: *agent_view_entry_block_id,
position: *position,
});
}
EnterAgentBlockAction::ForkConversation { conversation_id } => {
ctx.emit(AgentViewEntryBlockEvent::ForkConversation {
conversation_id: *conversation_id,
});
}
}
}
}
+87 -79
View File
@@ -1,25 +1,23 @@
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
use std::time::Duration;
use instant::Instant;
use parking_lot::FairMutex;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::keymap::Keystroke;
use galaxyui::AppContext;
use galaxyui::{
r#async::SpawnedFutureHandle, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity,
};
use parking_lot::FairMutex;
use crate::terminal::input::message_bar::{Message, MessageItem};
use crate::terminal::input::slash_commands::SlashCommandTrigger;
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::{
ai::agent::conversation::AIConversationId,
terminal::{view::ambient_agent::AmbientAgentViewModel, TerminalModel},
BlocklistAIHistoryModel,
};
use galaxyui::r#async::SpawnedFutureHandle;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::{DismissalStrategy, EphemeralMessage, EphemeralMessageModel};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::orchestration_topology::{
adjacent_orchestration_child_conversation_id, OrchestrationNavigationDirection,
};
use crate::terminal::input::message_bar::{Message, MessageItem};
use crate::terminal::input::slash_commands::SlashCommandTrigger;
use crate::terminal::TerminalModel;
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::BlocklistAIHistoryModel;
/// Error returned when entering the agent view fails.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
@@ -100,7 +98,7 @@ impl PendingConfirmation {
///
/// Depending on the entrypoint, an `AgentView` block representing the entry may be inserted into
/// the terminal blocklist.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentViewEntryOrigin {
/// Entered agent view from user input (e.g. /agent or cmd-enter keypress).
Input {
@@ -138,6 +136,8 @@ pub enum AgentViewEntryOrigin {
ThirdPartyCloudAgent,
/// Entered agent view via the CLI (e.g. `warp agent run`).
Cli,
/// Entered agent view via the headless TUI frontend.
Tui,
/// Entered agent view by adding an image (drag-and-drop or paste).
ImageAdded,
/// Entered agent view by executing a slash command that requires agent mode.
@@ -146,8 +146,8 @@ pub enum AgentViewEntryOrigin {
},
SlashInit,
CreateEnvironment,
/// Entered agent view by executing a slash command that requires agent mode.
Keybinding,
/// Entered agent view from the new-conversation keybinding.
Keybinding(Keystroke),
/// Entered agent view by attaching context from the code review panel.
CodeReviewContext,
/// Entered agent view from codex integration modal.
@@ -169,6 +169,11 @@ pub enum AgentViewEntryOrigin {
/// Entered agent view because a parent agent started this child agent via StartAgent.
ChildAgent,
/// Entered agent view by clicking a pill / breadcrumb in the orchestration
/// pill bar (or breadcrumb row) to navigate the current pane to a sibling
/// or parent conversation in the same orchestration tree.
OrchestrationPillBar,
/// Entered agent view after opening project from OS directory picker.
ProjectEntry,
@@ -178,6 +183,10 @@ pub enum AgentViewEntryOrigin {
/// Entered agent view by clearing the buffer (Cmd+K) while already in agent view.
ClearBuffer,
/// Entered agent view via the "Jump to Latest Agent Message" command, which
/// returns to the most recent conversation from the terminal.
JumpToLatestAgentMessage,
// The variants below actually correspond to callsites where the selected conversation is
// updated, but don't actually correspond to entering the agent view. They exist so we can
// continue to call `set_pending_query_state_for_(new|existing)_conversation`, but you'll find
@@ -257,6 +266,13 @@ impl AgentViewState {
}
}
pub fn origin(&self) -> Option<AgentViewEntryOrigin> {
match self {
AgentViewState::Active { origin, .. } => Some(origin.clone()),
AgentViewState::Inactive => None,
}
}
/// Returns `true` if in an active agent view state.
pub fn is_active(&self) -> bool {
matches!(self, AgentViewState::Active { .. })
@@ -334,7 +350,7 @@ const NEW_CONVERSATION_KEYBINDING_CONFIRMATION_MESSAGE_ID: &str =
/// Controller responsible for managing and updating agent view state for a given terminal pane.
///
/// `AgentViewState` is stored on the terminal model but should only be updated via the APIs on
/// this constroller, which ensures the correct events are emitted and downstream effects take
/// this controller, which ensures the correct events are emitted and downstream effects take
/// place.
pub struct AgentViewController {
terminal_model: Arc<FairMutex<TerminalModel>>,
@@ -343,7 +359,6 @@ pub struct AgentViewController {
/// Set during terminal pane attach; used for pane-group-scoped visibility checks.
pane_group_id: Option<EntityId>,
agent_view_state: AgentViewState,
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
pending_confirmation: Option<PendingConfirmation>,
pending_confirmation_abort_handle: Option<SpawnedFutureHandle>,
@@ -368,16 +383,13 @@ impl AgentViewController {
pub fn new(
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
_ctx: &mut ModelContext<Self>,
) -> Self {
Self {
terminal_model,
terminal_view_id,
pane_group_id: None,
agent_view_state: AgentViewState::Inactive,
ambient_agent_view_model,
ephemeral_message_model,
pending_confirmation: None,
pending_confirmation_abort_handle: None,
@@ -388,6 +400,11 @@ impl AgentViewController {
self.pane_group_id
}
/// Returns the [`EntityId`] of the [`TerminalView`] that owns this controller.
pub fn terminal_view_id(&self) -> EntityId {
self.terminal_view_id
}
pub fn set_pane_group_id(&mut self, pane_group_id: EntityId) {
self.pane_group_id = Some(pane_group_id);
}
@@ -408,35 +425,37 @@ impl AgentViewController {
&self.agent_view_state
}
/// Resolves the conversation adjacent to the active agent-view conversation
/// in the canonical orchestration pill order.
pub fn adjacent_orchestration_conversation_id(
&self,
direction: OrchestrationNavigationDirection,
app: &AppContext,
) -> Option<AIConversationId> {
let active_conversation_id = self.agent_view_state.active_conversation_id()?;
adjacent_orchestration_child_conversation_id(
BlocklistAIHistoryModel::as_ref(app),
active_conversation_id,
direction,
)
}
/// Returns whether the user is allowed to exit agent view.
/// This is used to determine both whether the escape key should work
/// and whether the escape keybinding should be displayed.
pub fn can_exit_agent_view(
&self,
ctx: &impl galaxyui::ModelAsRef,
) -> Result<(), ExitAgentViewError> {
pub fn can_exit_agent_view(&self) -> Result<(), ExitAgentViewError> {
let model = self.terminal_model.lock();
let ambient_agent_model = self.ambient_agent_view_model.as_ref(ctx);
let is_fullscreen_with_long_running = self.agent_view_state.is_fullscreen()
&& model
.block_list()
.active_block()
.is_active_and_long_running();
// Ambient agent sessions should not allow exiting agent view if it's not backed by a terminal session because there's nothing to escape to.
if ambient_agent_model.is_ambient_agent() {
if !ambient_agent_model.has_parent_terminal() {
return Err(ExitAgentViewError::AmbientAgent);
}
// But if there is a terminal backing and we're in a LRC, we should be able to escape
else if is_fullscreen_with_long_running {
return Ok(());
}
}
// In a non-ambient agent case, users cannot exit the fullscreen agent view with an active long running command.
if is_fullscreen_with_long_running {
// Cloud agent panes do not have the same underlying terminal ownership
// constraint (no local shell process), so long-running third party agent
// commands should not trap the user in agent view.
if is_fullscreen_with_long_running && !model.is_dummy_cloud_mode_session() {
return Err(ExitAgentViewError::LongRunningCommand);
}
@@ -569,6 +588,26 @@ impl AgentViewController {
&mut self,
keybinding_name: &str,
ctx: &mut ModelContext<Self>,
) -> bool {
let Some(keystroke) = keybinding_name_to_keystroke(keybinding_name, ctx) else {
log::warn!(
"Expected keybinding for slash command {keybinding_name}, but none was found"
);
return true;
};
self.should_start_new_conversation_for_keystroke(keystroke, ctx)
}
/// Decides whether a keybinding-triggered new conversation should proceed immediately.
///
/// We only require a second press when the user is already in an active, non-empty
/// conversation. This protects against accidental conversation resets from muscle-memory
/// keypresses, while preserving single-step behavior for explicit typed/slash-menu execution.
pub fn should_start_new_conversation_for_keystroke(
&mut self,
keystroke: Keystroke,
ctx: &mut ModelContext<Self>,
) -> bool {
enum Decision {
StartNewConversation,
@@ -596,13 +635,6 @@ impl AgentViewController {
break 'decision Decision::StartNewConversation;
}
let Some(keystroke) = keybinding_name_to_keystroke(keybinding_name, ctx) else {
log::warn!(
"Expected keybinding for slash command {keybinding_name}, but none was found"
);
break 'decision Decision::StartNewConversation;
};
let normalized_keystroke = keystroke.normalized();
if self.is_new_conversation_keybinding_confirmation_active_for(
conversation_id,
@@ -711,7 +743,7 @@ impl AgentViewController {
.active_block()
.is_active_and_long_running()
&& !terminal_model.is_conversation_transcript_viewer()
&& !matches!(origin, AgentViewEntryOrigin::ThirdPartyCloudAgent)
&& !matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent)
};
if is_long_running {
@@ -781,7 +813,8 @@ impl AgentViewController {
history_model.start_new_conversation(
self.terminal_view_id,
false,
matches!(origin, AgentViewEntryOrigin::CloudAgent),
matches!(&origin, AgentViewEntryOrigin::CloudAgent),
matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent),
ctx,
)
});
@@ -793,7 +826,7 @@ impl AgentViewController {
self.agent_view_state = AgentViewState::Active {
conversation_id,
origin,
origin: origin.clone(),
display_mode,
original_conversation_length: exchange_count,
};
@@ -802,21 +835,6 @@ impl AgentViewController {
.block_list_mut()
.set_agent_view_state(self.agent_view_state.clone());
if origin == AgentViewEntryOrigin::CloudAgent {
// Only enter setup state if there are no existing environments.
// If environments exist, the user should go directly to composing.
let has_environments =
!crate::ai::cloud_environments::CloudAmbientAgentEnvironment::get_all(ctx)
.is_empty();
self.ambient_agent_view_model.update(ctx, |model, ctx| {
if has_environments {
model.enter_composing_from_setup(ctx);
} else {
model.enter_setup(ctx);
}
});
}
ctx.emit(AgentViewControllerEvent::EnteredAgentView {
conversation_id,
is_new: exchange_count == 0,
@@ -882,7 +900,7 @@ impl AgentViewController {
) {
self.clear_new_conversation_keybinding_confirmation(ctx);
// Check if exiting agent view is allowed.
if self.can_exit_agent_view(ctx).is_err() {
if self.can_exit_agent_view().is_err() {
return;
}
@@ -942,16 +960,6 @@ impl AgentViewController {
.block_list_mut()
.set_agent_view_state(self.agent_view_state.clone());
// Capture ambient agent status before resetting it
let was_ambient_agent = self.ambient_agent_view_model.as_ref(ctx).is_ambient_agent();
// Reset ambient agent status when exiting agent view
if was_ambient_agent {
self.ambient_agent_view_model.update(ctx, |model, ctx| {
model.reset_status(ctx);
});
}
let history_model = BlocklistAIHistoryModel::handle(ctx);
let final_exchange_count = history_model
.as_ref(ctx)
@@ -959,6 +967,7 @@ impl AgentViewController {
.map(|conversation| conversation.exchange_count())
.unwrap_or(0);
let was_ambient_agent = origin == AgentViewEntryOrigin::CloudAgent;
ctx.emit(AgentViewControllerEvent::ExitedAgentView {
conversation_id,
origin,
@@ -1012,7 +1021,6 @@ fn exit_confirmation_message(
) -> Message {
use galaxyui::SingletonEntity;
use crate::terminal::input::message_bar::{Message, MessageItem};
let appearance = Appearance::handle(app).as_ref(app);
@@ -0,0 +1,176 @@
use warpui::{AppContext, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::{
AgentViewController, AgentViewControllerEvent, AgentViewEntryOrigin, EnterAgentViewError,
};
use crate::ai::agent::conversation::{AIConversationAutoexecuteMode, AIConversationId};
use crate::ai::blocklist::conversation_selection::{
ConversationSelection, ConversationSelectionEvent,
};
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
/// GUI conversation selection backed unconditionally by Agent View.
pub(crate) struct AgentViewConversationSelection {
terminal_surface_id: EntityId,
agent_view_controller: ModelHandle<AgentViewController>,
}
impl AgentViewConversationSelection {
/// Creates GUI conversation selection for a terminal view.
pub(crate) fn new(
terminal_surface_id: warpui::EntityId,
agent_view_controller: ModelHandle<AgentViewController>,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) -> Self {
ctx.subscribe_to_model(&agent_view_controller, |_, _, event, ctx| match event {
AgentViewControllerEvent::EnteredAgentView {
display_mode,
origin,
..
} => {
ctx.emit(ConversationSelectionEvent::Changed);
ctx.emit(ConversationSelectionEvent::Activated {
is_fullscreen: display_mode.is_fullscreen(),
origin: origin.clone(),
});
}
AgentViewControllerEvent::ExitedAgentView {
conversation_id,
final_exchange_count,
is_exit_before_new_entrance,
..
} => {
ctx.emit(ConversationSelectionEvent::Changed);
ctx.emit(ConversationSelectionEvent::Deactivated {
conversation_id: *conversation_id,
final_exchange_count: *final_exchange_count,
is_exit_before_new_entrance: *is_exit_before_new_entrance,
});
}
AgentViewControllerEvent::ExitConfirmed { .. } => {}
});
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|selection, _, event, ctx| selection.handle_history_event(event, ctx),
);
Self {
terminal_surface_id,
agent_view_controller,
}
}
}
impl ConversationSelection for AgentViewConversationSelection {
fn selected_conversation_id(&self, app: &AppContext) -> Option<AIConversationId> {
self.agent_view_controller
.as_ref(app)
.agent_view_state()
.active_conversation_id()
}
fn is_conversation_active(&self, app: &AppContext) -> bool {
self.agent_view_controller.as_ref(app).is_active()
}
fn is_conversation_fullscreen(&self, app: &AppContext) -> bool {
self.agent_view_controller.as_ref(app).is_fullscreen()
}
fn select_existing_conversation(
&mut self,
conversation_id: AIConversationId,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
if let Err(error) = self.agent_view_controller.update(ctx, |controller, ctx| {
controller.try_enter_agent_view(Some(conversation_id), origin, ctx)
}) {
log::error!("Failed to enter agent view for existing conversation: {error}");
}
}
fn select_new_conversation(
&mut self,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
if let Err(error) = self.agent_view_controller.update(ctx, |controller, ctx| {
controller.try_enter_agent_view(None, origin, ctx)
}) {
log::error!("Failed to enter agent view for new conversation: {error}");
}
}
fn try_start_new_conversation(
&mut self,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) -> Result<AIConversationId, EnterAgentViewError> {
self.agent_view_controller.update(ctx, |controller, ctx| {
controller.try_enter_agent_view(None, origin, ctx)
})
}
fn pending_query_autoexecute_override(
&self,
app: &AppContext,
) -> AIConversationAutoexecuteMode {
self.selected_conversation_id(app)
.as_ref()
.and_then(|conversation_id| {
BlocklistAIHistoryModel::as_ref(app).conversation(conversation_id)
})
.map(|conversation| conversation.autoexecute_override())
.unwrap_or_default()
}
fn toggle_pending_query_autoexecute(
&mut self,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
if let Some(conversation_id) = self.selected_conversation_id(ctx) {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.toggle_autoexecute_override(
&conversation_id,
self.terminal_surface_id,
ctx,
);
});
}
}
fn handle_history_event(
&mut self,
event: &BlocklistAIHistoryEvent,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
if event
.terminal_surface_id()
.is_some_and(|id| id != self.terminal_surface_id)
{
return;
}
match event {
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } => {
self.agent_view_controller
.update(ctx, |controller, ctx| controller.exit_agent_view(ctx));
}
BlocklistAIHistoryEvent::SplitConversation {
old_conversation_id,
new_conversation_id,
..
} if self.selected_conversation_id(ctx) == Some(*old_conversation_id) => {
self.select_existing_conversation(
*new_conversation_id,
AgentViewEntryOrigin::AgentRequestedNewConversation,
ctx,
);
}
_ => {}
}
}
}
#[cfg(test)]
#[path = "conversation_selection_tests.rs"]
mod tests;
@@ -0,0 +1,68 @@
use std::sync::Arc;
use parking_lot::FairMutex;
use warpui::r#async::executor::Background;
use warpui::{App, EntityId};
use super::AgentViewConversationSelection;
use crate::ai::blocklist::agent_view::{
AgentViewController, AgentViewEntryOrigin, EphemeralMessageModel,
};
use crate::ai::blocklist::{BlocklistAIHistoryModel, ConversationSelection};
use crate::terminal::color::{self, Colors};
use crate::terminal::event_listener::ChannelEventListener;
use crate::terminal::model::test_utils::block_size;
use crate::terminal::TerminalModel;
use crate::test_util::settings::initialize_settings_for_tests;
#[test]
fn gui_selection_delegates_to_agent_view() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let terminal_surface_id = EntityId::new();
let terminal_model = Arc::new(FairMutex::new(TerminalModel::new_for_test(
block_size(),
color::List::from(&Colors::default()),
ChannelEventListener::new_for_test(),
Arc::new(Background::default()),
false,
None,
false,
false,
None,
)));
let ephemeral_message_model = app.add_model(|_| EphemeralMessageModel::new());
let agent_view_controller = app.add_model(|_| {
AgentViewController::new(terminal_model, terminal_surface_id, ephemeral_message_model)
});
let selection = app.add_model(|ctx| {
Box::new(AgentViewConversationSelection::new(
terminal_surface_id,
agent_view_controller.clone(),
ctx,
)) as Box<dyn ConversationSelection>
});
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_surface_id, false, false, false, ctx)
});
selection.update(&mut app, |selection, ctx| {
selection.select_existing_conversation(
conversation_id,
AgentViewEntryOrigin::ConversationSelector,
ctx,
);
});
selection.read(&app, |selection, ctx| {
assert_eq!(
selection.selected_conversation_id(ctx),
Some(conversation_id)
);
});
agent_view_controller.read(&app, |controller, _| {
assert!(controller.is_active());
});
});
}
@@ -4,9 +4,8 @@ use std::time::Duration;
use galaxyui::r#async::SpawnedFutureHandle;
use galaxyui::{Entity, ModelContext};
use crate::terminal::input::message_bar::{Message, MessageProvider};
use super::agent_message_bar::AgentMessageArgs;
use crate::terminal::input::message_bar::{Message, MessageProvider};
const DEFAULT_MESSAGE_DURATION: Duration = Duration::from_millis(1500);
@@ -1,25 +1,23 @@
use std::sync::Arc;
use ai::agent::action::{AIAgentActionType, ShellCommandDelay};
use parking_lot::FairMutex;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::{CornerRadius, Radius};
use galaxyui::{
elements::{CornerRadius, Radius},
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, View, ViewContext,
};
use parking_lot::FairMutex;
use crate::{
ai::{
agent::icons,
blocklist::{
block::cli_controller::LongRunningCommandControlState,
inline_action::inline_action_header::HeaderConfig, BlocklistAIActionModel,
BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
},
},
terminal::{model::session::Sessions, TerminalModel},
ui_components::{blended_colors, icons::Icon},
use crate::ai::agent::icons;
use crate::ai::blocklist::block::cli_controller::LongRunningCommandControlState;
use crate::ai::blocklist::inline_action::inline_action_header::HeaderConfig;
use crate::ai::blocklist::{
BlocklistAIActionModel, BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
};
use crate::terminal::model::session::Sessions;
use crate::terminal::TerminalModel;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
const AGENT_PROMPT_TO_INTERACT_MESSAGE: &str = "Prompt agent to interact with";
const AGENT_WAITING_ON_INSTRUCTIONS_MESSAGE: &str = "Agent is waiting on instructions";
@@ -47,7 +45,7 @@ impl InlineAgentViewHeader {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
if event
.terminal_view_id()
.terminal_surface_id()
.is_some_and(|id| id != me.terminal_view_id)
{
return;
+52 -7
View File
@@ -1,33 +1,39 @@
pub(crate) mod agent_input_footer;
mod agent_message_bar;
mod agent_view_block;
pub mod child_agent_status_card;
mod controller;
mod conversation_selection;
mod ephemeral_message_model;
mod inline_agent_view_header;
// TODO: Move orchestration_conversation_links module import elsewhere.
pub(crate) mod orchestration_avatar;
pub(crate) mod orchestration_conversation_links;
pub mod orchestration_pill_bar;
pub mod orchestration_pill_bar_model;
pub mod shortcuts;
pub(crate) mod subagent_inline_panel;
mod zero_state_block;
use std::sync::LazyLock;
pub use agent_input_footer::*;
pub use agent_message_bar::*;
pub use agent_view_block::*;
pub use controller::*;
pub(crate) use conversation_selection::AgentViewConversationSelection;
pub use ephemeral_message_model::*;
use galaxyui::fonts::Properties;
pub use inline_agent_view_header::*;
pub use zero_state_block::*;
use std::sync::LazyLock;
pub use orchestration_pill_bar::{render_orchestration_breadcrumbs, OrchestrationPillBar};
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::Fill;
use galaxy_core::ui::{appearance::Appearance, color::blend::Blend};
use galaxyui::keymap::Keystroke;
use galaxyui::{AppContext, SingletonEntity};
use pathfinder_color::ColorU;
pub use zero_state_block::*;
use crate::terminal::model::TerminalModel;
use crate::view_components::action_button::ActionButtonTheme;
pub static ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> = LazyLock::new(|| {
@@ -49,6 +55,45 @@ pub static ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> = La
}
});
pub static ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> =
LazyLock::new(|| {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
Keystroke {
cmd: true,
alt: true,
key: "enter".to_owned(),
..Default::default()
}
} else {
Keystroke {
ctrl: true,
alt: true,
key: "enter".to_owned(),
..Default::default()
}
}
}
});
/// Returns `true` when the current pane is in a cloud or remote context.
pub fn is_in_cloud_context(
agent_view_state: &AgentViewState,
terminal_model: &TerminalModel,
) -> bool {
let origin_is_cloud = matches!(
agent_view_state,
AgentViewState::Active { origin, .. }
if matches!(
origin,
AgentViewEntryOrigin::CloudAgent | AgentViewEntryOrigin::ThirdPartyCloudAgent
)
);
origin_is_cloud
|| terminal_model.is_conversation_transcript_viewer()
|| terminal_model.is_dummy_cloud_mode_session()
}
pub fn agent_view_bg_fill(app: &AppContext) -> Fill {
let appearance = Appearance::as_ref(app);
appearance.theme().surface_overlay_1()
@@ -0,0 +1,41 @@
use warpui::elements::Element;
use warpui::{AppContext, SingletonEntity};
use crate::ai::blocklist::agent_view::orchestration_pill_bar::{
render_agent_avatar_disc, render_orchestrator_avatar_disc,
};
use crate::appearance::Appearance;
const TRANSCRIPT_AVATAR_SCALE: f32 = 1.25;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum OrchestrationAvatar {
Orchestrator,
Agent { display_name: String },
}
impl OrchestrationAvatar {
pub(crate) fn agent(display_name: String) -> Self {
Self::Agent { display_name }
}
pub(crate) fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let size = app.font_cache().line_height(
appearance.monospace_font_size(),
appearance.line_height_ratio(),
) * TRANSCRIPT_AVATAR_SCALE;
match self {
Self::Orchestrator => render_orchestrator_avatar_disc(size, theme, appearance),
Self::Agent { display_name } => {
render_agent_avatar_disc(display_name, size, theme, appearance)
}
}
}
}
#[cfg(test)]
#[path = "orchestration_avatar_tests.rs"]
mod tests;
@@ -0,0 +1,11 @@
use super::OrchestrationAvatar;
#[test]
fn agent_avatar_identity_is_display_name_based_for_pill_consistency() {
assert_eq!(
OrchestrationAvatar::agent("Agent 1".to_string()),
OrchestrationAvatar::Agent {
display_name: "Agent 1".to_string(),
}
);
}
@@ -1,30 +1,28 @@
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxyui::{
elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex,
Hoverable, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
},
fonts::{Properties, Weight::Bold},
platform::Cursor,
text_layout::ClipConfig,
AppContext, Element, EventContext, SingletonEntity,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::Vector2F;
use crate::{
ai::{
agent::{
api::ServerConversationToken,
conversation::{AIConversation, AIConversationId},
},
agent_conversations_model::AgentConversationsModel,
blocklist::BlocklistAIHistoryModel,
},
ui_components::{blended_colors, icons::Icon},
workspace::{RestoreConversationLayout, WorkspaceAction},
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex, Hoverable,
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::fonts::Properties;
use galaxyui::fonts::Weight::Bold;
use galaxyui::platform::Cursor;
use galaxyui::text_layout::ClipConfig;
use galaxyui::{AppContext, Element, EntityId, EventContext, SingletonEntity};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
use crate::ai::agent_conversations_model::entry::AgentConversationEntryId;
use crate::ai::agent_conversations_model::{
AgentConversationNavigationSubject, AgentConversationsModel,
};
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::terminal::view::TerminalAction;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspace::{RestoreConversationLayout, WorkspaceAction, WorkspaceRegistry};
pub(crate) fn conversation_id_for_agent_id(
agent_id: &str,
@@ -40,6 +38,79 @@ pub(crate) fn conversation_id_for_agent_id(
})
}
/// True if the conversation is open in some other visible pane. Hidden
/// child-agent panes are excluded so unopened children don't look
/// "already open".
pub(crate) fn is_conversation_open_in_other_visible_view(
conversation_id: AIConversationId,
self_terminal_view_id: EntityId,
app: &AppContext,
) -> bool {
let Some(owner) =
BlocklistAIHistoryModel::as_ref(app).terminal_surface_id_for_conversation(&conversation_id)
else {
return false;
};
if owner == self_terminal_view_id {
return false;
}
pane_group_id_containing_terminal_view(owner, app).is_some()
}
/// Finds the pane group containing the given terminal view across all
/// visible panes/tabs. Used to distinguish same-tab vs cross-tab focus.
pub(crate) fn pane_group_id_containing_terminal_view(
terminal_view_id: EntityId,
app: &AppContext,
) -> Option<EntityId> {
let registry = WorkspaceRegistry::as_ref(app);
for (_, workspace_handle) in registry.all_workspaces(app) {
let workspace = workspace_handle.as_ref(app);
for pane_group_handle in workspace.tab_views() {
let pane_group = pane_group_handle.as_ref(app);
for pane_id in pane_group.visible_pane_ids() {
if let Some(terminal_view) = pane_group.terminal_view_from_pane_id(pane_id, app) {
if terminal_view.id() == terminal_view_id {
return Some(pane_group_handle.id());
}
}
}
}
}
None
}
/// Navigates to a child agent's pane: focuses an existing sibling pane,
/// activates the owning tab, or splits off a new pane.
pub(crate) fn dispatch_focus_or_open_child_agent_pane(
conversation_id: AIConversationId,
self_terminal_view_id: EntityId,
ctx: &mut EventContext,
app: &AppContext,
) {
if let Some(owner_view_id) =
BlocklistAIHistoryModel::as_ref(app).terminal_surface_id_for_conversation(&conversation_id)
{
if owner_view_id != self_terminal_view_id {
if let Some(owner_pane_group_id) =
pane_group_id_containing_terminal_view(owner_view_id, app)
{
let self_pane_group_id =
pane_group_id_containing_terminal_view(self_terminal_view_id, app);
if Some(owner_pane_group_id) == self_pane_group_id {
ctx.dispatch_typed_action(TerminalAction::RevealChildAgent { conversation_id });
} else {
ctx.dispatch_typed_action(WorkspaceAction::FocusTerminalViewInWorkspace {
terminal_view_id: owner_view_id,
});
}
return;
}
}
}
ctx.dispatch_typed_action(TerminalAction::OpenChildAgentInNewPane { conversation_id });
}
pub(crate) fn parent_conversation_id(
active_conversation: &AIConversation,
app: &AppContext,
@@ -54,19 +125,14 @@ pub(crate) fn parent_conversation_id(
pub(crate) fn conversation_navigation_action(
conversation_id: AIConversationId,
app: &AppContext,
) -> WorkspaceAction {
AgentConversationsModel::as_ref(app)
.get_conversation(&conversation_id)
.and_then(|conversation| {
conversation.get_open_action(Some(RestoreConversationLayout::SplitPane), app)
})
.unwrap_or(WorkspaceAction::RestoreOrNavigateToConversation {
pane_view_locator: None,
window_id: None,
) -> Option<WorkspaceAction> {
AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(AgentConversationEntryId::Conversation(
conversation_id,
terminal_view_id: None,
restore_layout: Some(RestoreConversationLayout::SplitPane),
})
)),
Some(RestoreConversationLayout::SplitPane),
app,
)
}
pub(crate) fn parent_conversation_navigation_card(
@@ -79,7 +145,7 @@ pub(crate) fn parent_conversation_navigation_card(
.conversation(&parent_conversation_id)
.and_then(|conversation| conversation.title())
.unwrap_or_else(|| "Parent conversation".to_string());
let action = conversation_navigation_action(parent_conversation_id, app);
let action = conversation_navigation_action(parent_conversation_id, app)?;
Some(conversation_navigation_card(
parent_title,
Some("Back to parent conversation".to_string()),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
//! Shared singleton holding cross-pane UI state for the orchestration pill
//! bar: the set of pinned child-agent conversations (persisted so pins
//! survive restarts) and a per-orchestrator horizontal scroll handle so
//! the pill row's scroll offset survives switching between sibling panes.
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use warpui::elements::ClippedScrollStateHandle;
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
/// Toggle `id` in `pinned` — insert when absent, remove when present.
/// Returns the new membership state (`true` = now pinned).
/// Extracted so toggle semantics can be unit tested without a singleton.
pub(super) fn toggle_pin_in_set(
pinned: &mut HashSet<AIConversationId>,
id: AIConversationId,
) -> bool {
if pinned.remove(&id) {
false
} else {
pinned.insert(id);
true
}
}
/// Singleton owning cross-pane UI state for the orchestration pill bar.
pub struct OrchestrationPillBarModel {
/// In-memory mirror of which child conversations are pinned. The
/// persisted source of truth lives on each conversation; this set
/// exists for fast lookups and cross-pane event notification.
pinned: HashSet<AIConversationId>,
/// One scroll handle per orchestrator conversation id. Every pill
/// bar rendering the same orchestration tree clones the same handle
/// so panning in one pane is reflected in sibling panes. `RefCell`
/// so handles can be lazily created from `&AppContext` paths.
horizontal_scroll_states: RefCell<HashMap<AIConversationId, ClippedScrollStateHandle>>,
}
impl OrchestrationPillBarModel {
/// Construct the singleton seeded with the set of conversation ids that
/// were already pinned in persisted storage at startup.
pub fn new(initial_pinned: HashSet<AIConversationId>, ctx: &mut ModelContext<Self>) -> Self {
// Prune deleted conversations globally so per-pane code never
// has to (and can't accidentally clobber sibling panes' pins).
let history_handle = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_handle, |this, _, event, ctx| match event {
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
} => {
if this.pinned.remove(conversation_id) {
ctx.emit(OrchestrationPillBarEvent::PinSetChanged);
}
// Drop the matching scroll handle so deleted orchestrators
// don't leak entries. No event needed — nothing subscribes
// to scroll-state changes.
this.horizontal_scroll_states
.borrow_mut()
.remove(conversation_id);
}
_ => {}
});
Self {
pinned: initial_pinned,
horizontal_scroll_states: RefCell::new(HashMap::new()),
}
}
/// Returns `true` if the given conversation id is currently pinned.
pub fn is_pinned(&self, conversation_id: &AIConversationId) -> bool {
self.pinned.contains(conversation_id)
}
/// Returns the shared horizontal scroll handle for the given
/// orchestration tree, lazily creating one on first access. Every
/// pill bar in that tree clones the same handle, so panning in one
/// pane stays in sync with sibling panes.
pub fn horizontal_scroll_state_for(
&self,
orchestrator_id: AIConversationId,
) -> ClippedScrollStateHandle {
self.horizontal_scroll_states
.borrow_mut()
.entry(orchestrator_id)
.or_default()
.clone()
}
/// Toggle whether `conversation_id` is pinned, persist the new state to
/// the underlying conversation, and notify subscribers.
pub fn toggle_pin(&mut self, conversation_id: AIConversationId, ctx: &mut ModelContext<Self>) {
let now_pinned = toggle_pin_in_set(&mut self.pinned, conversation_id);
// Push the change down to the conversation so the next session
// restore reflects it. Doing this even if the conversation isn't
// yet loaded is fine — the in-memory set is the active source
// until that conversation rehydrates.
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.set_conversation_pinned(conversation_id, now_pinned, ctx);
});
ctx.emit(OrchestrationPillBarEvent::PinSetChanged);
}
/// Clears the in-memory pinned set and the scroll handle cache.
/// Invoked on logout so the next user does not inherit the previous
/// account's UI state; persisted pins are wiped by the sqlite reset
/// that runs alongside logout.
pub fn reset(&mut self) {
self.pinned.clear();
self.horizontal_scroll_states.borrow_mut().clear();
}
}
impl Entity for OrchestrationPillBarModel {
type Event = OrchestrationPillBarEvent;
}
impl SingletonEntity for OrchestrationPillBarModel {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OrchestrationPillBarEvent {
/// The pinned set changed (toggle or history-driven prune).
PinSetChanged,
}
#[cfg(test)]
#[path = "orchestration_pill_bar_model_tests.rs"]
mod tests;
@@ -0,0 +1,146 @@
use std::collections::HashSet;
use warpui::App;
use super::*;
use crate::ai::agent::conversation::AIConversation;
use crate::persistence::ModelEvent;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
#[test]
fn toggle_pin_in_set_flips_membership_for_each_call() {
let mut pinned: HashSet<AIConversationId> = HashSet::new();
let id = AIConversationId::new();
// Initially empty.
assert!(!pinned.contains(&id));
// First toggle pins the id and reports the new state.
assert!(toggle_pin_in_set(&mut pinned, id));
assert!(pinned.contains(&id));
assert_eq!(pinned.len(), 1);
// Second toggle unpins it.
assert!(!toggle_pin_in_set(&mut pinned, id));
assert!(!pinned.contains(&id));
assert!(pinned.is_empty());
// Third toggle re-pins, confirming the function is idempotent under
// pairs and doesn't accumulate state.
assert!(toggle_pin_in_set(&mut pinned, id));
assert!(pinned.contains(&id));
}
#[test]
fn toggle_pin_in_set_only_affects_target_id() {
let mut pinned: HashSet<AIConversationId> = HashSet::new();
let a = AIConversationId::new();
let b = AIConversationId::new();
toggle_pin_in_set(&mut pinned, a);
toggle_pin_in_set(&mut pinned, b);
assert!(pinned.contains(&a));
assert!(pinned.contains(&b));
// Unpinning `a` leaves `b` alone.
toggle_pin_in_set(&mut pinned, a);
assert!(!pinned.contains(&a));
assert!(pinned.contains(&b));
}
/// End-to-end check that toggling a pin (a) updates the in-memory
/// `AIConversation.pinned` flag on the history model and (b) emits an
/// `UpdateMultiAgentConversation` SQLite event carrying `pinned: true`,
/// which is what makes the pin survive an app restart.
#[test]
fn toggle_pin_persists_pinned_state_to_sqlite_event() {
App::test((), |mut app| async move {
// `write_updated_conversation_state` reads `GeneralSettings`,
// `AppExecutionMode`, and the global model_event_sender; wire all
// three up so the SQLite write path actually runs.
initialize_settings_for_tests(&mut app);
let (sender, receiver) = std::sync::mpsc::sync_channel::<ModelEvent>(4);
let mut global_resource_handles = GlobalResourceHandles::mock(&mut app);
global_resource_handles.model_event_sender = Some(sender);
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
// Restore a conversation that starts unpinned. The history model is
// the source of truth for the persisted flag.
let conversation = AIConversation::new(false, false);
let conversation_id = conversation.id();
let terminal_view_id = warpui::EntityId::new();
history_model.update(&mut app, |model, ctx| {
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
});
// Pin model must be registered after the history model because it
// subscribes to history events on construction.
let pin_model =
app.add_singleton_model(|ctx| OrchestrationPillBarModel::new(HashSet::new(), ctx));
pin_model.update(&mut app, |model, ctx| {
model.toggle_pin(conversation_id, ctx);
});
// In-memory pin set reflects the toggle.
pin_model.read(&app, |model, _| {
assert!(
model.is_pinned(&conversation_id),
"pin set should contain the conversation after toggle"
);
});
// The conversation's persisted `pinned` flag is now true.
history_model.read(&app, |model, _| {
let pinned = model.conversation(&conversation_id).map(|c| c.is_pinned());
assert_eq!(
pinned,
Some(true),
"AIConversation.is_pinned() should reflect the toggle"
);
});
// The SQLite writer should have received an UpdateMultiAgentConversation
// event with pinned=true. The fire-and-forget send happens on the
// executor, so allow a short timeout.
let event = receiver
.recv_timeout(std::time::Duration::from_secs(1))
.expect("expected an UpdateMultiAgentConversation event after toggle_pin");
match event {
ModelEvent::UpdateMultiAgentConversation {
conversation_id: persisted_id,
conversation_data,
..
} => {
assert_eq!(persisted_id, conversation_id.to_string());
assert!(
conversation_data.pinned,
"persisted AgentConversationData.pinned should be true"
);
}
other => panic!("expected UpdateMultiAgentConversation, got {other:?}"),
}
// Toggling again should write pinned=false through the same path.
pin_model.update(&mut app, |model, ctx| {
model.toggle_pin(conversation_id, ctx);
});
let event = receiver
.recv_timeout(std::time::Duration::from_secs(1))
.expect("expected a second UpdateMultiAgentConversation event after unpin");
match event {
ModelEvent::UpdateMultiAgentConversation {
conversation_data, ..
} => {
assert!(
!conversation_data.pinned,
"persisted AgentConversationData.pinned should be false after unpin"
);
}
other => panic!("expected UpdateMultiAgentConversation, got {other:?}"),
}
});
}
@@ -0,0 +1,199 @@
use super::*;
// Traversal and canonical pill-order correctness are exercised in
// `app/src/ai/blocklist/orchestration_topology_tests.rs`. These tests stay
// focused on the pill bar's own dispatch behavior.
/// The data layer that `OrchestrationPillBar::pill_specs` reads must
/// surface restored orchestration children before any pane has been created.
///
/// `pill_specs` (defined privately on `OrchestrationPillBar`) walks
/// `descendant_conversation_ids_in_spawn_order(history, orchestrator_id)` and
/// then `filter_map(|id| history.conversation(&id))`. The
/// `history.conversation(&id)` lookup must return `Some` for restored
/// children even before the parent's hidden pane materializes, or the pill
/// bar renders nothing. This test asserts both layers work after
/// `BlocklistAIHistoryModel::new` runs, before any `restore_conversations` /
/// pane materialization.
#[test]
fn pill_bar_data_layer_finds_restored_children_before_pane_creation() {
use chrono::Utc;
use uuid::Uuid;
use warpui::App;
use crate::ai::blocklist::orchestration_topology::descendant_conversation_ids_in_spawn_order;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::persistence::model::{
AgentConversation, AgentConversationData, AgentConversationRecord,
};
App::test((), |app| async move {
let parent_id = AIConversationId::new();
let child_id = AIConversationId::new();
let parent_run_id = Uuid::new_v4().to_string();
let child_run_id = Uuid::new_v4().to_string();
let now = Utc::now().naive_utc();
let conversations = vec![
AgentConversation {
conversation: AgentConversationRecord {
id: 1,
conversation_id: child_id.to_string(),
conversation_data: serde_json::to_string(&AgentConversationData {
server_conversation_token: Some("child-token".to_string()),
conversation_usage_metadata: None,
reverted_action_ids: None,
forked_from_server_conversation_token: None,
artifacts_json: None,
parent_agent_id: Some(parent_run_id.clone()),
agent_name: Some("Agent 1".to_string()),
orchestration_harness_type: None,
parent_conversation_id: Some(parent_id.to_string()),
is_remote_child: false,
root_task_is_optimistic: None,
run_id: Some(child_run_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
pinned: false,
})
.expect("child conversation data should serialize"),
last_modified_at: now,
},
tasks: vec![warp_multi_agent_api::Task {
id: format!("task-{child_id}"),
messages: vec![warp_multi_agent_api::Message {
fetched_memories: vec![],
id: "child-msg".to_string(),
task_id: format!("task-{child_id}"),
server_message_data: String::new(),
citations: vec![],
message: Some(warp_multi_agent_api::message::Message::UserQuery(
warp_multi_agent_api::message::UserQuery {
query: "Child query".to_string(),
context: None,
referenced_attachments: Default::default(),
mode: None,
intended_agent: Default::default(),
},
)),
request_id: "request-1".to_string(),
timestamp: None,
}],
dependencies: None,
description: "Child query".to_string(),
summary: String::new(),
server_data: String::new(),
}],
},
AgentConversation {
conversation: AgentConversationRecord {
id: 2,
conversation_id: parent_id.to_string(),
conversation_data: serde_json::to_string(&AgentConversationData {
server_conversation_token: Some("parent-token".to_string()),
conversation_usage_metadata: None,
reverted_action_ids: None,
forked_from_server_conversation_token: None,
artifacts_json: None,
parent_agent_id: None,
agent_name: None,
orchestration_harness_type: None,
parent_conversation_id: None,
is_remote_child: false,
root_task_is_optimistic: None,
run_id: Some(parent_run_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
pinned: false,
})
.expect("parent conversation data should serialize"),
last_modified_at: now - chrono::Duration::seconds(1),
},
tasks: vec![warp_multi_agent_api::Task {
id: format!("task-{parent_id}"),
messages: vec![warp_multi_agent_api::Message {
fetched_memories: vec![],
id: "parent-msg".to_string(),
task_id: format!("task-{parent_id}"),
server_message_data: String::new(),
citations: vec![],
message: Some(warp_multi_agent_api::message::Message::UserQuery(
warp_multi_agent_api::message::UserQuery {
query: "Parent query".to_string(),
context: None,
referenced_attachments: Default::default(),
mode: None,
intended_agent: Default::default(),
},
)),
request_id: "request-2".to_string(),
timestamp: None,
}],
dependencies: None,
description: "Parent query".to_string(),
summary: String::new(),
server_data: String::new(),
}],
},
];
let history_model =
app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &conversations));
history_model.read(&app, |model, _| {
// pill_specs walks `descendant_conversation_ids_in_spawn_order`
// first. This index must be populated for restored children at
// app startup, before any pane materializes.
let descendants = descendant_conversation_ids_in_spawn_order(model, parent_id);
assert_eq!(
descendants,
vec![child_id],
"orchestration topology must surface restored children before any pane is created",
);
// pill_specs then collects pill specs via
// `descendants.into_iter().filter_map(|id| history.conversation(&id))`.
// The child must be hydrated eagerly so this lookup succeeds and
// the pill bar renders; otherwise the filter_map would drop the
// child (because `conversation(&child_id)` returned `None`) and
// `pill_specs` would return `None` from the
// `children.is_empty()` early-exit.
let resolved_children: Vec<&AIConversation> = descendants
.iter()
.filter_map(|id| model.conversation(id))
.collect();
assert_eq!(
resolved_children.len(),
1,
"restored child conversation must be available in conversations_by_id so \
OrchestrationPillBar::pill_specs renders a child pill",
);
assert_eq!(resolved_children[0].id(), child_id);
assert_eq!(resolved_children[0].agent_name(), Some("Agent 1"));
});
});
}
#[test]
fn navigation_action_for_child_pill_reveals_existing_child_pane() {
let conversation_id = AIConversationId::new();
assert!(matches!(
navigation_action_for_pill(PillKind::Child, conversation_id),
TerminalAction::RevealChildAgent {
conversation_id: actual_id,
} if actual_id == conversation_id
));
}
#[test]
fn navigation_action_for_orchestrator_pill_switches_in_place() {
let conversation_id = AIConversationId::new();
assert!(matches!(
navigation_action_for_pill(PillKind::Orchestrator, conversation_id),
TerminalAction::SwitchAgentViewToConversation {
conversation_id: actual_id,
} if actual_id == conversation_id
));
}
@@ -1,27 +1,27 @@
mod model;
pub use model::*;
use pathfinder_color::ColorU;
use std::borrow::Cow;
use galaxy_core::{features::FeatureFlag, ui::appearance::Appearance};
use galaxyui::{
elements::{Border, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text},
keymap::Keystroke,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Element, SingletonEntity,
pub use model::*;
use pathfinder_color::ColorU;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::{
Border, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text,
};
use warpui::keymap::Keystroke;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::{AppContext, Element, SingletonEntity};
use crate::{
ai::blocklist::agent_view::ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
cmd_or_ctrl_shift,
terminal::{self, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING},
ui_components::blended_colors,
util::bindings::keybinding_name_to_keystroke,
workspace::view::{
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
},
use crate::ai::blocklist::agent_view::{
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE, ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
};
use crate::cmd_or_ctrl_shift;
use crate::terminal::{self, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING};
use crate::ui_components::blended_colors;
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::workspace::view::{
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
};
#[derive(Copy, Clone, Debug, Default)]
@@ -259,7 +259,6 @@ pub fn render_agent_shortcuts_view(
}
pub mod styles {
use galaxy_core::ui::appearance::Appearance;
pub fn keystroke_size(appearance: &Appearance) -> f32 {
font_size(appearance) + 2.
@@ -1,12 +1,9 @@
use galaxy_core::send_telemetry_from_ctx;
use galaxyui::{Entity, ModelContext, ModelHandle};
use galaxy_core::send_telemetry_from_ctx;
use crate::{
ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent},
server::telemetry::TelemetryEvent,
terminal::input::buffer_model::InputBufferModel,
};
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
use crate::server::telemetry::TelemetryEvent;
use crate::terminal::input::buffer_model::InputBufferModel;
/// Model responsible for managing state required to conditionally render the shortcuts view.
pub struct AgentShortcutViewModel {
@@ -19,12 +16,12 @@ impl AgentShortcutViewModel {
agent_view_controller: ModelHandle<AgentViewController>,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(&input_buffer_model, |me, event, ctx| {
ctx.subscribe_to_model(&input_buffer_model, |me, _, event, ctx| {
if me.is_shortcut_view_open && !event.new_content.is_empty() {
me.hide_shortcut_view(ctx);
}
});
ctx.subscribe_to_model(&agent_view_controller, |me, event, ctx| {
ctx.subscribe_to_model(&agent_view_controller, |me, _, event, ctx| {
if matches!(event, AgentViewControllerEvent::ExitedAgentView { .. }) {
me.hide_shortcut_view(ctx);
}
@@ -1,57 +1,53 @@
#![allow(dead_code, unused_imports, unused_variables)]
use galaxy_core::{features::FeatureFlag, report_if_error, ui::Icon};
use galaxyui::{
elements::{
Clipped, Container, CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement,
HighlightedHyperlink, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable,
Text,
},
fonts::{Properties, Weight},
keymap::Keystroke,
prelude::{Align, ConstrainedBox, Cursor, Empty, Hoverable, MainAxisAlignment, SavePosition},
scene::Border,
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use std::borrow::Cow;
use std::cmp::Reverse;
use std::path::Path;
use std::sync::Arc;
use itertools::Itertools as _;
use markdown_parser::{parse_markdown, FormattedText, FormattedTextFragment, FormattedTextLine};
use parking_lot::FairMutex;
use settings::Setting;
use galaxy_core::features::FeatureFlag;
use galaxy_core::report_if_error;
use galaxy_core::ui::Icon;
use galaxyui::elements::{
Clipped, Container, CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement,
HighlightedHyperlink, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::keymap::Keystroke;
use galaxyui::prelude::{
Align, ConstrainedBox, Cursor, Empty, Hoverable, MainAxisAlignment, SavePosition,
};
use galaxyui::scene::Border;
use galaxyui::ui_components::components::{UiComponent as _, UiComponentStyles};
use galaxyui::{
Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext,
};
use std::{borrow::Cow, cmp::Reverse, path::Path, sync::Arc};
use crate::{
ai::{
active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId},
agent::conversation::AIConversationId,
blocklist::{
agent_view::{
agent_view_bg_color, AgentViewController, AgentViewEntryOrigin,
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
},
history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel},
},
conversation_navigation::ConversationNavigationData,
},
appearance::Appearance,
changelog_model::{self, ChangelogModel},
settings::{AISettings, AISettingsChangedEvent},
terminal::{
self,
event::BlockType,
input::message_bar::{common::render_standard_message, Message, MessageItem},
model::{
blocks::BlockHeightItem,
session::{BootstrapSessionType, Session, SessionType, Sessions},
},
model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher},
prompt,
view::{
ambient_agent::{AmbientAgentViewModel, AmbientAgentViewModelEvent},
TerminalAction,
},
TerminalModel,
},
util::time_format::format_approx_duration_from_now_utc,
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::agent_view::{
agent_view_bg_color, AgentViewController, AgentViewEntryOrigin,
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE, ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
};
use crate::ai::blocklist::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::ai::conversation_navigation::ConversationNavigationData;
use crate::appearance::Appearance;
use crate::changelog_model::{self, ChangelogModel};
use crate::settings::{AISettings, AISettingsChangedEvent};
use crate::terminal::event::BlockType;
use crate::terminal::input::message_bar::common::render_standard_message;
use crate::terminal::input::message_bar::{Message, MessageItem};
use crate::terminal::model::blocks::BlockHeightItem;
use crate::terminal::model::session::{BootstrapSessionType, Session, SessionType, Sessions};
use crate::terminal::model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher};
use crate::terminal::view::ambient_agent::{AmbientAgentViewModel, AmbientAgentViewModelEvent};
use crate::terminal::view::TerminalAction;
use crate::terminal::{self, prompt, TerminalModel};
use crate::util::time_format::format_approx_duration_from_now_utc;
const CLOUD_AGENT_DOCS_URL: &str = "https://docs.warp.dev/agent-platform/cloud-agents/overview";
const OZ_UPDATES_SECTION_HEADER: &str = "What's new in Oz";
@@ -97,13 +93,14 @@ impl AgentViewZeroStateBlock {
origin: AgentViewEntryOrigin,
agent_view_controller: ModelHandle<AgentViewController>,
sessions: &ModelHandle<Sessions>,
cloud_agent_view_model: &ModelHandle<AmbientAgentViewModel>,
cloud_agent_view_model: Option<&ModelHandle<AmbientAgentViewModel>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
model_events_dispatcher: &ModelHandle<ModelEventDispatcher>,
should_show_init_callout: bool,
ctx: &mut ViewContext<Self>,
) -> Self {
let cloud_agent_view_model_clone = cloud_agent_view_model.clone();
let cloud_agent_view_model_clone = cloud_agent_view_model.cloned();
let model_events_clone = model_events_dispatcher.clone();
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
@@ -116,7 +113,10 @@ impl AgentViewZeroStateBlock {
me.should_hide = true;
ctx.unsubscribe_to_model(&model_events_clone);
ctx.unsubscribe_to_model(&history_model);
ctx.unsubscribe_to_model(&cloud_agent_view_model_clone);
if let Some(cloud_agent_view_model) = cloud_agent_view_model_clone.as_ref()
{
ctx.unsubscribe_to_model(cloud_agent_view_model);
}
ctx.notify();
return;
}
@@ -140,7 +140,7 @@ impl AgentViewZeroStateBlock {
ctx.notify();
});
let cloud_agent_view_model_clone = cloud_agent_view_model.clone();
let cloud_agent_view_model_clone = cloud_agent_view_model.cloned();
ctx.subscribe_to_model(
model_events_dispatcher,
move |me, model_events_dispatcher, event, ctx| {
@@ -152,7 +152,11 @@ impl AgentViewZeroStateBlock {
me.should_hide = true;
ctx.unsubscribe_to_model(&model_events_dispatcher);
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
ctx.unsubscribe_to_model(&cloud_agent_view_model_clone);
if let Some(cloud_agent_view_model) =
cloud_agent_view_model_clone.as_ref()
{
ctx.unsubscribe_to_model(cloud_agent_view_model);
}
ctx.notify();
}
}
@@ -166,34 +170,46 @@ impl AgentViewZeroStateBlock {
},
);
let model_events_clone = model_events_dispatcher.clone();
ctx.subscribe_to_model(cloud_agent_view_model, move |me, model, event, ctx| {
if FeatureFlag::CloudModeSetupV2.is_enabled() {
match event {
AmbientAgentViewModelEvent::DispatchedAgent
| AmbientAgentViewModelEvent::Cancelled
if !me.should_hide =>
{
me.should_hide = true;
ctx.unsubscribe_to_model(&model);
ctx.unsubscribe_to_model(&model_events_clone);
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
ctx.notify();
}
_ => (),
if let Some(cloud_agent_view_model) = cloud_agent_view_model {
let model_events_clone = model_events_dispatcher.clone();
ctx.subscribe_to_model(cloud_agent_view_model, move |me, model, event, ctx| {
if me.should_hide {
return;
}
} else if model.as_ref(ctx).should_show_status_footer() {
me.should_hide = true;
ctx.unsubscribe_to_model(&model);
ctx.unsubscribe_to_model(&model_events_clone);
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
ctx.notify();
}
});
let cloud_agent_view = cloud_agent_view_model.as_ref(ctx);
// Hide the zero state when this pane becomes a local-to-cloud handoff
// pane (REMOTE-1486). The fresh cloud-mode banner is suppressed because
// the pane is actually pre-loaded with a forked source conversation, not
// a brand-new one.
if matches!(event, AmbientAgentViewModelEvent::PendingHandoffChanged)
&& model.as_ref(ctx).is_local_to_cloud_handoff()
{
me.should_hide = true;
} else if FeatureFlag::CloudModeSetupV2.is_enabled() {
if matches!(
event,
AmbientAgentViewModelEvent::DispatchedAgent
| AmbientAgentViewModelEvent::Cancelled
) {
me.should_hide = true;
}
} else if model.as_ref(ctx).should_show_status_footer() {
me.should_hide = true;
}
if me.should_hide {
ctx.unsubscribe_to_model(&model);
ctx.unsubscribe_to_model(&model_events_clone);
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
ctx.notify();
}
});
}
let has_parent_terminal =
!cloud_agent_view.is_ambient_agent() || cloud_agent_view.has_parent_terminal();
cloud_agent_view_model.is_none_or(|model| !model.as_ref(ctx).is_ambient_agent());
let is_local_to_cloud_handoff = cloud_agent_view_model
.is_some_and(|model| model.as_ref(ctx).is_local_to_cloud_handoff());
let changelog_model = ChangelogModel::handle(ctx);
ctx.subscribe_to_model(&changelog_model, |me, changelog_model, event, ctx| {
if let changelog_model::Event::ChangelogRequestComplete { .. } = event {
@@ -230,6 +246,10 @@ impl AgentViewZeroStateBlock {
Self::recent_conversations_for_working_directory(current_working_directory, ctx)
})
.unwrap_or_default();
let should_hide = matches!(origin, AgentViewEntryOrigin::AcceptedPassiveCodeDiff)
|| is_local_to_cloud_handoff;
let is_oz_updates_expanded = !origin.is_cloud_agent()
&& *AISettings::handle(ctx).as_ref(ctx).should_expand_oz_updates;
Self {
conversation_id,
@@ -239,11 +259,11 @@ impl AgentViewZeroStateBlock {
terminal_model,
current_working_directory,
cached_recent_conversations,
should_hide: matches!(origin, AgentViewEntryOrigin::AcceptedPassiveCodeDiff),
should_hide,
should_show_init_callout,
has_parent_terminal,
state_handles,
is_oz_updates_expanded: false,
is_oz_updates_expanded,
}
}
@@ -402,7 +422,7 @@ impl View for AgentViewZeroStateBlock {
let active_session = self.active_session(app);
let body = render_body(
ZeroStateBodyProps {
origin: self.origin,
origin: self.origin.clone(),
has_parent_terminal: self.has_parent_terminal,
should_show_init_callout: self.should_show_init_callout,
recent_conversations: &self.cached_recent_conversations,
@@ -681,7 +701,11 @@ fn render_body(props: ZeroStateBodyProps<'_>, app: &AppContext) -> Vec<Box<dyn E
MessageItem::text("start a new agent conversation"),
],
|ctx| {
ctx.dispatch_typed_action(TerminalAction::StartNewAgentConversation);
ctx.dispatch_typed_action(TerminalAction::StartNewAgentConversation {
origin: AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
});
},
state_handles.start_new_conversation.clone(),
)]),
@@ -1145,14 +1169,19 @@ fn render_oz_updates(props: OzUpdatesProps<'_>, app: &AppContext) -> Option<Box<
}
/// Renders the ambient credits banner showing free cloud credits.
/// If `link_mouse_state` is provided, a "Launch cloud agent" link is shown.
pub fn render_ambient_credits_banner(credits: i32, app: &AppContext) -> Box<dyn Element> {
pub fn render_ambient_credits_banner<A>(
credits: i32,
close_button_mouse_state: MouseStateHandle,
dismiss_action: A,
app: &AppContext,
) -> Box<dyn Element>
where
A: Action + Clone + 'static,
{
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let font_size = styles::CREDITS_BANNER_FONT_SIZE;
// Use ANSI terminal colors for the pill styling.
let text_color = theme.terminal_colors().normal.blue;
let credits_text = format!("{credits} free cloud agent credits");
@@ -1161,8 +1190,26 @@ pub fn render_ambient_credits_banner(credits: i32, app: &AppContext) -> Box<dyn
.with_style(Properties::default().weight(Weight::Semibold))
.soft_wrap(false)
.finish();
let close_button = appearance
.ui_builder()
.close_button(12., close_button_mouse_state)
.with_style(UiComponentStyles {
font_color: Some(text_color.into()),
..Default::default()
})
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(dismiss_action.clone());
})
.finish();
Container::new(text)
let content = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(text)
.with_child(Container::new(close_button).with_margin_left(4.).finish())
.finish();
Container::new(content)
.with_border(Border::all(1.).with_border_color(text_color.into()))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.with_vertical_padding(2.)
@@ -1,14 +1,16 @@
use std::path::PathBuf;
use std::sync::Arc;
use warpui::r#async::executor::Background;
use super::{display_working_directory, format_session_location, should_render_oz_updates_section};
use crate::ai::blocklist::agent_view::zero_state_block::current_working_directory_for_zero_state;
use crate::terminal::model::ansi::{Handler, InitShellValue, PrecmdValue, SSHValue};
use crate::terminal::color::{self, Colors};
use crate::terminal::event_listener::ChannelEventListener;
use crate::terminal::model::ansi::{Handler, InitShellValue, PromptMetadata, SSHValue};
use crate::terminal::model::session::Session;
use crate::terminal::model::test_utils::block_size;
use crate::terminal::model::{session::Session, TerminalModel};
use crate::terminal::{
color::{self, Colors},
event_listener::ChannelEventListener,
};
use galaxyui::r#async::executor::Background;
use std::{path::PathBuf, sync::Arc};
use crate::terminal::model::TerminalModel;
fn terminal_with_startup_path(startup_path: Option<&str>) -> TerminalModel {
TerminalModel::new_for_test(
@@ -76,9 +78,9 @@ fn display_working_directory_abbreviates_subdirectory_under_home() {
#[test]
fn cwd_for_recent_conversations_prefers_active_block_pwd() {
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
terminal.precmd(PrecmdValue {
terminal.prompt_only_precmd(PromptMetadata {
pwd: Some("/active/path".to_owned()),
session_id: Some(0),
session_id: Some(123),
..Default::default()
});
@@ -96,7 +98,12 @@ fn cwd_for_recent_conversations_uses_startup_path_before_bootstrap_for_local_ses
#[test]
fn cwd_for_recent_conversations_does_not_use_startup_path_for_pending_ssh_bootstrap() {
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
terminal.ssh(SSHValue::default());
terminal.ssh(SSHValue {
session_id: Some(123),
remote_session_id: Some(456),
..Default::default()
});
let cwd = current_working_directory_for_zero_state(&terminal);
assert_eq!(cwd, None);
}