Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::terminal::model::session::Session;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WarpAiOsContext {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub distribution: Option<String>,
}
/// The execution context of the active session. This struct
/// is sent as a JSON blob in our AI prompts.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WarpAiExecutionContext {
pub os: WarpAiOsContext,
pub shell_name: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub shell_version: Option<String>,
}
impl WarpAiExecutionContext {
pub fn new(session: &Arc<Session>) -> Self {
WarpAiExecutionContext {
os: WarpAiOsContext {
category: session.host_info().os_category.clone(),
distribution: session.host_info().linux_distribution.clone(),
},
shell_name: session.shell().shell_type().name().to_owned(),
shell_version: session.shell().version().clone(),
}
}
}
impl WarpAiExecutionContext {
pub fn to_json_string(&self) -> Option<String> {
serde_json::to_string(self).ok()
}
}
+179
View File
@@ -0,0 +1,179 @@
//! AI Assistant has since been renamed to "Warp AI" in the product.
use std::{collections::HashSet, sync::Arc};
use crate::{
ai::{RequestLimitInfo, RequestLimitRefreshDuration},
server::telemetry::OpenedWarpAISource,
terminal::model::terminal_model::BlockIndex,
workflows::workflow::{Argument, Workflow},
};
use itertools::Itertools;
use lazy_static::lazy_static;
use pathfinder_color::ColorU;
use serde::{Deserialize, Serialize};
use warp_core::command::ExitCode;
use warp_graphql::{
ai::{
RequestLimitInfo as RequestLimitInfoGraphql,
RequestLimitRefreshDuration as RequestLimitRefreshDurationGraphql,
},
mutations::generate_commands::{GenerateCommandsFailureType, GeneratedCommand},
};
pub mod execution_context;
pub mod panel;
pub mod requests;
pub mod transcript;
pub mod utils;
#[cfg(test)]
mod test_util;
/// We want to make sure the user doesn't send a prompt too large.s
/// Since a token is ~ 4 chars, the limit we impose here is 250 tokens.
/// This is also roughly the limit at which the editor starts degrading.
pub const PROMPT_CHARACTER_LIMIT: usize = 1000;
pub const AI_ASSISTANT_FEATURE_NAME: &str = "Warp AI";
pub const ASK_AI_ASSISTANT_TEXT: &str = "Ask Warp AI";
pub const AI_ASSISTANT_SVG_PATH: &str = "bundled/svg/ai-assistant.svg";
lazy_static! {
pub static ref AI_ASSISTANT_LOGO_COLOR: ColorU = ColorU::new(243, 185, 17, 255);
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AskAIType {
/// Covers all possible origins of text selection, including the block list terminal,
/// the alt-screen terminal, and the input area. Not all instances will require
/// `populate_input_box`, which determines whether we should automatically render
/// something like "Explain the following" within the user's input box.
FromTextSelection {
text: Arc<String>,
populate_input_box: bool,
},
/// Data about a block to inform Agent Mode.
FromBlock {
input: Arc<String>,
output: Arc<String>,
exit_code: ExitCode,
block_index: BlockIndex,
},
/// Which blocks to attach to a block list AI query.
FromBlocks {
block_indices: HashSet<BlockIndex>,
},
FromAICommandSearch {
query: Arc<String>,
},
}
impl From<&AskAIType> for OpenedWarpAISource {
fn from(value: &AskAIType) -> Self {
match value {
AskAIType::FromAICommandSearch { .. } => OpenedWarpAISource::FromAICommandSearch,
AskAIType::FromBlock { .. } | AskAIType::FromBlocks { .. } => {
OpenedWarpAISource::HelpWithBlock
}
AskAIType::FromTextSelection { .. } => OpenedWarpAISource::HelpWithTextSelection,
}
}
}
pub struct AIGeneratedCommand {
command: String,
description: String,
parameters: Vec<AIGeneratedCommandParameter>,
}
pub struct AIGeneratedCommandParameter {
id: String,
description: String,
}
impl From<AIGeneratedCommand> for Workflow {
fn from(ai_command: AIGeneratedCommand) -> Self {
// Note that we use the AI generated description as the _title_ of the workflow.
Workflow::new(ai_command.description, ai_command.command).with_arguments(
ai_command
.parameters
.into_iter()
.map(|p| Argument {
name: p.id,
description: Some(p.description),
default_value: None,
arg_type: Default::default(),
})
.collect_vec(),
)
}
}
impl From<GeneratedCommand> for AIGeneratedCommand {
fn from(value: GeneratedCommand) -> Self {
AIGeneratedCommand {
command: value.command,
description: value.description,
parameters: value
.parameters
.into_iter()
.map(|p| AIGeneratedCommandParameter {
id: p.id,
description: p.description,
})
.collect_vec(),
}
}
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
pub enum GenerateCommandsFromNaturalLanguageError {
BadPrompt,
AiProviderError,
RateLimited,
Other,
}
impl From<GenerateCommandsFailureType> for GenerateCommandsFromNaturalLanguageError {
fn from(value: GenerateCommandsFailureType) -> Self {
match value {
GenerateCommandsFailureType::BadPrompt => Self::BadPrompt,
GenerateCommandsFailureType::AiProviderError => Self::AiProviderError,
GenerateCommandsFailureType::RateLimited => Self::RateLimited,
GenerateCommandsFailureType::Other => Self::Other,
}
}
}
impl From<RequestLimitRefreshDurationGraphql> for RequestLimitRefreshDuration {
fn from(value: RequestLimitRefreshDurationGraphql) -> Self {
match value {
RequestLimitRefreshDurationGraphql::Monthly => RequestLimitRefreshDuration::Monthly,
RequestLimitRefreshDurationGraphql::Weekly => RequestLimitRefreshDuration::Weekly,
RequestLimitRefreshDurationGraphql::EveryTwoWeeks => {
RequestLimitRefreshDuration::EveryTwoWeeks
}
}
}
}
impl From<RequestLimitInfoGraphql> for RequestLimitInfo {
fn from(value: RequestLimitInfoGraphql) -> Self {
RequestLimitInfo {
is_unlimited: value.is_unlimited,
limit: value.request_limit as usize,
num_requests_used_since_refresh: value.requests_used_since_last_refresh as usize,
next_refresh_time: value.next_refresh_time,
request_limit_refresh_duration: value.request_limit_refresh_duration.into(),
is_unlimited_voice: value.is_unlimited_voice,
voice_request_limit: value.voice_request_limit as usize,
voice_requests_used_since_last_refresh: value.voice_requests_used_since_last_refresh
as usize,
is_unlimited_codebase_indices: value.is_unlimited_codebase_indices,
max_codebase_indices: value.max_codebase_indices as usize,
max_files_per_repo: value.max_files_per_repo as usize,
embedding_generation_batch_size: value.embedding_generation_batch_size as usize,
}
}
}
File diff suppressed because it is too large Load Diff
+438
View File
@@ -0,0 +1,438 @@
// TODO(roland): Delete all of this once agent mode fully replaces the AI assistant panel.
// app/src/ai/request_usage_model duplicates much of this logic.
use std::sync::Arc;
use chrono::{OutOfRangeError, Utc};
use futures::stream::AbortHandle;
use warp_core::user_preferences::GetUserPreferences as _;
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
use crate::{
ai::{RequestLimitInfo, RequestUsageInfo},
ai_assistant::utils::{AssistantTranscriptPart, TranscriptPartSubType},
auth::AuthStateProvider,
send_telemetry_from_ctx,
server::{
server_api::{ai::AIClient, ServerApi},
telemetry::{TelemetryEvent, WarpAIRequestResult},
},
workspaces::user_workspaces::UserWorkspaces,
};
use super::{
execution_context::WarpAiExecutionContext,
utils::{markdown_segments_from_text, FormattedTranscriptMessage, TranscriptPart},
};
use anyhow::Result;
/// The key for the corresponding entry in UserDefaults.
/// Not wiring through Settings for now since this data is only needed by the panel view.
pub const REQUEST_LIMIT_INFO_CACHE_KEY: &str = "AIAssistantRequestLimitInfo";
/// Tracks the current request status for making Warp AI requests against server.
pub enum RequestStatus {
/// There isn't a request in flight right now.
NotInFlight,
/// There's currently a request in flight.
InFlight {
/// The request itself (i.e. the prompt).
request: FormattedTranscriptMessage,
/// A handle to abort the request if desired.
abort_handle: AbortHandle,
},
}
fn cache_request_limit_info(request_limit_info: RequestLimitInfo, app_mut: &mut AppContext) {
if let Ok(serialized) = serde_json::to_string(&request_limit_info) {
let _ = app_mut
.private_user_preferences()
.write_value(REQUEST_LIMIT_INFO_CACHE_KEY, serialized);
}
}
fn get_cached_request_limit_info(app_mut: &mut AppContext) -> Option<RequestLimitInfo> {
app_mut
.private_user_preferences()
.read_value(REQUEST_LIMIT_INFO_CACHE_KEY)
.unwrap_or_default()
.and_then(|serialized| serde_json::from_str(serialized.as_str()).ok())
}
#[derive(Debug, Clone)]
pub enum GenerateDialogueResult {
Success {
answer: String,
truncated: bool,
request_limit_info: RequestLimitInfo,
transcript_summarized: bool,
},
Failure {
request_limit_info: RequestLimitInfo,
},
}
pub struct Requests {
server_api: Arc<ServerApi>,
ai_client: Arc<dyn AIClient>,
request_status: RequestStatus,
request_limit_info: RequestLimitInfo,
/// The currently displayed transcript.
current_transcript: Vec<TranscriptPart>,
/// Has the server summarized the current transcript because it's running long?
current_transcript_summarized: bool,
/// When a user Restarts their transcript, we still remember
/// the previous transcript parts for things like suggestions.
/// This list is mutually exclusive from current_transcript.
old_transcript_parts: Vec<TranscriptPart>,
ai_execution_context: Option<WarpAiExecutionContext>,
}
impl Entity for Requests {
type Event = Event;
}
pub enum Event {
RequestFinished { succeeded: bool },
}
/// Private interface.
impl Requests {
fn remaining_time_to_refresh_std(&self) -> Result<std::time::Duration, OutOfRangeError> {
self.request_limit_info
.next_refresh_time
.utc()
.signed_duration_since(Utc::now())
.to_std()
}
}
/// Public interface.
impl Requests {
pub fn new(
server_api: Arc<ServerApi>,
ai_client: Arc<dyn AIClient>,
ctx: &mut ModelContext<Self>,
) -> Self {
// Check if the user has cached request limit info from before.
// If not, let's just make an assumption about the server's default request limit
// and fetch the true request limit later.
let cached_request_limit_info = get_cached_request_limit_info(ctx);
let request_limit_info = cached_request_limit_info.unwrap_or_default();
let requests = Self {
server_api,
ai_client,
current_transcript: Vec::new(),
current_transcript_summarized: false,
old_transcript_parts: Vec::new(),
request_status: RequestStatus::NotInFlight,
request_limit_info,
ai_execution_context: None,
};
if cached_request_limit_info.is_none()
&& AuthStateProvider::as_ref(ctx).get().is_logged_in()
{
let ai_client = requests.ai_client.clone();
let _ = ctx.spawn(
async move { ai_client.get_request_limit_info().await },
Self::update_request_limit_info,
);
}
requests
}
pub fn update_ai_execution_context(
&mut self,
ai_execution_context: Option<WarpAiExecutionContext>,
) {
self.ai_execution_context = ai_execution_context;
}
pub fn update_request_limit_info(
&mut self,
result: Result<RequestUsageInfo>,
ctx: &mut ModelContext<Self>,
) {
match result {
Ok(usage_info) => {
self.request_limit_info = usage_info.request_limit_info;
ctx.notify();
cache_request_limit_info(usage_info.request_limit_info, ctx);
}
Err(e) => {
log::warn!("Failed to retrieve initial request limit info: {e:#}");
}
}
}
/// Starts a Warp AI request against the server with the given request prompt.
pub fn issue_request(&mut self, request: String, ctx: &mut ModelContext<Self>) {
let server_api = self.server_api.clone();
let raw_request = request.trim();
let request_for_api = raw_request.to_string();
let transcript = self.current_transcript.clone();
let transcript_part_index = transcript.len();
let ai_execution_context = self.ai_execution_context.clone();
let request_in_markdown = markdown_segments_from_text(
transcript_part_index,
TranscriptPartSubType::Question,
raw_request,
);
let future_handle = ctx.spawn(
async move {
let start_time = Utc::now();
(start_time, server_api
.generate_dialogue_answer(transcript, request_for_api, ai_execution_context)
.await)
},
move |model, (start_time, response), ctx| {
let succeeded = response.is_ok();
let end_time = Utc::now();
let mut current_request_status = RequestStatus::NotInFlight;
std::mem::swap(&mut model.request_status, &mut current_request_status);
if let RequestStatus::InFlight { request, .. } = current_request_status {
match response {
Ok(GenerateDialogueResult::Success {
mut answer,
truncated,
request_limit_info,
transcript_summarized,
}) => {
if truncated {
answer.push_str("...");
}
let trimmed_response = answer.trim();
let response_in_markdown = markdown_segments_from_text(
transcript_part_index,
TranscriptPartSubType::Answer,
trimmed_response,
);
model.current_transcript.push(TranscriptPart {
user: request,
assistant: AssistantTranscriptPart {
is_error: false,
copy_all_tooltip_and_button_mouse_handles: Some((Default::default(), Default::default())),
formatted_message: FormattedTranscriptMessage {
markdown: response_in_markdown,
raw: trimmed_response.to_string(),
},
},
});
cache_request_limit_info(request_limit_info, ctx);
model.request_limit_info = request_limit_info;
// If the transcript was already marked as summarized before,
// it will remain so until it's reset.
model.current_transcript_summarized |= transcript_summarized;
let req_latency = end_time.signed_duration_since(start_time).num_milliseconds();
send_telemetry_from_ctx!(
TelemetryEvent::WarpAIRequestIssued { result: WarpAIRequestResult::Succeeded { latency_ms: req_latency, truncated }},
ctx
);
}
Ok(GenerateDialogueResult::Failure { request_limit_info }) if request_limit_info.limit <= request_limit_info.num_requests_used_since_refresh => {
cache_request_limit_info(request_limit_info, ctx);
model.request_limit_info = request_limit_info;
let next_time = if let Some(next_refresh_time) = model.serialized_time_until_refresh() {
format!("after {next_refresh_time}")
} else {
String::from("later")
};
let auth_state = AuthStateProvider::as_ref(ctx).get();
let response = if let Some(team) = UserWorkspaces::as_ref(ctx).current_team() {
let current_user_email = auth_state.user_email().unwrap_or_default();
let has_admin_permissions = team.has_admin_permissions(&current_user_email);
if team.billing_metadata.can_upgrade_to_higher_tier_plan() {
if has_admin_permissions {
let upgrade_url = UserWorkspaces::upgrade_link_for_team(team.uid);
format!("It seems you're out of credits. Please try again {next_time}.\n\n[Upgrade]({upgrade_url}) for more credits.")
} else {
format!("It seems you're out of credits. Please try again {next_time}.\n\nContact a team admin to upgrade for more credits.")
}
} else {
format!("It seems you're out of credits. Please try again {next_time}.")
}
} else {
let user_id = auth_state.user_id().unwrap_or_default();
let upgrade_url = UserWorkspaces::upgrade_link(user_id);
format!("It seems you're out of credits. Please try again {next_time}.\n\n[Upgrade]({upgrade_url}) for more credits.")
};
let response_in_markdown = markdown_segments_from_text(
transcript_part_index,
TranscriptPartSubType::Answer,
&response,
);
model.current_transcript.push(TranscriptPart {
user: request,
assistant: AssistantTranscriptPart {
is_error: true,
copy_all_tooltip_and_button_mouse_handles: None,
formatted_message: FormattedTranscriptMessage {
markdown: response_in_markdown,
raw: response,
},
},
});
send_telemetry_from_ctx!(
TelemetryEvent::WarpAIRequestIssued { result: WarpAIRequestResult::OutOfRequests},
ctx
);
}
_ => {
let response = "We're experiencing technical difficulties right now. Please try again later.".to_owned();
let response_in_markdown = markdown_segments_from_text(
transcript_part_index,
TranscriptPartSubType::Answer,
&response,
);
model.current_transcript.push(TranscriptPart {
user: request,
assistant: AssistantTranscriptPart {
is_error: true,
copy_all_tooltip_and_button_mouse_handles: None,
formatted_message: FormattedTranscriptMessage {
markdown: response_in_markdown,
raw: response,
},
},
});
send_telemetry_from_ctx!(
TelemetryEvent::WarpAIRequestIssued { result: WarpAIRequestResult::Failed},
ctx
);
}
}
}
ctx.emit(Event::RequestFinished { succeeded });
ctx.notify();
},
);
self.request_status = RequestStatus::InFlight {
request: FormattedTranscriptMessage {
markdown: request_in_markdown,
raw: raw_request.to_string(),
},
abort_handle: future_handle.abort_handle(),
};
ctx.notify();
}
pub fn reset(&mut self, ctx: &mut ModelContext<Self>) {
if let RequestStatus::InFlight { abort_handle, .. } = &self.request_status {
abort_handle.abort();
}
let mut old_transcript = Vec::new();
std::mem::swap(&mut old_transcript, &mut self.current_transcript);
self.old_transcript_parts.extend(old_transcript);
self.request_status = RequestStatus::NotInFlight;
self.current_transcript_summarized = false;
ctx.notify();
}
pub fn transcript(&self) -> &[TranscriptPart] {
self.current_transcript.as_slice()
}
/// Includes the old transcript parts appended with the current
/// transcript parts. You likely want to just be using the current transcript parts
/// (exposed by the `Requests::transcript` API) in most use cases.
fn total_transcript_history(&self) -> impl Iterator<Item = &TranscriptPart> {
self.old_transcript_parts
.iter()
.chain(self.current_transcript.iter())
}
pub fn all_past_transcript_prompts(&self) -> Vec<String> {
self.total_transcript_history()
.map(|p| p.raw_user_prompt().to_string())
.collect()
}
pub fn request_status(&self) -> &RequestStatus {
&self.request_status
}
pub fn current_transcript_summarized(&self) -> bool {
self.current_transcript_summarized
}
/// Returns the number of remaining requests the user has based on their latest rate limit info.
/// If the current time is past the next refresh time, then the number of remaining reqs is the limit.
pub fn num_remaining_reqs(&self) -> usize {
match self.remaining_time_to_refresh_std() {
Err(_) => self.request_limit_info.limit,
Ok(t) if t.is_zero() => self.request_limit_info.limit,
Ok(_t) => {
self.request_limit_info.limit
- self.request_limit_info.num_requests_used_since_refresh
}
}
}
pub fn num_requests_used(&self) -> usize {
self.request_limit_info.limit - self.num_remaining_reqs()
}
pub fn request_limit(&self) -> usize {
self.request_limit_info.limit
}
/// Returns the next refresh time based on the latest rate limit info as a formatted string.
/// If the current time is past the next refresh time, then returns None.
pub fn serialized_time_until_refresh(&self) -> Option<String> {
match self.remaining_time_to_refresh_std() {
Err(_) => None,
Ok(t) if t.is_zero() => None,
Ok(t) => {
let num_minutes = t.as_secs() / 60;
let num_hours = num_minutes / 60;
let num_days = num_hours / 24;
let remaining_text = if num_days > 0 {
format!("{num_days} days")
} else if num_hours > 0 {
format!("{num_hours} hours")
} else {
format!("{num_minutes} minutes")
};
Some(remaining_text)
}
}
}
}
#[cfg(test)]
impl Requests {
pub fn new_with_transcript(transcript: Vec<TranscriptPart>) -> Self {
use crate::server::server_api::ServerApiProvider;
Self {
server_api: ServerApiProvider::new_for_test().get(),
ai_client: ServerApiProvider::new_for_test().get_ai_client(),
current_transcript: transcript,
current_transcript_summarized: false,
old_transcript_parts: Vec::new(),
request_status: RequestStatus::NotInFlight,
request_limit_info: RequestLimitInfo::default(),
ai_execution_context: None,
}
}
}
+39
View File
@@ -0,0 +1,39 @@
use crate::ai_assistant::utils::{
AssistantTranscriptPart, CodeBlockIndex, FormattedTranscriptMessage, MarkdownSegment,
};
use markdown_parser::{CodeBlockText, FormattedText};
pub fn default_code_block_segment(code_block_index: CodeBlockIndex) -> MarkdownSegment {
MarkdownSegment::CodeBlock {
index: code_block_index,
code: CodeBlockText {
lang: String::from(""),
code: String::from(""),
},
mouse_state_handles: Default::default(),
}
}
pub fn default_other_segment() -> MarkdownSegment {
MarkdownSegment::Other {
formatted_text: FormattedText::new(vec![]),
highlighted_hyperlink: Default::default(),
}
}
pub fn default_formatted_message(segments: Vec<MarkdownSegment>) -> FormattedTranscriptMessage {
FormattedTranscriptMessage {
markdown: Some(segments),
raw: String::from(""),
}
}
pub fn default_assistant_transcript_part(
formatted_transcript_message: FormattedTranscriptMessage,
) -> AssistantTranscriptPart {
AssistantTranscriptPart {
is_error: false,
formatted_message: formatted_transcript_message,
copy_all_tooltip_and_button_mouse_handles: None,
}
}
+979
View File
@@ -0,0 +1,979 @@
use markdown_parser::markdown_parser::RUNNABLE_BLOCK_MARKDOWN_LANG;
use markdown_parser::CodeBlockText;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use warp_core::ui::builder::AnimatedButtonOptions;
use warpui::clipboard::ClipboardContent;
use warpui::elements::{DispatchEventResult, Stack};
use warpui::units::Pixels;
use warpui::{
elements::{
Align, Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, EventHandler, Fill, Flex,
FormattedTextElement, HyperlinkUrl, Icon, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentAnchor, ParentElement, Radius, SavePosition, ScrollbarWidth,
Shrinkable, Text, Wrap,
},
keymap::Keystroke,
platform::Cursor,
ui_components::components::{UiComponent, UiComponentStyles},
units::IntoPixels,
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
WeakViewHandle,
};
use warpui::{BlurContext, FocusContext};
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{
appearance::Appearance,
send_telemetry_from_ctx,
server::telemetry::{SaveAsWorkflowModalSource, TelemetryEvent, WarpAIActionType},
ui_components::blended_colors,
};
use super::panel::HEADER_HEIGHT;
use super::{
panel::HEXAGON_ALERT_SVG_PATH,
requests::{RequestStatus, Requests},
utils::{
code_block_position_id, markdown_segments_from_text, render_prepared_response_button,
render_request_limit_info, save_as_workflow_position_id, AssistantTranscriptPart,
CodeBlockIndex, FormattedTranscriptMessage, MarkdownSegment, TranscriptPartSubType,
},
AI_ASSISTANT_SVG_PATH,
};
const TRANSCRIPT_POSITION_ID: &str = "ai_assistant::transcript";
const TERMINAL_INPUT_SVG_PATH: &str = "bundled/svg/terminal-input.svg";
const USER_ICON_SVG_PATH: &str = "bundled/svg/user.svg";
const SAVE_WORKFLOW_ICON_PATH: &str = "bundled/svg/workflow.svg";
const BODY_FONT_SIZE: f32 = 13.;
const CODE_FONT_SIZE: f32 = 12.;
const WARNING_MESSAGE_FONT_SIZE: f32 = 10.;
const PANEL_LEFT_MARGIN: f32 = 15.;
const DETAILS_BOTTOM_MARGIN: f32 = 12.;
const COPY_BUTTON_SIZE: f32 = 14.;
const TERMINAL_INPUT_BUTTON_SIZE: f32 = 20.;
const SAVE_AS_WORKFLOW_BUTTON_SIZE: f32 = 20.;
const HOW_DO_I_FIX_PROMPT: &str = "How do I fix this?";
const SHOW_EXAMPLES_PROMPT: &str = "Show examples.";
const WHAT_TO_DO_NEXT_PROMPT: &str = "What should I do next?";
const IN_FLIGHT_REQUEST_TEXT: &str = "Generating answer...";
const ACCURACY_NOTICE_TEXT: &str = "AI responses can be inaccurate.";
const MISSING_CONTEXT_NOTICE_TEXT: &str =
"Warp AI might forget earlier answers as conversations get long.";
lazy_static::lazy_static! {
static ref SCROLL_BUFFER_OFFSET_PX: Pixels = (10.).into_pixels();
}
#[derive(Debug, Clone, Default)]
pub struct CodeBlockMouseStateHandles {
pub play_button: MouseStateHandle,
pub play_button_tooltip: MouseStateHandle,
pub copy_button: MouseStateHandle,
pub copy_button_tooltip: MouseStateHandle,
pub save_as_workflow_button: MouseStateHandle,
pub save_as_workflow_button_tooltip: MouseStateHandle,
}
#[derive(Default)]
struct MouseStateHandles {
show_examples_button: MouseStateHandle,
what_to_do_next_button: MouseStateHandle,
how_do_i_fix_button: MouseStateHandle,
}
/// A view to render a Q/A style transcript.
pub struct Transcript {
view_handle: WeakViewHandle<Transcript>,
requests_model: ModelHandle<Requests>,
selected_code_block: Option<CodeBlockIndex>,
clipped_scroll_state: ClippedScrollStateHandle,
mouse_state_handles: MouseStateHandles,
}
#[derive(Debug, Clone)]
pub enum TranscriptAction {
CopyAnswerToClipboard {
transcript_part_index: usize,
},
CopyCodeToClipboard {
code_block_index: CodeBlockIndex,
},
PasteInTerminalInput {
code_block_index: CodeBlockIndex,
},
OpenWorkflowModal(CodeBlockIndex),
ClickedCodeBlock {
code_block_index: CodeBlockIndex,
},
ClickedUrl(HyperlinkUrl),
Keydown(Keystroke),
/// A mouse down event outside of the other clickable elements (e.g. buttons, code blocks, etc.)
MouseDown,
}
pub enum TranscriptEvent {
PasteInTerminalInput { code_block_index: CodeBlockIndex },
FocusEditor,
FocusTranscript,
ClickedCodeBlock,
OpenWorkflowModalWithCommand(String),
}
impl Entity for Transcript {
type Event = TranscriptEvent;
}
impl TypedActionView for Transcript {
type Action = TranscriptAction;
fn handle_action(&mut self, action: &TranscriptAction, ctx: &mut ViewContext<Self>) {
use TranscriptAction::*;
match action {
CopyAnswerToClipboard {
transcript_part_index,
} => {
let answer = self
.requests_model
.as_ref(ctx)
.transcript()
.get(*transcript_part_index)
.map(|p| p.assistant.formatted_message.raw.clone());
if let Some(answer) = answer {
ctx.clipboard().write(ClipboardContent::plain_text(answer));
}
send_telemetry_from_ctx!(
TelemetryEvent::WarpAIAction {
action_type: WarpAIActionType::CopyAnswer
},
ctx
);
}
CopyCodeToClipboard { code_block_index } => {
self.copy_code_to_clipboard(*code_block_index, ctx);
}
PasteInTerminalInput { code_block_index } => {
self.paste_in_terminal_input(*code_block_index, ctx);
}
OpenWorkflowModal(code_block_index) => self.open_workflow_modal(*code_block_index, ctx),
ClickedUrl(url) => {
ctx.open_url(&url.url);
}
ClickedCodeBlock { code_block_index } => {
self.selected_code_block = Some(*code_block_index);
ctx.emit(TranscriptEvent::ClickedCodeBlock);
ctx.notify();
}
Keydown(keystroke) => self.handle_keydown(keystroke, ctx),
MouseDown => {
if self.selected_code_block.is_none() {
ctx.emit(TranscriptEvent::FocusEditor);
} else {
ctx.emit(TranscriptEvent::FocusTranscript);
}
ctx.notify();
}
}
}
}
impl Transcript {
pub fn new(requests_model: &ModelHandle<Requests>, ctx: &mut ViewContext<Self>) -> Self {
ctx.observe(requests_model, |_, _, ctx| ctx.notify());
Self {
view_handle: ctx.handle(),
requests_model: requests_model.to_owned(),
selected_code_block: None,
clipped_scroll_state: Default::default(),
mouse_state_handles: Default::default(),
}
}
fn copy_code_to_clipboard(
&mut self,
code_block_index: CodeBlockIndex,
ctx: &mut ViewContext<Self>,
) {
if let Some(code) = self.code_for_index(code_block_index, ctx) {
ctx.clipboard().write(ClipboardContent::plain_text(code));
}
send_telemetry_from_ctx!(
TelemetryEvent::WarpAIAction {
action_type: WarpAIActionType::CopyCode
},
ctx
);
}
fn paste_in_terminal_input(
&mut self,
code_block_index: CodeBlockIndex,
ctx: &mut ViewContext<Self>,
) {
ctx.emit(TranscriptEvent::PasteInTerminalInput { code_block_index });
send_telemetry_from_ctx!(
TelemetryEvent::WarpAIAction {
action_type: WarpAIActionType::InsertIntoInput
},
ctx
);
}
fn open_workflow_modal(
&mut self,
code_block_index: CodeBlockIndex,
ctx: &mut ViewContext<Self>,
) {
if let Some(code) = self.code_for_index(code_block_index, ctx) {
ctx.emit(TranscriptEvent::OpenWorkflowModalWithCommand(code));
}
send_telemetry_from_ctx!(
TelemetryEvent::SaveAsWorkflowModal {
source: SaveAsWorkflowModalSource::WarpAIPanel
},
ctx
);
}
fn handle_keydown(&mut self, keystroke: &Keystroke, ctx: &mut ViewContext<Self>) {
let Some(selected_block_index) = self.selected_code_block else {
return;
};
if keystroke.key == "down" {
let new_index = self.next_code_block_index(ctx);
if new_index.is_some() {
self.selected_code_block = new_index;
} else if keystroke.cmd {
self.selected_code_block = None;
self.scroll_to_bottom_of_transcript(ctx);
ctx.emit(TranscriptEvent::FocusEditor);
}
ctx.notify();
} else if keystroke.key == "up" {
let new_index = self.previous_code_block_index(ctx);
if new_index.is_some() {
self.selected_code_block = new_index;
ctx.notify();
}
} else if keystroke.cmd && keystroke.key == "c" {
self.copy_code_to_clipboard(selected_block_index, ctx);
} else if keystroke.cmd && keystroke.key == "enter" {
self.paste_in_terminal_input(selected_block_index, ctx);
} else if keystroke.cmd && keystroke.key == "s" {
self.open_workflow_modal(selected_block_index, ctx);
} else if keystroke.key == "escape" {
self.selected_code_block = None;
ctx.emit(TranscriptEvent::FocusEditor);
ctx.notify();
}
// If we took an action on a code block or changed code blocks, let's scroll to it
// so the user knows what's going on.
if let Some(selected_code_block) = self.selected_code_block {
self.scroll_to_code_block(selected_code_block, ctx);
}
}
/// Only scrolls to the code block if it isn't already in the viewport.
fn scroll_to_code_block(
&mut self,
code_block_index: CodeBlockIndex,
ctx: &mut ViewContext<Self>,
) {
let Some(transcript_pos) = ctx.element_position_by_id(TRANSCRIPT_POSITION_ID) else {
return;
};
let Some(code_block_pos) =
ctx.element_position_by_id(code_block_position_id(code_block_index))
else {
return;
};
let current_scroll_top_px = self.clipped_scroll_state.scroll_start();
let viewable_transcript_height_px = transcript_pos.height().into_pixels();
let code_block_start_y_px =
code_block_pos.origin_y().into_pixels() - transcript_pos.origin_y().into_pixels();
let code_block_end_y_px =
code_block_pos.origin_y().into_pixels() + code_block_pos.height().into_pixels();
// We only need to scroll if either the start of the code block is cut off or the end is cut off.
if code_block_start_y_px < Pixels::zero()
|| code_block_end_y_px > viewable_transcript_height_px
{
// In the case that the new scroll top exceeds the max scroll top, the after_layout
// of clipped scrollable will re-adjust accordingly, so this is safe.
self.clipped_scroll_state.scroll_to(
current_scroll_top_px + code_block_start_y_px - *SCROLL_BUFFER_OFFSET_PX,
);
ctx.notify();
}
}
pub fn scroll_to_bottom_of_transcript(&mut self, ctx: &mut ViewContext<Self>) {
// This relies on the fact that the clipped scrollable will recompute
// the scroll_top in after_layout if it exceeds the true max.
self.clipped_scroll_state.scroll_to(f32::MAX.into_pixels());
ctx.notify();
}
fn previous_code_block_index(&self, ctx: &mut ViewContext<Self>) -> Option<CodeBlockIndex> {
let transcript = self.requests_model.as_ref(ctx).transcript();
let selected_code_block_index = self.selected_code_block?;
let transcript_index = selected_code_block_index.transcript_index();
// Try to find the prev code block in the current part.
let found = transcript
.get(transcript_index)
.and_then(|p| p.prev_code_block_index(selected_code_block_index));
// If it's not in the current part, then take the last code block from the closest
// transcript part to this one (in reverse).
found.or_else(|| {
transcript
.get(..transcript_index)?
.iter()
.rev()
.find_map(|part| part.last_code_block_index())
})
}
fn next_code_block_index(&self, ctx: &mut ViewContext<Self>) -> Option<CodeBlockIndex> {
let transcript = self.requests_model.as_ref(ctx).transcript();
let selected_code_block_index = self.selected_code_block?;
let transcript_index = selected_code_block_index.transcript_index();
// Try to find the next code block in the current part.
let found = transcript
.get(transcript_index)
.and_then(|p| p.next_code_block_index(selected_code_block_index));
// If it's not in the current part, then take the first code block from the closest
// transcript part to this one (in sequence).
found.or_else(|| {
transcript
.get(transcript_index + 1..)?
.iter()
.find_map(|part| part.first_code_block_index())
})
}
pub fn select_last_code_block(&mut self, ctx: &mut ViewContext<Self>) {
let transcript = self.requests_model.as_ref(ctx).transcript();
// The last code block will be the last code block in the first transcript part starting from the end.
let code_block_index = transcript
.iter()
.rev()
.find_map(|p| p.last_code_block_index());
self.selected_code_block = code_block_index;
if let Some(new_code_block) = self.selected_code_block {
self.scroll_to_code_block(new_code_block, ctx);
ctx.emit(TranscriptEvent::ClickedCodeBlock);
}
ctx.notify();
}
pub fn clear_selected_block(&mut self, ctx: &mut ViewContext<Self>) {
self.selected_code_block = None;
ctx.notify();
}
pub fn code_for_index(
&self,
code_block_index: CodeBlockIndex,
app: &AppContext,
) -> Option<String> {
let transcript = self.requests_model.as_ref(app).transcript();
transcript
.get(code_block_index.transcript_index())
.and_then(|p| p.code_for_block(code_block_index).map(ToOwned::to_owned))
}
pub fn reset(&mut self, ctx: &mut ViewContext<Self>) {
self.selected_code_block = None;
ctx.notify();
}
}
/// Rendering-related implementation.
impl Transcript {
fn render_code_block_actions(
&self,
code_block_index: CodeBlockIndex,
appearance: &Appearance,
code_block_info: &CodeBlockText,
mouse_state_handles: &CodeBlockMouseStateHandles,
) -> Box<dyn Element> {
let mut buttons = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::End)
.with_main_axis_size(MainAxisSize::Max);
let copy_button = appearance
.ui_builder()
.copy_button(COPY_BUTTON_SIZE, mouse_state_handles.copy_button.clone())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(TranscriptAction::CopyCodeToClipboard {
code_block_index,
});
})
.with_cursor(Cursor::PointingHand)
.finish();
buttons.add_child(appearance.ui_builder().tool_tip_on_element(
"Copy code to clipboard [Cmd + C]".to_string(),
mouse_state_handles.copy_button_tooltip.clone(),
copy_button,
ParentAnchor::TopRight,
ChildAnchor::BottomRight,
vec2f(0., -5.),
));
if code_block_info.lang.ends_with("sh")
|| code_block_info.lang == RUNNABLE_BLOCK_MARKDOWN_LANG
{
let insert_button = appearance
.ui_builder()
.animated_button(
mouse_state_handles.play_button.clone(),
TERMINAL_INPUT_SVG_PATH,
AnimatedButtonOptions {
size: TERMINAL_INPUT_BUTTON_SIZE,
padding: Some(4.),
color: None,
with_accent_animations: true,
circular: true,
},
)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(TranscriptAction::PasteInTerminalInput {
code_block_index,
});
})
.with_cursor(Cursor::PointingHand)
.finish();
buttons.add_child(
Container::new(appearance.ui_builder().tool_tip_on_element(
"Insert code into terminal input [Cmd + Enter]".to_string(),
mouse_state_handles.play_button_tooltip.clone(),
insert_button,
ParentAnchor::TopRight,
ChildAnchor::BottomRight,
vec2f(0., -5.),
))
.with_margin_left(10.)
.with_margin_bottom(-4.)
.finish(),
);
let save_as_workflow_button = appearance
.ui_builder()
.animated_button(
mouse_state_handles.save_as_workflow_button.clone(),
SAVE_WORKFLOW_ICON_PATH,
AnimatedButtonOptions {
size: SAVE_AS_WORKFLOW_BUTTON_SIZE,
padding: Some(4.),
color: None,
with_accent_animations: true,
circular: true,
},
)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(TranscriptAction::OpenWorkflowModal(code_block_index))
})
.with_cursor(Cursor::PointingHand)
.finish();
buttons.add_child(
SavePosition::new(
Container::new(appearance.ui_builder().tool_tip_on_element(
"Save as workflow [Cmd + S]".to_string(),
mouse_state_handles.save_as_workflow_button_tooltip.clone(),
save_as_workflow_button,
ParentAnchor::TopRight,
ChildAnchor::BottomRight,
vec2f(0., -5.),
))
.with_margin_left(2.)
.with_margin_bottom(-4.)
.finish(),
&save_as_workflow_position_id(code_block_index),
)
.finish(),
);
}
buttons.finish()
}
fn render_assistant_answer(
&self,
transcript_part_index: usize,
part: &AssistantTranscriptPart,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let background_color = theme.surface_2().into_solid();
let icon = if part.is_error {
ConstrainedBox::new(
Icon::new(HEXAGON_ALERT_SVG_PATH, appearance.theme().ui_error_color()).finish(),
)
.with_height(18.)
.with_width(18.)
.finish()
} else {
ConstrainedBox::new(
Icon::new(
AI_ASSISTANT_SVG_PATH,
theme.main_text_color(background_color.into()),
)
.finish(),
)
.with_height(16.)
.with_width(16.)
.finish()
};
let bottom_right_element = part.copy_all_tooltip_and_button_mouse_handles.clone().map(
|(tooltip_handle, button_handle)| {
let copy_button = appearance
.ui_builder()
.copy_button(16., button_handle)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(TranscriptAction::CopyAnswerToClipboard {
transcript_part_index,
})
})
.with_cursor(Cursor::PointingHand)
.finish();
appearance.ui_builder().tool_tip_on_element(
"Copy answer to clipboard".to_string(),
tooltip_handle,
copy_button,
ParentAnchor::TopRight,
ChildAnchor::BottomRight,
vec2f(0., -5.),
)
},
);
self.render_message(
&part.formatted_message,
background_color,
icon,
bottom_right_element,
appearance,
)
}
fn render_user_prompt(
&self,
dialogue: &FormattedTranscriptMessage,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let background_color = theme.surface_1().into_solid();
let icon = ConstrainedBox::new(
Icon::new(
USER_ICON_SVG_PATH,
theme.main_text_color(background_color.into()),
)
.finish(),
)
.with_height(16.)
.with_width(16.)
.finish();
self.render_message(dialogue, background_color, icon, None, appearance)
}
/// Renders a single message (whether that be a user's prompt or assistant's answer).
fn render_message(
&self,
dialogue: &FormattedTranscriptMessage,
background_color: ColorU,
icon: Box<dyn Element>,
bottom_right_element: Option<Box<dyn Element>>,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let inline_code_bg_color = appearance.theme().surface_3().into_solid();
let body = if let Some(parts) = &dialogue.markdown {
let mut column = Flex::column();
for part in parts {
let column_part = match part {
MarkdownSegment::Other {
formatted_text,
highlighted_hyperlink,
} => FormattedTextElement::new(
formatted_text.to_owned(),
BODY_FONT_SIZE,
appearance.ui_font_family(),
appearance.monospace_font_family(),
theme.main_text_color(theme.surface_2()).into_solid(),
highlighted_hyperlink.clone(),
)
.with_inline_code_properties(
Some(theme.nonactive_ui_text_color().into()),
Some(inline_code_bg_color),
)
.register_default_click_handlers(move |url, ctx, _| {
ctx.dispatch_typed_action(TranscriptAction::ClickedUrl(url));
})
.finish(),
MarkdownSegment::CodeBlock {
index,
code,
mouse_state_handles,
} => {
let actions = self.render_code_block_actions(
*index,
appearance,
code,
mouse_state_handles,
);
let code = code.code.clone();
let (border_fill, border_width, padding) =
if self.selected_code_block == Some(*index) {
(appearance.theme().accent(), 1.5, 11.5)
} else {
(appearance.theme().outline(), 1., 12.)
};
let code_block_index = *index;
EventHandler::new(
Container::new(
SavePosition::new(
Container::new(
Flex::column()
.with_child(
appearance
.ui_builder()
.wrappable_text(code, true)
.with_style(UiComponentStyles {
font_family_id: Some(
appearance.monospace_font_family(),
),
font_size: Some(CODE_FONT_SIZE),
..Default::default()
})
.build()
.with_margin_bottom(10.)
.finish(),
)
.with_child(actions)
.finish(),
)
.with_uniform_padding(padding)
.with_border(
Border::all(border_width).with_border_fill(border_fill),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.finish(),
&code_block_position_id(code_block_index),
)
.finish(),
)
.with_margin_top(10.)
.with_margin_bottom(10.)
.finish(),
)
.on_left_mouse_down(move |ctx, _, _| {
ctx.dispatch_typed_action(TranscriptAction::ClickedCodeBlock {
code_block_index,
});
DispatchEventResult::StopPropagation
})
.finish()
}
};
column.add_child(column_part);
}
column.finish()
} else {
// If we don't have the markdown representation, just render it as basic text.
appearance
.ui_builder()
.wrappable_text(dialogue.raw.to_owned(), true)
.with_style(UiComponentStyles {
font_size: Some(BODY_FONT_SIZE),
..Default::default()
})
.build()
.finish()
};
let mut final_col = Flex::column().with_child(body);
if let Some(bottom_right_element) = bottom_right_element {
final_col.add_child(
Align::new(
Container::new(bottom_right_element)
.with_margin_top(16.)
.finish(),
)
.right()
.finish(),
);
}
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Container::new(icon)
.with_margin_right(12.)
.with_margin_top(3.)
.finish(),
)
.with_child(Shrinkable::new(1., Container::new(final_col.finish()).finish()).finish());
Container::new(row.finish())
.with_background_color(background_color)
.with_padding_left(PANEL_LEFT_MARGIN)
.with_padding_top(16.)
.with_padding_bottom(16.)
.with_padding_right(20.)
.finish()
}
fn render_prepared_responses(&self, appearance: &Appearance) -> Box<dyn Element> {
Wrap::row()
.with_run_spacing(10.)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_child(render_prepared_response_button(
appearance,
self.mouse_state_handles.what_to_do_next_button.clone(),
None,
Some(8.),
WHAT_TO_DO_NEXT_PROMPT,
))
.with_child(
Container::new(render_prepared_response_button(
appearance,
self.mouse_state_handles.show_examples_button.clone(),
None,
Some(8.),
SHOW_EXAMPLES_PROMPT,
))
.with_margin_left(10.)
.with_margin_right(10.)
.finish(),
)
.with_child(render_prepared_response_button(
appearance,
self.mouse_state_handles.how_do_i_fix_button.clone(),
None,
Some(8.),
HOW_DO_I_FIX_PROMPT,
))
.finish()
}
fn render_warning_message(&self, message: String, appearance: &Appearance) -> Box<dyn Element> {
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_child(
Text::new_inline(
message,
appearance.ui_font_family(),
WARNING_MESSAGE_FONT_SIZE,
)
.with_color(blended_colors::text_sub(
appearance.theme(),
appearance.theme().background(),
))
.finish(),
)
.finish()
}
}
impl View for Transcript {
fn ui_name() -> &'static str {
"AIAssistantTranscript"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let transcript = self.requests_model.as_ref(app).transcript();
let request_status = self.requests_model.as_ref(app).request_status();
let num_remaining_reqs = self.requests_model.as_ref(app).num_remaining_reqs();
let mut blocks = Flex::column();
for (index, part) in transcript.iter().enumerate() {
blocks.add_child(self.render_user_prompt(&part.user, appearance));
blocks.add_child(self.render_assistant_answer(index, &part.assistant, appearance));
}
if let RequestStatus::InFlight { request, .. } = request_status {
blocks.add_child(self.render_user_prompt(request, appearance));
let transcript_part_index = transcript.len();
let in_flight_request_markdown = markdown_segments_from_text(
transcript_part_index,
TranscriptPartSubType::Answer,
IN_FLIGHT_REQUEST_TEXT,
);
blocks.add_child(self.render_assistant_answer(
transcript_part_index,
&AssistantTranscriptPart {
is_error: false,
copy_all_tooltip_and_button_mouse_handles: None,
formatted_message: FormattedTranscriptMessage {
markdown: in_flight_request_markdown,
raw: IN_FLIGHT_REQUEST_TEXT.to_owned(),
},
},
appearance,
));
}
if !transcript.is_empty() && matches!(request_status, RequestStatus::NotInFlight) {
// Only show the prepared responses if the last response wasn't an error
// and the user still has remaining requests.
if !transcript.last().is_none_or(|p| p.assistant.is_error) && num_remaining_reqs > 0 {
blocks.add_child(
Container::new(self.render_prepared_responses(appearance))
.with_margin_top(15.)
.finish(),
);
}
let is_custom_llm_enabled: bool = UserWorkspaces::as_ref(app)
.current_team()
.is_some_and(|team| team.is_custom_llm_enabled());
if !is_custom_llm_enabled {
blocks.add_child(
Container::new(render_request_limit_info(
&self.requests_model,
app,
appearance,
))
.with_margin_top(15.)
.finish(),
);
}
let current_transcript_summarized = self
.requests_model
.as_ref(app)
.current_transcript_summarized();
blocks.add_child(
Container::new(
self.render_warning_message(ACCURACY_NOTICE_TEXT.to_string(), appearance),
)
.with_margin_top(DETAILS_BOTTOM_MARGIN)
.with_margin_bottom(if current_transcript_summarized {
DETAILS_BOTTOM_MARGIN / 2.
} else {
DETAILS_BOTTOM_MARGIN
})
.finish(),
);
if current_transcript_summarized {
blocks.add_child(
Container::new(self.render_warning_message(
MISSING_CONTEXT_NOTICE_TEXT.to_string(),
appearance,
))
.with_margin_bottom(DETAILS_BOTTOM_MARGIN)
.finish(),
);
}
}
// Note: we don't render a scrollbar because the gutter makes the segmented transcript
// look "broken".
let transcript = SavePosition::new(
ClippedScrollable::vertical(
self.clipped_scroll_state.clone(),
blocks.finish(),
ScrollbarWidth::None,
theme.disabled_text_color(theme.background()).into(),
theme.main_text_color(theme.background()).into(),
Fill::None,
)
.with_padding_end(0.)
.with_padding_start(0.)
.finish(),
TRANSCRIPT_POSITION_ID,
)
.finish();
let mut navigatable_transcript =
EventHandler::new(transcript).on_left_mouse_down(|ctx, _, _| {
ctx.dispatch_typed_action(TranscriptAction::MouseDown);
DispatchEventResult::StopPropagation
});
// Only handle keydown events when a code block is selected and the transcript is focused.
let is_focused = self
.view_handle
.upgrade(app)
.is_some_and(|v| v.is_focused(app));
if self.selected_code_block.is_some() && is_focused {
navigatable_transcript = navigatable_transcript.on_keydown(|ctx, _, keystroke| {
ctx.dispatch_typed_action(TranscriptAction::Keydown(keystroke.to_owned()));
DispatchEventResult::StopPropagation
});
}
let mut stack = Stack::new();
stack.add_child(
Container::new(navigatable_transcript.finish())
.with_padding_top(HEADER_HEIGHT)
.finish(),
);
stack.finish()
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
// Force a re-render to reflect the fact that this view is now focused.
ctx.notify();
}
}
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
if blur_ctx.is_self_blurred() {
// Force a re-render to reflect the fact that this view is now blurred.
ctx.notify();
}
}
}
#[cfg(test)]
#[path = "transcript_tests.rs"]
mod transcript_tests;
+164
View File
@@ -0,0 +1,164 @@
use crate::{
appearance, test_util::settings::initialize_settings_for_tests,
workspaces::user_workspaces::UserWorkspaces,
};
use warpui::{platform::WindowStyle, App};
use crate::ai_assistant::{
requests::Requests,
test_util::{
default_assistant_transcript_part, default_code_block_segment, default_formatted_message,
default_other_segment,
},
utils::{CodeBlockIndex, TranscriptPart, TranscriptPartSubType},
};
use super::Transcript;
// Mocked data to make it easy to test.
lazy_static::lazy_static! {
static ref TRANSCRIPT: Vec<TranscriptPart> = vec![
TranscriptPart {
user: default_formatted_message(vec![
default_other_segment(),
default_code_block_segment(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 0)),
default_other_segment(),
default_code_block_segment(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 1)),
]),
assistant: default_assistant_transcript_part(default_formatted_message(vec![
default_code_block_segment(CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 0)),
])),
},
TranscriptPart {
user: default_formatted_message(vec![
default_code_block_segment(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 0)),
default_code_block_segment(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 1)),
]),
assistant: default_assistant_transcript_part(default_formatted_message(vec![
default_other_segment(),
default_other_segment(),
])),
},
];
}
fn initialize_app(app: &mut App) {
initialize_settings_for_tests(app);
appearance::register(app);
app.add_singleton_model(UserWorkspaces::default_mock);
}
#[test]
fn test_next_code_block() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let requests_model = app.add_model(|_| Requests::new_with_transcript(TRANSCRIPT.clone()));
let (_, transcript_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
Transcript::new(&requests_model, ctx)
});
transcript_view.update(&mut app, |view, ctx| {
// Starting point
view.selected_code_block =
Some(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 0));
let next_code_block = view.next_code_block_index(ctx);
assert_eq!(
next_code_block,
Some(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 1))
);
view.selected_code_block = next_code_block;
let next_code_block = view.next_code_block_index(ctx);
assert_eq!(
next_code_block,
Some(CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 0))
);
view.selected_code_block = next_code_block;
let next_code_block = view.next_code_block_index(ctx);
assert_eq!(
next_code_block,
Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 0))
);
view.selected_code_block = next_code_block;
let next_code_block = view.next_code_block_index(ctx);
assert_eq!(
next_code_block,
Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 1))
);
view.selected_code_block = next_code_block;
let next_code_block = view.next_code_block_index(ctx);
assert_eq!(next_code_block, None)
});
});
}
#[test]
fn test_prev_code_block() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let requests_model = app.add_model(|_| Requests::new_with_transcript(TRANSCRIPT.clone()));
let (_, transcript_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
Transcript::new(&requests_model, ctx)
});
transcript_view.update(&mut app, |view, ctx| {
// Starting point
view.selected_code_block =
Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 1));
let prev_code_block = view.previous_code_block_index(ctx);
assert_eq!(
prev_code_block,
Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 0))
);
view.selected_code_block = prev_code_block;
let prev_code_block = view.previous_code_block_index(ctx);
assert_eq!(
prev_code_block,
Some(CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 0))
);
view.selected_code_block = prev_code_block;
let prev_code_block = view.previous_code_block_index(ctx);
assert_eq!(
prev_code_block,
Some(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 1))
);
view.selected_code_block = prev_code_block;
let prev_code_block = view.previous_code_block_index(ctx);
assert_eq!(
prev_code_block,
Some(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 0))
);
view.selected_code_block = prev_code_block;
let prev_code_block = view.previous_code_block_index(ctx);
assert_eq!(prev_code_block, None);
});
});
}
#[test]
fn test_last_code_block() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let requests_model = app.add_model(|_| Requests::new_with_transcript(TRANSCRIPT.clone()));
let (_, transcript_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
Transcript::new(&requests_model, ctx)
});
transcript_view.update(&mut app, |view, ctx| {
view.select_last_code_block(ctx);
assert_eq!(
view.selected_code_block,
Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 1))
);
});
});
}
+479
View File
@@ -0,0 +1,479 @@
/// Common functionality used across different AI Assistant components.
use markdown_parser::{parse_markdown, CodeBlockText, FormattedText, FormattedTextLine};
use pathfinder_color::ColorU;
use warpui::{
elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, HighlightedHyperlink,
Icon, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Text,
},
platform::Cursor,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Element, ModelHandle,
};
use crate::{appearance::Appearance, ui_components::blended_colors};
use super::{panel::AIAssistantAction, requests::Requests, transcript::CodeBlockMouseStateHandles};
const PREPARED_RESPONSE_FONT_SIZE: f32 = 11.;
const REQUEST_LIMIT_INFO_FONT_SIZE: f32 = 11.;
const SQUARE_ALERT_SVG_PATH: &str = "bundled/svg/alert-square.svg";
const TRIANGLE_ALERT_SVG_PATH: &str = "bundled/svg/alert-triangle.svg";
/// A transcript part is a question and answer _pair_. This is to enforce
/// the invariant that every question has an answer.
#[derive(Clone)]
pub struct TranscriptPart {
pub user: FormattedTranscriptMessage,
pub assistant: AssistantTranscriptPart,
}
/// The assistant part of a transcript part.
#[derive(Clone)]
pub struct AssistantTranscriptPart {
pub is_error: bool,
pub formatted_message: FormattedTranscriptMessage,
pub copy_all_tooltip_and_button_mouse_handles: Option<(MouseStateHandle, MouseStateHandle)>,
}
/// The information needed to render a single transcript message (whether it be a question or answer).
#[derive(Clone)]
pub struct FormattedTranscriptMessage {
/// If we can't parse the message as markdown, we can still
/// use the `raw` field to display it. But we should try to render as markdown.
pub markdown: Option<Vec<MarkdownSegment>>,
pub raw: String,
}
impl FormattedTranscriptMessage {
/// Finds the index of the first code block in the message, if there is one.
fn first_code_block_index(&self) -> Option<CodeBlockIndex> {
let segments = self.markdown.as_ref()?;
segments.iter().find_map(|s| match s {
MarkdownSegment::CodeBlock { index, .. } => Some(*index),
_ => None,
})
}
/// Finds the index of the last code block in the message, if there is one.
fn last_code_block_index(&self) -> Option<CodeBlockIndex> {
let segments = self.markdown.as_ref()?;
segments.iter().rev().find_map(|s| match s {
MarkdownSegment::CodeBlock { index, .. } => Some(*index),
_ => None,
})
}
/// Finds the index of the next code block after `code_block_index` in the message, if there is one.
fn next_code_block_index(&self, code_block_index: usize) -> Option<CodeBlockIndex> {
let segments = self.markdown.as_ref()?;
segments.iter().find_map(|s| match s {
MarkdownSegment::CodeBlock { index, .. } => {
(index.code_block_index == code_block_index + 1).then_some(*index)
}
_ => None,
})
}
/// Finds the index of the previous code block before `code_block_index` in the message, if there is one.
fn prev_code_block_index(&self, code_block_index: usize) -> Option<CodeBlockIndex> {
if code_block_index == 0 {
return None;
}
let segments = self.markdown.as_ref()?;
segments.iter().find_map(|s| match s {
MarkdownSegment::CodeBlock { index, .. } => {
(index.code_block_index == code_block_index - 1).then_some(*index)
}
_ => None,
})
}
/// Returns the raw code block string for the given code block index.
fn code_for_block(&self, code_block_index: usize) -> Option<&str> {
let segments = self.markdown.as_ref()?;
segments.iter().find_map(|s| match s {
MarkdownSegment::CodeBlock { index, code, .. } => {
(index.code_block_index == code_block_index).then_some(code.code.as_str())
}
_ => None,
})
}
}
/// A MarkdownSegment differs from a FormattedText in that we intentionally
/// separate out certain markdown elements.
/// For now, only code blocks are rendered differently.
#[derive(Clone)]
pub enum MarkdownSegment {
CodeBlock {
index: CodeBlockIndex,
code: CodeBlockText,
mouse_state_handles: CodeBlockMouseStateHandles,
},
Other {
/// The formatted text does _not_ contain any of the other
/// MarkdownSegment's.
formatted_text: FormattedText,
highlighted_hyperlink: HighlightedHyperlink,
},
}
impl TranscriptPart {
pub fn raw_user_prompt(&self) -> &str {
self.user.raw.as_str()
}
pub fn raw_assistant_answer(&self) -> &str {
self.assistant.formatted_message.raw.as_str()
}
/// Returns the index of the first code block in this transcript part, if there is one.
pub fn first_code_block_index(&self) -> Option<CodeBlockIndex> {
self.user
.first_code_block_index()
.or_else(|| self.assistant.formatted_message.first_code_block_index())
}
/// Returns the index of the last code block in this transcript part, if there is one.
pub fn last_code_block_index(&self) -> Option<CodeBlockIndex> {
self.assistant
.formatted_message
.last_code_block_index()
.or_else(|| self.user.last_code_block_index())
}
/// Returns the index of the next code block after the given code block index in this transcript part, if there is one.
pub fn next_code_block_index(
&self,
code_block_index: CodeBlockIndex,
) -> Option<CodeBlockIndex> {
match code_block_index.transcript_part_type {
// Since a transcript part is question -> answer, check if there's a next code block in the question part,
// otherwise get the first code block in the answer part.
TranscriptPartSubType::Question => self
.user
.next_code_block_index(code_block_index.code_block_index)
.or_else(|| self.assistant.formatted_message.first_code_block_index()),
TranscriptPartSubType::Answer => self
.assistant
.formatted_message
.next_code_block_index(code_block_index.code_block_index),
}
}
/// Returns the index of the previous code block before the given code block index in this transcript part, if there is one.
pub fn prev_code_block_index(
&self,
code_block_index: CodeBlockIndex,
) -> Option<CodeBlockIndex> {
match code_block_index.transcript_part_type {
TranscriptPartSubType::Question => self
.user
.prev_code_block_index(code_block_index.code_block_index),
// Since a transcript part is question -> answer, check if there's a previous code block in the answer part,
// otherwise get the last code block from the question part.
TranscriptPartSubType::Answer => self
.assistant
.formatted_message
.prev_code_block_index(code_block_index.code_block_index)
.or_else(|| self.user.last_code_block_index()),
}
}
pub fn code_for_block(&self, code_block_index: CodeBlockIndex) -> Option<&str> {
match code_block_index.transcript_part_type {
TranscriptPartSubType::Question => {
self.user.code_for_block(code_block_index.code_block_index)
}
TranscriptPartSubType::Answer => self
.assistant
.formatted_message
.code_for_block(code_block_index.code_block_index),
}
}
}
/// Since a transcript part consists of two sub parts (question and answer),
/// this enum is used to identify which of the two we're referring to.
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum TranscriptPartSubType {
Question,
Answer,
}
impl TranscriptPartSubType {
fn as_str(&self) -> &'static str {
match self {
Self::Question => "question",
Self::Answer => "answer",
}
}
}
/// A CodeBlockIndex is used to uniquely identify a code block in a transcript.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct CodeBlockIndex {
/// The index into the `trancripts` list.
transcript_part_index: usize,
/// Since each transcript part consists of two sub-parts (question & answer),
/// we need to distinguish which of these sub-parts the code block is in.
transcript_part_type: TranscriptPartSubType,
/// A subpart can have > 1 code blocks, so this specifies the exact one.
code_block_index: usize,
}
impl CodeBlockIndex {
pub fn new(
transcript_part_index: usize,
transcript_part_type: TranscriptPartSubType,
code_block_index: usize,
) -> Self {
Self {
transcript_part_index,
transcript_part_type,
code_block_index,
}
}
pub fn as_id_str(&self) -> String {
format!(
"{}_{}_{}",
self.transcript_part_index,
self.transcript_part_type.as_str(),
self.code_block_index
)
}
pub fn transcript_index(&self) -> usize {
self.transcript_part_index
}
}
pub fn render_prepared_response_button(
appearance: &Appearance,
mouse_state_handle: MouseStateHandle,
width: Option<f32>,
right_left_padding: Option<f32>,
prompt: &'static str,
) -> Box<dyn Element> {
let theme = appearance.theme();
let default_button_styles = UiComponentStyles {
width,
font_size: Some(PREPARED_RESPONSE_FONT_SIZE),
font_family_id: Some(appearance.ui_font_family()),
font_color: Some(
appearance
.theme()
.main_text_color(appearance.theme().background())
.into(),
),
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
border_color: Some(theme.accent().into()),
border_width: Some(1.),
padding: Some(Coords {
top: 5.,
bottom: 5.,
left: right_left_padding.unwrap_or(0.),
right: right_left_padding.unwrap_or(0.),
}),
..Default::default()
};
let hovered_and_clicked_styles = UiComponentStyles {
background: Some(theme.accent().into()),
font_color: Some(theme.background().into()),
..default_button_styles
};
appearance
.ui_builder()
.button_with_custom_styles(
ButtonVariant::Text,
mouse_state_handle,
default_button_styles,
Some(hovered_and_clicked_styles),
Some(hovered_and_clicked_styles),
Some(hovered_and_clicked_styles),
)
.with_centered_text_label(prompt.to_string())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIAssistantAction::PreparedPrompt(prompt))
})
.finish()
}
pub fn render_request_limit_info(
request_model: &ModelHandle<Requests>,
app: &AppContext,
appearance: &Appearance,
) -> Box<dyn Element> {
let text_color: ColorU =
blended_colors::text_sub(appearance.theme(), appearance.theme().background());
let num_requests_used = request_model.as_ref(app).num_requests_used();
let num_requests_remaining = request_model.as_ref(app).num_remaining_reqs();
let request_limit = request_model.as_ref(app).request_limit();
let next_refresh_time = request_model.as_ref(app).serialized_time_until_refresh();
// Always show the remaining requests count.
let mut row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new_inline(
format!("Credits used: {num_requests_used} / {request_limit}.",),
appearance.ui_font_family(),
REQUEST_LIMIT_INFO_FONT_SIZE,
)
.with_color(text_color)
.finish(),
);
// Add the warning icon if necessary.
let icon = if num_requests_remaining == 0 {
Some(Icon::new(
TRIANGLE_ALERT_SVG_PATH,
appearance.theme().ui_error_color(),
))
} else if num_requests_remaining <= 10 {
Some(Icon::new(
SQUARE_ALERT_SVG_PATH,
appearance.theme().ui_warning_color(),
))
} else {
None
};
if let Some(icon) = icon {
row.add_child(
Container::new(
ConstrainedBox::new(icon.finish())
.with_height(16.)
.with_width(16.)
.finish(),
)
.with_margin_left(5.)
.finish(),
);
}
// Show the next refresh time if it's valid.
if let Some(next_refresh_time) = next_refresh_time {
row.add_child(
Container::new(
Text::new_inline(
format!("{next_refresh_time} until refresh."),
appearance.ui_font_family(),
REQUEST_LIMIT_INFO_FONT_SIZE,
)
.with_color(text_color)
.finish(),
)
.with_margin_left(5.)
.finish(),
);
}
row.finish()
}
pub fn code_block_position_id(code_block_index: CodeBlockIndex) -> String {
format!("code_block_id_{}", code_block_index.as_id_str(),)
}
pub fn save_as_workflow_position_id(code_block_index: CodeBlockIndex) -> String {
format!(
"{}_save_as_workflow",
code_block_position_id(code_block_index)
)
}
pub fn markdown_segments_from_text(
transcript_part_index: usize,
transcript_part_type: TranscriptPartSubType,
text: &str,
) -> Option<Vec<MarkdownSegment>> {
let parsed = parse_markdown(text).ok();
parsed.map(|p| {
translate_formatted_text_into_markdown_segments(
transcript_part_index,
transcript_part_type,
p,
)
})
}
fn translate_formatted_text_into_markdown_segments(
transcript_part_index: usize,
transcript_part_type: TranscriptPartSubType,
formatted_text: FormattedText,
) -> Vec<MarkdownSegment> {
// At a high-level, we want to go through the FormattedText and extract
// all the code-blocks separately from contiguous non-code blocks. We want
// to do this so that we can render the code-blocks specially. The final
// result is a set of markdown_segments.
let mut markdown_segments = vec![];
// The running non-code block is a contigous sequence of FormattedTextLine's
// that _do not_ contain any code blocks.
let mut running_non_code_block = vec![];
let mut curr_code_block_index = 0;
for part in formatted_text.lines {
match part {
FormattedTextLine::CodeBlock(mut code) => {
// If we found a code block, flush out the running non-code-block
// contiguous sequence into a single markdown segment.
if !running_non_code_block.is_empty() {
markdown_segments.push(MarkdownSegment::Other {
formatted_text: FormattedText::new_trimmed(running_non_code_block),
highlighted_hyperlink: Default::default(),
});
}
code.code = code.code.trim().to_string();
markdown_segments.push(MarkdownSegment::CodeBlock {
index: CodeBlockIndex::new(
transcript_part_index,
transcript_part_type,
curr_code_block_index,
),
code,
mouse_state_handles: Default::default(),
});
curr_code_block_index += 1;
running_non_code_block = vec![];
}
_ => {
// If this is anything other than a code block, tack it onto
// our running sequence.
running_non_code_block.push(part);
}
}
}
// If we had a non-code block sequence that we haven't flushed yet by the end,
// flush it now.
if !running_non_code_block.is_empty() {
markdown_segments.push(MarkdownSegment::Other {
formatted_text: FormattedText::new_trimmed(running_non_code_block),
highlighted_hyperlink: Default::default(),
});
}
markdown_segments
}
#[cfg(test)]
#[path = "utils_tests.rs"]
mod utils_tests;
+143
View File
@@ -0,0 +1,143 @@
use crate::ai_assistant::test_util::{
default_assistant_transcript_part, default_code_block_segment, default_formatted_message,
default_other_segment,
};
use super::{FormattedTranscriptMessage, TranscriptPart, TranscriptPartSubType};
use crate::ai_assistant::utils::CodeBlockIndex;
// Mocked data to make it easy to test.
lazy_static::lazy_static! {
static ref FIRST_USER_CODE_BLOCK_INDEX: CodeBlockIndex = CodeBlockIndex::new(0, TranscriptPartSubType::Question, 0);
static ref SECOND_USER_CODE_BLOCK_INDEX: CodeBlockIndex = CodeBlockIndex::new(0, TranscriptPartSubType::Question, 1);
static ref USER_FORMATTED_MESSAGE: FormattedTranscriptMessage = default_formatted_message(vec![
default_code_block_segment(*FIRST_USER_CODE_BLOCK_INDEX),
default_other_segment(),
default_code_block_segment(*SECOND_USER_CODE_BLOCK_INDEX),
default_other_segment(),
]);
static ref FIRST_ASSISTANT_CODE_BLOCK_INDEX: CodeBlockIndex = CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 0);
static ref SECOND_ASSISTANT_CODE_BLOCK_INDEX: CodeBlockIndex = CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 1);
static ref ASSISTANT_FORMATTED_MESSAGE: FormattedTranscriptMessage = default_formatted_message(vec![
default_other_segment(),
default_other_segment(),
default_code_block_segment(*FIRST_ASSISTANT_CODE_BLOCK_INDEX),
default_code_block_segment(*SECOND_ASSISTANT_CODE_BLOCK_INDEX),
]);
static ref TRANSCRIPT_PART: TranscriptPart =
TranscriptPart {
user: (*USER_FORMATTED_MESSAGE).clone(),
assistant: default_assistant_transcript_part((*ASSISTANT_FORMATTED_MESSAGE).clone())
};
}
#[test]
fn test_formatted_transcript_message_first_code_block() {
assert_eq!(
USER_FORMATTED_MESSAGE.first_code_block_index(),
Some(*FIRST_USER_CODE_BLOCK_INDEX)
);
assert_eq!(
ASSISTANT_FORMATTED_MESSAGE.first_code_block_index(),
Some(*FIRST_ASSISTANT_CODE_BLOCK_INDEX)
);
}
#[test]
fn test_formatted_transcript_message_last_code_block() {
assert_eq!(
USER_FORMATTED_MESSAGE.last_code_block_index(),
Some(*SECOND_USER_CODE_BLOCK_INDEX)
);
assert_eq!(
ASSISTANT_FORMATTED_MESSAGE.last_code_block_index(),
Some(*SECOND_ASSISTANT_CODE_BLOCK_INDEX)
);
}
#[test]
fn test_formatted_transcript_message_next_code_block() {
assert_eq!(
USER_FORMATTED_MESSAGE.next_code_block_index(0),
Some(*SECOND_USER_CODE_BLOCK_INDEX)
);
assert_eq!(USER_FORMATTED_MESSAGE.next_code_block_index(1), None);
assert_eq!(
ASSISTANT_FORMATTED_MESSAGE.next_code_block_index(0),
Some(*SECOND_ASSISTANT_CODE_BLOCK_INDEX)
);
assert_eq!(ASSISTANT_FORMATTED_MESSAGE.next_code_block_index(1), None);
}
#[test]
fn test_formatted_transcript_message_prev_code_block() {
assert_eq!(USER_FORMATTED_MESSAGE.prev_code_block_index(0), None);
assert_eq!(
USER_FORMATTED_MESSAGE.prev_code_block_index(1),
Some(*FIRST_USER_CODE_BLOCK_INDEX)
);
assert_eq!(ASSISTANT_FORMATTED_MESSAGE.prev_code_block_index(0), None);
assert_eq!(
ASSISTANT_FORMATTED_MESSAGE.prev_code_block_index(1),
Some(*FIRST_ASSISTANT_CODE_BLOCK_INDEX)
);
}
#[test]
fn test_transcript_part_first_code_block() {
assert_eq!(
TRANSCRIPT_PART.first_code_block_index(),
Some(*FIRST_USER_CODE_BLOCK_INDEX)
);
}
#[test]
fn test_transcript_part_last_code_block() {
assert_eq!(
TRANSCRIPT_PART.last_code_block_index(),
Some(*SECOND_ASSISTANT_CODE_BLOCK_INDEX)
);
}
#[test]
fn test_transcript_part_next_code_block() {
assert_eq!(
TRANSCRIPT_PART.next_code_block_index(*FIRST_USER_CODE_BLOCK_INDEX),
Some(*SECOND_USER_CODE_BLOCK_INDEX)
);
assert_eq!(
TRANSCRIPT_PART.next_code_block_index(*SECOND_USER_CODE_BLOCK_INDEX),
Some(*FIRST_ASSISTANT_CODE_BLOCK_INDEX)
);
assert_eq!(
TRANSCRIPT_PART.next_code_block_index(*FIRST_ASSISTANT_CODE_BLOCK_INDEX),
Some(*SECOND_ASSISTANT_CODE_BLOCK_INDEX)
);
assert_eq!(
TRANSCRIPT_PART.next_code_block_index(*SECOND_ASSISTANT_CODE_BLOCK_INDEX),
None
);
}
#[test]
fn test_transcript_part_prev_code_block() {
assert_eq!(
TRANSCRIPT_PART.prev_code_block_index(*FIRST_USER_CODE_BLOCK_INDEX),
None
);
assert_eq!(
TRANSCRIPT_PART.prev_code_block_index(*SECOND_USER_CODE_BLOCK_INDEX),
Some(*FIRST_USER_CODE_BLOCK_INDEX)
);
assert_eq!(
TRANSCRIPT_PART.prev_code_block_index(*FIRST_ASSISTANT_CODE_BLOCK_INDEX),
Some(*SECOND_USER_CODE_BLOCK_INDEX)
);
assert_eq!(
TRANSCRIPT_PART.prev_code_block_index(*SECOND_ASSISTANT_CODE_BLOCK_INDEX),
Some(*FIRST_ASSISTANT_CODE_BLOCK_INDEX)
);
}