Files
galaxy/app/src/ai_assistant/panel.rs
T

1192 lines
45 KiB
Rust

use std::sync::Arc;
use std::time::Duration;
use chrono::Local;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use warp_editor::editor::NavigationKey;
use warpui::clipboard::ClipboardContent;
use warpui::elements::{
resizable_state_handle, Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DispatchEventResult, DragBarSide, Empty, EventHandler, Fill, Flex,
HyperlinkUrl, Icon, MainAxisAlignment, MainAxisSize, OffsetPositioning, ParentAnchor,
PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, Shrinkable,
Stack, Text,
};
use warpui::fonts::Properties;
use warpui::keymap::{EditableBinding, FixedBinding};
use warpui::platform::Cursor;
use warpui::presenter::ChildView;
use warpui::r#async::Timer;
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::{elements::Element, AppContext, Entity, TypedActionView, View, ViewContext};
use warpui::{FocusContext, ModelHandle, SingletonEntity, ViewHandle};
use crate::appearance::Appearance;
use crate::editor::{
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, TextOptions,
};
use crate::input_suggestions::{Event as InputSuggestionsEvent, InputSuggestions};
use crate::send_telemetry_from_ctx;
use crate::server::server_api::ai::AIClient;
use crate::server::server_api::ServerApi;
use crate::server::telemetry::{TelemetryEvent, WarpAIActionType};
use crate::terminal::resizable_data::{ModalType, ResizableData, DEFAULT_WARP_AI_WIDTH};
use crate::ui_components::blended_colors;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::ui_components::buttons::icon_button;
use crate::workspace::{ActiveSession, TAB_BAR_HEIGHT};
use crate::util::bindings::{cmd_or_ctrl_shift, CustomAction};
use warpui::elements::MouseStateHandle;
use warpui::elements::ParentElement;
use warpui::elements::Resizable;
use warpui::elements::ResizableStateHandle;
use super::execution_context::WarpAiExecutionContext;
use super::requests::{Event as RequestsEvent, RequestStatus, Requests};
use super::transcript::{Transcript, TranscriptEvent};
use super::utils::{render_prepared_response_button, render_request_limit_info, TranscriptPart};
use super::{
AskAIType, AI_ASSISTANT_FEATURE_NAME, AI_ASSISTANT_LOGO_COLOR, AI_ASSISTANT_SVG_PATH,
ASK_AI_ASSISTANT_TEXT, PROMPT_CHARACTER_LIMIT,
};
const INFO_ICON_SVG_PATH: &str = "bundled/svg/info.svg";
pub const HEXAGON_ALERT_SVG_PATH: &str = "bundled/svg/alert-hexagon.svg";
const EDITOR_SAVE_POSITION_ID: &str = "ai_assistant::editor";
const MIN_PANEL_WIDTH: f32 = 300.;
const MIN_REMAINING_WINDOW_SIZE: f32 = 200.;
const MAX_EDITOR_HEIGHT: f32 = 300.;
const MAX_INPUT_SUGGESTIONS_HEIGHT: f32 = 200.;
pub(super) const HEADER_HEIGHT: f32 = TAB_BAR_HEIGHT;
pub(super) const HEADER_VERTICAL_PADDING: f32 = 5.;
const PANEL_HORIZONTAL_PADDING: f32 = 6.;
const EDITOR_MARGIN: f32 = 16.;
const LOGO_SIZE: f32 = 20.;
const BODY_FONT_SIZE: f32 = 13.;
const TITLE_FONT_SIZE: f32 = 16.;
const ZERO_STATE_HELP_TEXT_FONT_SIZE: f32 = 12.;
const ZERO_STATE_HELP_TEXT: &str = "Shift + ctrl + space a block or text selection to ask Warp AI.";
const SCRIPT_ZERO_STATE_PROMPT: &str = "Write a script to connect to an AWS EC2 instance.";
const GIT_ZERO_STATE_PROMPT: &str = "How do I undo the most recent commits in git?";
const FILES_ZERO_STATE_PROMPT: &str = "How do I find all files containing specific text?";
// The placeholder texts are prepended with a space to give them cushion from the cursor.
const INIT_PLACEHOLDER_TEXT: &str = " Ask a question...";
const FOLLOWUP_PLACEHOLDER_TEXT: &str = " Type a response or click one above...";
const RESTART_BUTTON_TEXT: &str = "Restart";
const ASK_AI_BLOCK_INPUT_LIMIT: usize = 100;
#[derive(Default)]
struct MouseStateHandles {
close_panel_state: MouseStateHandle,
reset_context_button: MouseStateHandle,
copy_transcript_button: MouseStateHandle,
script_zero_state_prompt: MouseStateHandle,
git_zero_state_prompt: MouseStateHandle,
files_zero_state_prompt: MouseStateHandle,
}
pub enum AIAssistantPanelEvent {
ClosePanel,
PasteInTerminalInput(Arc<String>),
FocusTerminalInput,
OpenWorkflowModalWithCommand(String),
}
/// Which child view is currently focused. It must be exactly one of these.
#[derive(Copy, Clone)]
enum PanelFocusState {
Editor,
Transcript,
}
enum InputSuggestionsMode {
Open { origin_buffer_text: String },
Closed,
}
/// The panel view is responsible for the various components that make up the panel
/// (e.g. header, transcript, editor).
/// TODO: we should eventually refactor this and other panels into a more
/// general Panel view.
pub struct AIAssistantPanelView {
editor: ViewHandle<EditorView>,
transcript_view: ViewHandle<Transcript>,
input_suggestions_view: ViewHandle<InputSuggestions>,
input_suggestions_mode: InputSuggestionsMode,
requests_model: ModelHandle<Requests>,
focus_state: PanelFocusState,
resizable_state_handle: ResizableStateHandle,
mouse_state_handles: MouseStateHandles,
}
#[derive(Debug, Clone)]
pub enum AIAssistantAction {
ClosePanel,
ResetContext,
CopyTranscript,
PreparedPrompt(&'static str),
ClickedUrl(HyperlinkUrl),
CopyAnswerToClipboard(Arc<String>),
FocusTerminalInput,
FocusEditor,
}
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::custom(
CustomAction::CloseCurrentSession,
AIAssistantAction::ClosePanel,
"Close Warp AI",
id!("AIAssistantPanel"),
)]);
app.register_editable_bindings([
EditableBinding::new(
"ai_assistant_panel:focus_terminal_input",
"Focus Terminal Input From Warp AI",
AIAssistantAction::FocusTerminalInput,
)
.with_context_predicate(id!("AIAssistantPanel"))
.with_key_binding(cmd_or_ctrl_shift("l")),
EditableBinding::new(
"ai_assistant_panel:reset_context",
"Restart Warp AI",
AIAssistantAction::ResetContext,
)
.with_context_predicate(id!("AIAssistantPanel"))
.with_key_binding("ctrl-l"),
EditableBinding::new(
"ai_assistant_panel:reset_context",
"Restart Warp AI",
AIAssistantAction::ResetContext,
)
.with_context_predicate(id!("AIAssistantPanel"))
.with_key_binding(cmd_or_ctrl_shift("k")),
]);
}
impl AIAssistantPanelView {
pub fn new(
server_api: Arc<ServerApi>,
ai_client: Arc<dyn AIClient>,
ctx: &mut ViewContext<Self>,
) -> Self {
let editor = {
ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let options = EditorOptions {
text: TextOptions::ui_text(Some(BODY_FONT_SIZE), appearance),
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
autogrow: true,
soft_wrap: true,
supports_vim_mode: true,
..Default::default()
};
EditorView::new(options, ctx)
})
};
editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(INIT_PLACEHOLDER_TEXT, ctx)
});
ctx.subscribe_to_view(&editor, |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
let active_session_model = ActiveSession::handle(ctx);
ctx.observe(&active_session_model, Self::on_active_session_change);
let requests_model =
ctx.add_model(|ctx| Requests::new(server_api.clone(), ai_client.clone(), ctx));
ctx.subscribe_to_model(&requests_model, move |me, _, event, ctx| {
me.handle_requests_model_event(event, ctx);
});
ctx.observe(&requests_model, |_, _, ctx| ctx.notify());
let transcript_view =
ctx.add_typed_action_view(|ctx| Transcript::new(&requests_model, ctx));
ctx.subscribe_to_view(&transcript_view, |me, _, event, ctx| {
me.handle_transcript_event(event, ctx);
});
let input_suggestions_view = ctx.add_typed_action_view(InputSuggestions::new);
ctx.subscribe_to_view(&input_suggestions_view, move |me, _, event, ctx| {
me.handle_input_suggestions_event(event, ctx);
});
let resizable_data_handle = ResizableData::handle(ctx);
let resizable_state_handle = match resizable_data_handle
.as_ref(ctx)
.get_handle(ctx.window_id(), ModalType::WarpAIWidth)
{
Some(handle) => handle,
None => {
log::error!("Couldn't retrieve warp ai resizable state handle.");
resizable_state_handle(DEFAULT_WARP_AI_WIDTH)
}
};
let mut panel = Self {
editor,
transcript_view,
input_suggestions_view,
input_suggestions_mode: InputSuggestionsMode::Closed,
requests_model,
focus_state: PanelFocusState::Editor,
resizable_state_handle,
mouse_state_handles: Default::default(),
};
panel.tick(ctx);
panel.on_active_session_change(active_session_model, ctx);
panel
}
fn on_active_session_change(
&mut self,
active_session_handle: ModelHandle<ActiveSession>,
ctx: &mut ViewContext<Self>,
) {
let window_id = ctx.window_id();
let ai_execution_context = active_session_handle
.as_ref(ctx)
.session(window_id)
.as_ref()
.map(WarpAiExecutionContext::new);
self.requests_model.update(ctx, |requests, _| {
requests.update_ai_execution_context(ai_execution_context);
});
}
// Every minute, we re-render to make the next refresh time tick.
fn tick(&self, ctx: &mut ViewContext<Self>) {
ctx.spawn(
async move { Timer::after(Duration::from_secs(60)).await },
|view, _, ctx| {
view.transcript_view.update(ctx, |_, ctx| {
ctx.notify();
});
ctx.notify();
view.tick(ctx);
},
);
}
fn format_as_code_block(&self, content: &str) -> String {
// Intentionally choose a language that won't be interpreted as a shell language
// i.e. (*sh)
format!("```warp\n{}\n```", content.trim())
}
// TODO: reconsider if we should be doing all the formatting in here as opposed
// to doing the formatting at source and passing down the prompt to render as is.
pub fn ask_ai(&mut self, ask_type: &AskAIType, ctx: &mut ViewContext<Self>) {
match ask_type {
AskAIType::FromTextSelection {
text,
populate_input_box,
} => {
if *populate_input_box {
let prefix = "Explain the following:\n";
let code_block_formatting_len = self.format_as_code_block("").len();
let truncated =
if text.chars().count() + prefix.len() + code_block_formatting_len
> PROMPT_CHARACTER_LIMIT
{
// Take the first k characters of the text selection, where k is the
// remaining length after we limit the prompt and add formatting to it.
let truncated: String = text
.chars()
// Take 3 for the ellipsis
.take(
PROMPT_CHARACTER_LIMIT
- prefix.len()
- code_block_formatting_len
- 3,
)
.collect();
format!("{truncated}...")
} else {
text.to_string()
};
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(
&format!(
"{}{}",
prefix,
self.format_as_code_block(truncated.as_str())
),
ctx,
);
});
ctx.notify();
}
}
AskAIType::FromBlock {
input,
output,
exit_code,
..
} => {
let block_successful = exit_code.was_successful();
// Formatting strings.
let question = if block_successful {
"\nWhat should I do next?"
} else {
"\nHow do I fix this?"
};
let prefix = "I ran the command: `";
let suffix = "` and got the following output:\n";
let code_block_formatting_len = self.format_as_code_block("").len();
let non_input_output_len =
prefix.len() + suffix.len() + question.len() + code_block_formatting_len;
let input_len = input.chars().count();
let output_len = output.chars().count();
// If the input and output are longer than can be and the input is particularly large, try to
// shave the input down to a fixed number of chars.
let truncated_input = if input_len + output_len + non_input_output_len
> PROMPT_CHARACTER_LIMIT
&& input_len > ASK_AI_BLOCK_INPUT_LIMIT
{
let truncated: String = input.chars().take(ASK_AI_BLOCK_INPUT_LIMIT).collect();
format!("{truncated}...")
} else {
input.to_string()
};
let truncated_input_len = truncated_input.chars().count();
// If the truncated input and raw output are still longer than
// the allowed size, trim down the output.
let truncated_output = if truncated_input_len + output_len + non_input_output_len
> PROMPT_CHARACTER_LIMIT
{
// Take the last k characters of the block's output, where k is the
// remaining length after we limit the prompt and add formatting to it.
// + 3 for the ellipsis.
let output_starting_index =
output_len + truncated_input_len + non_input_output_len + 3
- PROMPT_CHARACTER_LIMIT;
let truncated: String = output.chars().skip(output_starting_index).collect();
format!("...{truncated}")
} else {
output.to_string()
};
// Insert the truncated strings (with the formatting around them) into the editor.
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(
&format!(
"{prefix}{}{suffix}{}{question}",
truncated_input,
self.format_as_code_block(truncated_output.as_str())
),
ctx,
);
});
ctx.notify();
}
AskAIType::FromAICommandSearch { query } => {
let truncated = if query.chars().count() > PROMPT_CHARACTER_LIMIT {
// Reserve 3 for the ellpisis
let truncated: String =
query.chars().take(PROMPT_CHARACTER_LIMIT - 3).collect();
format!("{truncated}...")
} else {
query.to_string()
};
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(truncated.as_str(), ctx);
});
}
// Not supported by the AI Assistant. Only supported by blocklist AI.
AskAIType::FromBlocks { .. } => (),
}
send_telemetry_from_ctx!(
TelemetryEvent::OpenedWarpAI {
source: ask_type.into()
},
ctx
);
}
fn is_prompt_too_long(&self, prompt: &str) -> bool {
prompt.chars().count() > PROMPT_CHARACTER_LIMIT
}
fn is_prompt_empty(&self, prompt: &str) -> bool {
prompt.chars().count() == 0
}
fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Enter => {
self.input_suggestions_mode = InputSuggestionsMode::Closed;
let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx);
if !self.is_prompt_too_long(buffer_text.as_str())
&& !self.is_prompt_empty(buffer_text.as_str())
{
self.issue_request(buffer_text, ctx);
} else {
// Only send this event if the user tried to execute with a longer than permitted prompt.
send_telemetry_from_ctx!(TelemetryEvent::WarpAICharacterLimitExceeded, ctx);
}
ctx.notify();
}
EditorEvent::Edited(_) => {
// Force a re-render so we can show the character limit warning.
ctx.notify();
if let Some(selected_text) = self
.input_suggestions_view
.as_ref(ctx)
.get_selected_item_text()
{
if selected_text == self.editor.as_ref(ctx).buffer_text(ctx) {
return;
}
}
self.input_suggestions_mode = InputSuggestionsMode::Closed;
}
EditorEvent::CmdUpOnFirstRow => {
self.transcript_view.update(ctx, |transcript_view, ctx| {
transcript_view.select_last_code_block(ctx);
});
ctx.notify();
}
EditorEvent::Activate => {
self.focus_state = PanelFocusState::Editor;
ctx.focus_self();
}
EditorEvent::Escape => {
self.input_suggestions_view
.update(ctx, |input_suggestions, ctx| {
input_suggestions.exit(true, ctx);
});
ctx.notify();
}
EditorEvent::Navigate(NavigationKey::Up) => {
if matches!(self.input_suggestions_mode, InputSuggestionsMode::Closed) {
let editor = self.editor.as_ref(ctx);
if editor.single_cursor_on_first_row(ctx) {
let buffer_text = editor.buffer_text(ctx);
let all_past_prompts = self
.requests_model
.as_ref(ctx)
.all_past_transcript_prompts();
self.input_suggestions_view
.update(ctx, |input_suggestions, ctx| {
input_suggestions.fuzzy_substring_search(
buffer_text.clone(),
all_past_prompts,
ctx,
);
});
self.input_suggestions_mode = InputSuggestionsMode::Open {
origin_buffer_text: buffer_text,
};
} else {
self.editor.update(ctx, |editor, ctx| editor.move_up(ctx));
}
} else {
self.input_suggestions_view
.update(ctx, |input_suggestions, ctx| {
input_suggestions.select_prev(ctx);
});
}
ctx.notify();
}
EditorEvent::Navigate(NavigationKey::Down) => {
if matches!(
self.input_suggestions_mode,
InputSuggestionsMode::Open { .. }
) {
self.input_suggestions_view
.update(ctx, |input_suggestions, ctx| {
if input_suggestions.is_empty() {
input_suggestions.exit(true, ctx);
} else {
input_suggestions.select_next(ctx);
}
});
} else {
self.editor.update(ctx, |editor, ctx| editor.move_down(ctx));
}
ctx.notify();
}
_ => {}
}
}
fn handle_transcript_event(&mut self, event: &TranscriptEvent, ctx: &mut ViewContext<Self>) {
match event {
TranscriptEvent::PasteInTerminalInput { code_block_index } => {
let code = self.transcript_view.read(ctx, |transcript_view, ctx| {
transcript_view.code_for_index(*code_block_index, ctx)
});
if let Some(code) = code {
ctx.emit(AIAssistantPanelEvent::PasteInTerminalInput(Arc::new(code)));
}
}
TranscriptEvent::FocusEditor => {
self.focus_state = PanelFocusState::Editor;
ctx.focus_self();
}
TranscriptEvent::ClickedCodeBlock | TranscriptEvent::FocusTranscript => {
self.focus_state = PanelFocusState::Transcript;
ctx.focus_self();
}
TranscriptEvent::OpenWorkflowModalWithCommand(command) => {
ctx.emit(AIAssistantPanelEvent::OpenWorkflowModalWithCommand(
command.clone(),
));
}
}
}
fn handle_requests_model_event(&mut self, event: &RequestsEvent, ctx: &mut ViewContext<Self>) {
match event {
RequestsEvent::RequestFinished { .. } => {
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
editor.set_placeholder_text(FOLLOWUP_PLACEHOLDER_TEXT, ctx);
});
self.transcript_view.update(ctx, |transcript_view, ctx| {
transcript_view.scroll_to_bottom_of_transcript(ctx);
});
ctx.notify();
}
}
}
fn handle_input_suggestions_event(
&mut self,
event: &InputSuggestionsEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
InputSuggestionsEvent::CloseSuggestion { .. } => {
if let InputSuggestionsMode::Open { origin_buffer_text } =
&self.input_suggestions_mode
{
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(origin_buffer_text, ctx);
});
}
self.input_suggestions_mode = InputSuggestionsMode::Closed;
}
InputSuggestionsEvent::Select(item) => {
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(item.text(), ctx);
});
}
InputSuggestionsEvent::ConfirmSuggestion { suggestion, .. } => {
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(suggestion, ctx);
});
self.input_suggestions_mode = InputSuggestionsMode::Closed;
}
InputSuggestionsEvent::ConfirmAndExecuteSuggestion { suggestion, .. } => {
self.issue_request(suggestion.to_string(), ctx);
self.input_suggestions_mode = InputSuggestionsMode::Closed;
}
InputSuggestionsEvent::IgnoreItem { .. } => {
// not worth implementing; feature is hardly used
}
}
ctx.notify();
}
fn issue_request(&mut self, request: String, ctx: &mut ViewContext<Self>) {
self.requests_model.update(ctx, |requests_model, ctx| {
requests_model.issue_request(request, ctx);
});
self.transcript_view.update(ctx, |transcript_view, ctx| {
transcript_view.clear_selected_block(ctx);
transcript_view.scroll_to_bottom_of_transcript(ctx);
});
ctx.notify();
}
fn reset_context(&mut self, ctx: &mut ViewContext<Self>) {
let request_status = self.requests_model.as_ref(ctx).request_status();
if matches!(request_status, &RequestStatus::InFlight { .. }) {
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
});
}
self.editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(INIT_PLACEHOLDER_TEXT, ctx);
});
self.requests_model.update(ctx, |requests_model, ctx| {
requests_model.reset(ctx);
});
self.transcript_view.update(ctx, |transcript_view, ctx| {
transcript_view.reset(ctx);
});
self.focus_state = PanelFocusState::Editor;
ctx.focus_self();
ctx.notify();
}
fn copy_transcript(&mut self, ctx: &mut ViewContext<Self>) {
let transcript = self.transcript(ctx);
let mut result = String::new();
let time_now = Local::now();
result.push_str(&format!(
"## Warp AI Transcript ({})\n\n",
time_now.format("%x %l:%M %p")
));
for part in transcript {
result.push_str(&format!("Prompt: {}\n\n", part.raw_user_prompt().trim()));
result.push_str(&format!(
"Warp AI: {}\n\n",
part.raw_assistant_answer().trim()
));
}
ctx.clipboard()
.write(ClipboardContent::plain_text(result.trim().to_string()));
}
fn should_render_zero_state(&self, app: &AppContext) -> bool {
self.transcript(app).is_empty()
&& matches!(self.request_status(app), RequestStatus::NotInFlight)
}
fn transcript<'a>(&self, app: &'a AppContext) -> &'a [TranscriptPart] {
self.requests_model.as_ref(app).transcript()
}
fn request_status<'a>(&self, app: &'a AppContext) -> &'a RequestStatus {
self.requests_model.as_ref(app).request_status()
}
fn num_remaining_reqs(&self, app: &AppContext) -> usize {
self.requests_model.as_ref(app).num_remaining_reqs()
}
#[cfg(feature = "integration_tests")]
pub fn editor(&self) -> &ViewHandle<EditorView> {
&self.editor
}
}
/// All rendering related capabilities.
impl AIAssistantPanelView {
fn render_title_bar(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let mut header = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(
Icon::new(AI_ASSISTANT_SVG_PATH, *AI_ASSISTANT_LOGO_COLOR).finish(),
)
.with_height(LOGO_SIZE)
.with_width(LOGO_SIZE)
.finish(),
)
.with_padding_right(4.)
.finish(),
)
.with_child(
Container::new(
appearance
.ui_builder()
.wrappable_text(AI_ASSISTANT_FEATURE_NAME.to_string(), false)
.with_style(UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(TITLE_FONT_SIZE),
font_weight: Some(warpui::fonts::Weight::Semibold),
font_color: Some(appearance.theme().active_ui_text_color().into()),
..Default::default()
})
.build()
.finish(),
)
.finish(),
)
.with_child(Shrinkable::new(1., Empty::new().finish()).finish());
// Add the copy and restart buttons iff the transcript is non-empty or there's a request in flight;
if !self.transcript(app).is_empty()
|| matches!(self.request_status(app), RequestStatus::InFlight { .. })
{
header.add_child(
Container::new(Align::new(self.render_restart_button(appearance)).finish())
.with_margin_right(4.)
.finish(),
);
header.add_child(
Container::new(self.render_copy_transcript_button(appearance))
.with_margin_right(4.)
.finish(),
);
}
// Add the close button
header.add_child(
Container::new(
icon_button(
appearance,
crate::ui_components::icons::Icon::X,
false,
self.mouse_state_handles.close_panel_state.clone(),
)
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(AIAssistantAction::ClosePanel))
.with_cursor(Cursor::PointingHand)
.finish(),
)
.finish(),
);
header.finish()
}
fn render_copy_transcript_button(&self, appearance: &Appearance) -> Box<dyn Element> {
let tooltip_background = appearance.theme().surface_1().into_solid();
let ui_builder = appearance.ui_builder().clone();
icon_button(
appearance,
crate::ui_components::icons::Icon::Copy,
false,
self.mouse_state_handles.copy_transcript_button.clone(),
)
.with_tooltip(move || {
let tool_tip_style = UiComponentStyles {
background: Some(Fill::Solid(tooltip_background)),
..Default::default()
};
ui_builder
.tool_tip("Copy transcript to clipboard".to_owned())
.with_style(tool_tip_style)
.build()
.finish()
})
.build()
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(AIAssistantAction::CopyTranscript))
.with_cursor(Cursor::PointingHand)
.finish()
}
fn render_restart_button(&self, appearance: &Appearance) -> Box<dyn Element> {
let default_styles = UiComponentStyles {
border_width: None,
font_color: Some(appearance.theme().active_ui_text_color().into()),
font_size: Some(12.),
font_family_id: Some(appearance.ui_font_family()),
padding: Some(Coords {
top: 4.,
bottom: 4.,
left: 8.,
right: 8.,
}),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
..Default::default()
};
let hover_style = UiComponentStyles {
background: Some(appearance.theme().surface_3().into()),
..default_styles
};
appearance
.ui_builder()
.button_with_custom_styles(
ButtonVariant::Text,
self.mouse_state_handles.reset_context_button.clone(),
default_styles,
Some(hover_style),
Some(hover_style),
Some(hover_style),
)
.with_text_label(RESTART_BUTTON_TEXT.to_owned())
.build()
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(AIAssistantAction::ResetContext))
.with_cursor(Cursor::PointingHand)
.finish()
}
fn render_editor_size_warning(
&self,
appearance: &Appearance,
buffer_len: usize,
) -> Box<dyn Element> {
Flex::row()
.with_children([
Container::new(
Text::new_inline(
"Character limit exceeded.",
appearance.ui_font_family(),
BODY_FONT_SIZE,
)
.with_style(Properties {
weight: warpui::fonts::Weight::Bold,
..Default::default()
})
.with_color(appearance.theme().ui_error_color())
.finish(),
)
.with_margin_right(10.)
.finish(),
Text::new_inline(
format!("{buffer_len} / {PROMPT_CHARACTER_LIMIT}"),
appearance.ui_font_family(),
BODY_FONT_SIZE,
)
.with_color(appearance.theme().ui_error_color())
.finish(),
])
.finish()
}
fn render_editor(&self) -> Box<dyn Element> {
SavePosition::new(
ConstrainedBox::new(ChildView::new(&self.editor).finish())
.with_max_height(MAX_EDITOR_HEIGHT)
.finish(),
EDITOR_SAVE_POSITION_ID,
)
.finish()
}
fn render_input_suggestions_menu(&self, appearance: &Appearance) -> Box<dyn Element> {
ConstrainedBox::new(
Container::new(ChildView::new(&self.input_suggestions_view).finish())
.with_uniform_margin(10.)
.with_background(appearance.theme().surface_2())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_border(Border::all(1.0).with_border_fill(appearance.theme().outline()))
.finish(),
)
.with_max_height(MAX_INPUT_SUGGESTIONS_HEIGHT)
.finish()
}
fn render_zero_state(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let theme = appearance.theme();
// TODO: fill should be Copy. it's cheap (a few ColorU's at most).
let sub_text_color = blended_colors::text_sub(theme, theme.surface_2());
let thick_overlay_color = theme.surface_3();
let mut column = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
ConstrainedBox::new(Icon::new(AI_ASSISTANT_SVG_PATH, thick_overlay_color).finish())
.with_height(44.)
.with_width(44.)
.finish(),
)
.with_child(
Container::new(
Text::new_inline(ASK_AI_ASSISTANT_TEXT, appearance.ui_font_family(), 14.)
.with_color(sub_text_color)
.finish(),
)
.with_margin_top(8.)
.finish(),
);
if self.num_remaining_reqs(app) > 0 {
column.add_children([
Container::new(render_prepared_response_button(
appearance,
self.mouse_state_handles.git_zero_state_prompt.clone(),
Some(300.),
None,
GIT_ZERO_STATE_PROMPT,
))
.with_margin_top(20.)
.with_margin_bottom(10.)
.finish(),
Container::new(render_prepared_response_button(
appearance,
self.mouse_state_handles.files_zero_state_prompt.clone(),
Some(300.),
None,
FILES_ZERO_STATE_PROMPT,
))
.with_margin_bottom(10.)
.finish(),
Container::new(render_prepared_response_button(
appearance,
self.mouse_state_handles.script_zero_state_prompt.clone(),
Some(300.),
None,
SCRIPT_ZERO_STATE_PROMPT,
))
.finish(),
]);
}
column.add_child(
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(
Icon::new(INFO_ICON_SVG_PATH, theme.active_ui_text_color())
.finish(),
)
.with_height(15.)
.with_width(15.)
.finish(),
)
.with_margin_right(8.)
.finish(),
)
.with_child(
Shrinkable::new(
1.,
appearance
.ui_builder()
.wrappable_text(ZERO_STATE_HELP_TEXT.to_string(), true)
.with_style(UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(ZERO_STATE_HELP_TEXT_FONT_SIZE),
font_color: Some(theme.active_ui_text_color().into()),
..Default::default()
})
.build()
.finish(),
)
.finish(),
)
.finish(),
)
.with_margin_top(25.)
.finish(),
);
let is_custom_llm_enabled: bool = UserWorkspaces::as_ref(app)
.current_team()
.is_some_and(|team| team.is_custom_llm_enabled());
if !is_custom_llm_enabled {
column.add_child(
Container::new(render_request_limit_info(
&self.requests_model,
app,
appearance,
))
.with_margin_top(18.)
.finish(),
);
}
Container::new(column.finish())
.with_margin_left(12.)
.with_margin_right(12.)
.with_padding_top(50.)
.finish()
}
}
impl Entity for AIAssistantPanelView {
type Event = AIAssistantPanelEvent;
}
impl TypedActionView for AIAssistantPanelView {
type Action = AIAssistantAction;
fn handle_action(&mut self, action: &AIAssistantAction, ctx: &mut ViewContext<Self>) {
use AIAssistantAction::*;
match action {
ResetContext => {
self.reset_context(ctx);
send_telemetry_from_ctx!(
TelemetryEvent::WarpAIAction {
action_type: WarpAIActionType::Restart
},
ctx
);
}
CopyTranscript => {
self.copy_transcript(ctx);
send_telemetry_from_ctx!(
TelemetryEvent::WarpAIAction {
action_type: WarpAIActionType::CopyTranscript
},
ctx
);
}
ClosePanel => {
ctx.emit(AIAssistantPanelEvent::ClosePanel);
}
PreparedPrompt(prompt) => {
self.issue_request(prompt.to_string(), ctx);
send_telemetry_from_ctx!(TelemetryEvent::UsedWarpAIPreparedPrompt { prompt }, ctx);
}
ClickedUrl(url) => {
ctx.open_url(&url.url);
}
CopyAnswerToClipboard(content) => {
ctx.clipboard()
.write(ClipboardContent::plain_text(content.to_string()));
send_telemetry_from_ctx!(
TelemetryEvent::WarpAIAction {
action_type: WarpAIActionType::CopyAnswer
},
ctx
);
}
FocusTerminalInput => ctx.emit(AIAssistantPanelEvent::FocusTerminalInput),
FocusEditor => {
self.focus_state = PanelFocusState::Editor;
ctx.focus_self();
}
}
}
}
impl View for AIAssistantPanelView {
fn ui_name() -> &'static str {
"AIAssistantPanel"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
match &self.focus_state {
PanelFocusState::Editor => {
self.transcript_view.update(ctx, |transcript_view, ctx| {
transcript_view.clear_selected_block(ctx)
});
ctx.focus(&self.editor);
}
PanelFocusState::Transcript => ctx.focus(&self.transcript_view),
}
ctx.notify();
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let mut panel = Flex::column().with_main_axis_size(MainAxisSize::Max);
let should_render_zero_state = self.should_render_zero_state(app);
let body = if should_render_zero_state {
Align::new(self.render_zero_state(appearance, app)).finish()
} else {
Align::new(ChildView::new(&self.transcript_view).finish())
.top_center()
.finish()
};
panel.add_child(Shrinkable::new(1., body).finish());
if matches!(self.request_status(app), RequestStatus::NotInFlight) {
let buffer_text = self.editor.as_ref(app).buffer_text(app);
if self.is_prompt_too_long(buffer_text.as_str()) {
panel.add_child(
Container::new(
self.render_editor_size_warning(appearance, buffer_text.chars().count()),
)
.with_padding_left(PANEL_HORIZONTAL_PADDING)
.with_padding_bottom(5.)
.with_padding_top(10.)
.finish(),
);
}
panel.add_child(
Container::new(self.render_editor())
.with_uniform_padding(EDITOR_MARGIN)
.finish(),
);
}
let mut stack = Stack::new().with_child(panel.finish());
stack.add_positioned_overlay_child(
ConstrainedBox::new(
Container::new(self.render_title_bar(appearance, app))
.with_padding_top(HEADER_VERTICAL_PADDING)
.with_padding_bottom(HEADER_VERTICAL_PADDING)
.with_padding_left(PANEL_HORIZONTAL_PADDING)
.with_padding_right(PANEL_HORIZONTAL_PADDING)
.finish(),
)
.with_height(HEADER_HEIGHT)
.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., HEADER_HEIGHT),
warpui::elements::ParentOffsetBounds::Unbounded,
ParentAnchor::TopLeft,
ChildAnchor::BottomLeft,
),
);
if matches!(
self.input_suggestions_mode,
InputSuggestionsMode::Open { .. }
) {
stack.add_positioned_overlay_child(
self.render_input_suggestions_menu(appearance),
OffsetPositioning::offset_from_save_position_element(
EDITOR_SAVE_POSITION_ID,
Vector2F::new(0., -10.),
PositionedElementOffsetBounds::ParentByPosition,
PositionedElementAnchor::TopLeft,
ChildAnchor::BottomLeft,
),
);
}
let styled_panel = Container::new(stack.finish())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)));
let clickable_panel =
EventHandler::new(styled_panel.finish()).on_left_mouse_down(|ctx, _, _| {
ctx.dispatch_typed_action(AIAssistantAction::FocusEditor);
DispatchEventResult::StopPropagation
});
Resizable::new(
self.resizable_state_handle.clone(),
clickable_panel.finish(),
)
.on_resize(move |ctx, _| ctx.notify())
.with_dragbar_side(DragBarSide::Left)
.with_bounds_callback(Box::new(|window_bounds| {
(
MIN_PANEL_WIDTH,
(window_bounds.x() - MIN_REMAINING_WINDOW_SIZE).max(MIN_PANEL_WIDTH),
)
}))
.finish()
}
}