
This PR factors the language model selector out into its own `language_model_selector` crate so that it can be reused in `assistant2`. Also renamed it from `ModelSelector` to `LanguageModelSelector` to be a bit more specific. Release Notes: - N/A
1199 lines
43 KiB
Rust
1199 lines
43 KiB
Rust
use crate::assistant_settings::AssistantSettings;
|
|
use crate::{
|
|
humanize_token_count, prompts::PromptBuilder, AssistantPanel, AssistantPanelEvent, RequestType,
|
|
DEFAULT_CONTEXT_LINES,
|
|
};
|
|
use anyhow::{Context as _, Result};
|
|
use client::telemetry::Telemetry;
|
|
use collections::{HashMap, VecDeque};
|
|
use editor::{
|
|
actions::{MoveDown, MoveUp, SelectAll},
|
|
Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer,
|
|
};
|
|
use fs::Fs;
|
|
use futures::{channel::mpsc, SinkExt, StreamExt};
|
|
use gpui::{
|
|
AppContext, Context, EventEmitter, FocusHandle, FocusableView, Global, Model, ModelContext,
|
|
Subscription, Task, TextStyle, UpdateGlobal, View, WeakView,
|
|
};
|
|
use language::Buffer;
|
|
use language_model::{
|
|
LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
|
|
};
|
|
use language_model_selector::LanguageModelSelector;
|
|
use language_models::report_assistant_event;
|
|
use settings::{update_settings_file, Settings};
|
|
use std::{
|
|
cmp,
|
|
sync::Arc,
|
|
time::{Duration, Instant},
|
|
};
|
|
use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase};
|
|
use terminal::Terminal;
|
|
use terminal_view::TerminalView;
|
|
use theme::ThemeSettings;
|
|
use ui::{prelude::*, IconButtonShape, Tooltip};
|
|
use util::ResultExt;
|
|
use workspace::{notifications::NotificationId, Toast, Workspace};
|
|
|
|
pub fn init(
|
|
fs: Arc<dyn Fs>,
|
|
prompt_builder: Arc<PromptBuilder>,
|
|
telemetry: Arc<Telemetry>,
|
|
cx: &mut AppContext,
|
|
) {
|
|
cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder, telemetry));
|
|
}
|
|
|
|
const PROMPT_HISTORY_MAX_LEN: usize = 20;
|
|
|
|
#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)]
|
|
struct TerminalInlineAssistId(usize);
|
|
|
|
impl TerminalInlineAssistId {
|
|
fn post_inc(&mut self) -> TerminalInlineAssistId {
|
|
let id = *self;
|
|
self.0 += 1;
|
|
id
|
|
}
|
|
}
|
|
|
|
pub struct TerminalInlineAssistant {
|
|
next_assist_id: TerminalInlineAssistId,
|
|
assists: HashMap<TerminalInlineAssistId, TerminalInlineAssist>,
|
|
prompt_history: VecDeque<String>,
|
|
telemetry: Option<Arc<Telemetry>>,
|
|
fs: Arc<dyn Fs>,
|
|
prompt_builder: Arc<PromptBuilder>,
|
|
}
|
|
|
|
impl Global for TerminalInlineAssistant {}
|
|
|
|
impl TerminalInlineAssistant {
|
|
pub fn new(
|
|
fs: Arc<dyn Fs>,
|
|
prompt_builder: Arc<PromptBuilder>,
|
|
telemetry: Arc<Telemetry>,
|
|
) -> Self {
|
|
Self {
|
|
next_assist_id: TerminalInlineAssistId::default(),
|
|
assists: HashMap::default(),
|
|
prompt_history: VecDeque::default(),
|
|
telemetry: Some(telemetry),
|
|
fs,
|
|
prompt_builder,
|
|
}
|
|
}
|
|
|
|
pub fn assist(
|
|
&mut self,
|
|
terminal_view: &View<TerminalView>,
|
|
workspace: Option<WeakView<Workspace>>,
|
|
assistant_panel: Option<&View<AssistantPanel>>,
|
|
initial_prompt: Option<String>,
|
|
cx: &mut WindowContext,
|
|
) {
|
|
let terminal = terminal_view.read(cx).terminal().clone();
|
|
let assist_id = self.next_assist_id.post_inc();
|
|
let prompt_buffer =
|
|
cx.new_model(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx));
|
|
let prompt_buffer = cx.new_model(|cx| MultiBuffer::singleton(prompt_buffer, cx));
|
|
let codegen = cx.new_model(|_| Codegen::new(terminal, self.telemetry.clone()));
|
|
|
|
let prompt_editor = cx.new_view(|cx| {
|
|
PromptEditor::new(
|
|
assist_id,
|
|
self.prompt_history.clone(),
|
|
prompt_buffer.clone(),
|
|
codegen,
|
|
assistant_panel,
|
|
workspace.clone(),
|
|
self.fs.clone(),
|
|
cx,
|
|
)
|
|
});
|
|
let prompt_editor_render = prompt_editor.clone();
|
|
let block = terminal_view::BlockProperties {
|
|
height: 2,
|
|
render: Box::new(move |_| prompt_editor_render.clone().into_any_element()),
|
|
};
|
|
terminal_view.update(cx, |terminal_view, cx| {
|
|
terminal_view.set_block_below_cursor(block, cx);
|
|
});
|
|
|
|
let terminal_assistant = TerminalInlineAssist::new(
|
|
assist_id,
|
|
terminal_view,
|
|
assistant_panel.is_some(),
|
|
prompt_editor,
|
|
workspace.clone(),
|
|
cx,
|
|
);
|
|
|
|
self.assists.insert(assist_id, terminal_assistant);
|
|
|
|
self.focus_assist(assist_id, cx);
|
|
}
|
|
|
|
fn focus_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut WindowContext) {
|
|
let assist = &self.assists[&assist_id];
|
|
if let Some(prompt_editor) = assist.prompt_editor.as_ref() {
|
|
prompt_editor.update(cx, |this, cx| {
|
|
this.editor.update(cx, |editor, cx| {
|
|
editor.focus(cx);
|
|
editor.select_all(&SelectAll, cx);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
fn handle_prompt_editor_event(
|
|
&mut self,
|
|
prompt_editor: View<PromptEditor>,
|
|
event: &PromptEditorEvent,
|
|
cx: &mut WindowContext,
|
|
) {
|
|
let assist_id = prompt_editor.read(cx).id;
|
|
match event {
|
|
PromptEditorEvent::StartRequested => {
|
|
self.start_assist(assist_id, cx);
|
|
}
|
|
PromptEditorEvent::StopRequested => {
|
|
self.stop_assist(assist_id, cx);
|
|
}
|
|
PromptEditorEvent::ConfirmRequested { execute } => {
|
|
self.finish_assist(assist_id, false, *execute, cx);
|
|
}
|
|
PromptEditorEvent::CancelRequested => {
|
|
self.finish_assist(assist_id, true, false, cx);
|
|
}
|
|
PromptEditorEvent::DismissRequested => {
|
|
self.dismiss_assist(assist_id, cx);
|
|
}
|
|
PromptEditorEvent::Resized { height_in_lines } => {
|
|
self.insert_prompt_editor_into_terminal(assist_id, *height_in_lines, cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn start_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut WindowContext) {
|
|
let assist = if let Some(assist) = self.assists.get_mut(&assist_id) {
|
|
assist
|
|
} else {
|
|
return;
|
|
};
|
|
|
|
let Some(user_prompt) = assist
|
|
.prompt_editor
|
|
.as_ref()
|
|
.map(|editor| editor.read(cx).prompt(cx))
|
|
else {
|
|
return;
|
|
};
|
|
|
|
self.prompt_history.retain(|prompt| *prompt != user_prompt);
|
|
self.prompt_history.push_back(user_prompt.clone());
|
|
if self.prompt_history.len() > PROMPT_HISTORY_MAX_LEN {
|
|
self.prompt_history.pop_front();
|
|
}
|
|
|
|
assist
|
|
.terminal
|
|
.update(cx, |terminal, cx| {
|
|
terminal
|
|
.terminal()
|
|
.update(cx, |terminal, _| terminal.input(CLEAR_INPUT.to_string()));
|
|
})
|
|
.log_err();
|
|
|
|
let codegen = assist.codegen.clone();
|
|
let Some(request) = self.request_for_inline_assist(assist_id, cx).log_err() else {
|
|
return;
|
|
};
|
|
|
|
codegen.update(cx, |codegen, cx| codegen.start(request, cx));
|
|
}
|
|
|
|
fn stop_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut WindowContext) {
|
|
let assist = if let Some(assist) = self.assists.get_mut(&assist_id) {
|
|
assist
|
|
} else {
|
|
return;
|
|
};
|
|
|
|
assist.codegen.update(cx, |codegen, cx| codegen.stop(cx));
|
|
}
|
|
|
|
fn request_for_inline_assist(
|
|
&self,
|
|
assist_id: TerminalInlineAssistId,
|
|
cx: &mut WindowContext,
|
|
) -> Result<LanguageModelRequest> {
|
|
let assist = self.assists.get(&assist_id).context("invalid assist")?;
|
|
|
|
let shell = std::env::var("SHELL").ok();
|
|
let (latest_output, working_directory) = assist
|
|
.terminal
|
|
.update(cx, |terminal, cx| {
|
|
let terminal = terminal.model().read(cx);
|
|
let latest_output = terminal.last_n_non_empty_lines(DEFAULT_CONTEXT_LINES);
|
|
let working_directory = terminal
|
|
.working_directory()
|
|
.map(|path| path.to_string_lossy().to_string());
|
|
(latest_output, working_directory)
|
|
})
|
|
.ok()
|
|
.unwrap_or_default();
|
|
|
|
let context_request = if assist.include_context {
|
|
assist.workspace.as_ref().and_then(|workspace| {
|
|
let workspace = workspace.upgrade()?.read(cx);
|
|
let assistant_panel = workspace.panel::<AssistantPanel>(cx)?;
|
|
Some(
|
|
assistant_panel
|
|
.read(cx)
|
|
.active_context(cx)?
|
|
.read(cx)
|
|
.to_completion_request(RequestType::Chat, cx),
|
|
)
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let prompt = self.prompt_builder.generate_terminal_assistant_prompt(
|
|
&assist
|
|
.prompt_editor
|
|
.clone()
|
|
.context("invalid assist")?
|
|
.read(cx)
|
|
.prompt(cx),
|
|
shell.as_deref(),
|
|
working_directory.as_deref(),
|
|
&latest_output,
|
|
)?;
|
|
|
|
let mut messages = Vec::new();
|
|
if let Some(context_request) = context_request {
|
|
messages = context_request.messages;
|
|
}
|
|
|
|
messages.push(LanguageModelRequestMessage {
|
|
role: Role::User,
|
|
content: vec![prompt.into()],
|
|
cache: false,
|
|
});
|
|
|
|
Ok(LanguageModelRequest {
|
|
messages,
|
|
tools: Vec::new(),
|
|
stop: Vec::new(),
|
|
temperature: None,
|
|
})
|
|
}
|
|
|
|
fn finish_assist(
|
|
&mut self,
|
|
assist_id: TerminalInlineAssistId,
|
|
undo: bool,
|
|
execute: bool,
|
|
cx: &mut WindowContext,
|
|
) {
|
|
self.dismiss_assist(assist_id, cx);
|
|
|
|
if let Some(assist) = self.assists.remove(&assist_id) {
|
|
assist
|
|
.terminal
|
|
.update(cx, |this, cx| {
|
|
this.clear_block_below_cursor(cx);
|
|
this.focus_handle(cx).focus(cx);
|
|
})
|
|
.log_err();
|
|
|
|
if let Some(model) = LanguageModelRegistry::read_global(cx).active_model() {
|
|
let codegen = assist.codegen.read(cx);
|
|
let executor = cx.background_executor().clone();
|
|
report_assistant_event(
|
|
AssistantEvent {
|
|
conversation_id: None,
|
|
kind: AssistantKind::InlineTerminal,
|
|
message_id: codegen.message_id.clone(),
|
|
phase: if undo {
|
|
AssistantPhase::Rejected
|
|
} else {
|
|
AssistantPhase::Accepted
|
|
},
|
|
model: model.telemetry_id(),
|
|
model_provider: model.provider_id().to_string(),
|
|
response_latency: None,
|
|
error_message: None,
|
|
language_name: None,
|
|
},
|
|
codegen.telemetry.clone(),
|
|
cx.http_client(),
|
|
model.api_key(cx),
|
|
&executor,
|
|
);
|
|
}
|
|
|
|
assist.codegen.update(cx, |codegen, cx| {
|
|
if undo {
|
|
codegen.undo(cx);
|
|
} else if execute {
|
|
codegen.complete(cx);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn dismiss_assist(
|
|
&mut self,
|
|
assist_id: TerminalInlineAssistId,
|
|
cx: &mut WindowContext,
|
|
) -> bool {
|
|
let Some(assist) = self.assists.get_mut(&assist_id) else {
|
|
return false;
|
|
};
|
|
if assist.prompt_editor.is_none() {
|
|
return false;
|
|
}
|
|
assist.prompt_editor = None;
|
|
assist
|
|
.terminal
|
|
.update(cx, |this, cx| {
|
|
this.clear_block_below_cursor(cx);
|
|
this.focus_handle(cx).focus(cx);
|
|
})
|
|
.is_ok()
|
|
}
|
|
|
|
fn insert_prompt_editor_into_terminal(
|
|
&mut self,
|
|
assist_id: TerminalInlineAssistId,
|
|
height: u8,
|
|
cx: &mut WindowContext,
|
|
) {
|
|
if let Some(assist) = self.assists.get_mut(&assist_id) {
|
|
if let Some(prompt_editor) = assist.prompt_editor.as_ref().cloned() {
|
|
assist
|
|
.terminal
|
|
.update(cx, |terminal, cx| {
|
|
terminal.clear_block_below_cursor(cx);
|
|
let block = terminal_view::BlockProperties {
|
|
height,
|
|
render: Box::new(move |_| prompt_editor.clone().into_any_element()),
|
|
};
|
|
terminal.set_block_below_cursor(block, cx);
|
|
})
|
|
.log_err();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TerminalInlineAssist {
|
|
terminal: WeakView<TerminalView>,
|
|
prompt_editor: Option<View<PromptEditor>>,
|
|
codegen: Model<Codegen>,
|
|
workspace: Option<WeakView<Workspace>>,
|
|
include_context: bool,
|
|
_subscriptions: Vec<Subscription>,
|
|
}
|
|
|
|
impl TerminalInlineAssist {
|
|
pub fn new(
|
|
assist_id: TerminalInlineAssistId,
|
|
terminal: &View<TerminalView>,
|
|
include_context: bool,
|
|
prompt_editor: View<PromptEditor>,
|
|
workspace: Option<WeakView<Workspace>>,
|
|
cx: &mut WindowContext,
|
|
) -> Self {
|
|
let codegen = prompt_editor.read(cx).codegen.clone();
|
|
Self {
|
|
terminal: terminal.downgrade(),
|
|
prompt_editor: Some(prompt_editor.clone()),
|
|
codegen: codegen.clone(),
|
|
workspace: workspace.clone(),
|
|
include_context,
|
|
_subscriptions: vec![
|
|
cx.subscribe(&prompt_editor, |prompt_editor, event, cx| {
|
|
TerminalInlineAssistant::update_global(cx, |this, cx| {
|
|
this.handle_prompt_editor_event(prompt_editor, event, cx)
|
|
})
|
|
}),
|
|
cx.subscribe(&codegen, move |codegen, event, cx| {
|
|
TerminalInlineAssistant::update_global(cx, |this, cx| match event {
|
|
CodegenEvent::Finished => {
|
|
let assist = if let Some(assist) = this.assists.get(&assist_id) {
|
|
assist
|
|
} else {
|
|
return;
|
|
};
|
|
|
|
if let CodegenStatus::Error(error) = &codegen.read(cx).status {
|
|
if assist.prompt_editor.is_none() {
|
|
if let Some(workspace) = assist
|
|
.workspace
|
|
.as_ref()
|
|
.and_then(|workspace| workspace.upgrade())
|
|
{
|
|
let error =
|
|
format!("Terminal inline assistant error: {}", error);
|
|
workspace.update(cx, |workspace, cx| {
|
|
struct InlineAssistantError;
|
|
|
|
let id =
|
|
NotificationId::composite::<InlineAssistantError>(
|
|
assist_id.0,
|
|
);
|
|
|
|
workspace.show_toast(Toast::new(id, error), cx);
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if assist.prompt_editor.is_none() {
|
|
this.finish_assist(assist_id, false, false, cx);
|
|
}
|
|
}
|
|
})
|
|
}),
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
enum PromptEditorEvent {
|
|
StartRequested,
|
|
StopRequested,
|
|
ConfirmRequested { execute: bool },
|
|
CancelRequested,
|
|
DismissRequested,
|
|
Resized { height_in_lines: u8 },
|
|
}
|
|
|
|
struct PromptEditor {
|
|
id: TerminalInlineAssistId,
|
|
fs: Arc<dyn Fs>,
|
|
height_in_lines: u8,
|
|
editor: View<Editor>,
|
|
edited_since_done: bool,
|
|
prompt_history: VecDeque<String>,
|
|
prompt_history_ix: Option<usize>,
|
|
pending_prompt: String,
|
|
codegen: Model<Codegen>,
|
|
_codegen_subscription: Subscription,
|
|
editor_subscriptions: Vec<Subscription>,
|
|
pending_token_count: Task<Result<()>>,
|
|
token_count: Option<usize>,
|
|
_token_count_subscriptions: Vec<Subscription>,
|
|
workspace: Option<WeakView<Workspace>>,
|
|
}
|
|
|
|
impl EventEmitter<PromptEditorEvent> for PromptEditor {}
|
|
|
|
impl Render for PromptEditor {
|
|
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
|
let status = &self.codegen.read(cx).status;
|
|
let buttons = match status {
|
|
CodegenStatus::Idle => {
|
|
vec![
|
|
IconButton::new("cancel", IconName::Close)
|
|
.icon_color(Color::Muted)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx))
|
|
.on_click(
|
|
cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)),
|
|
),
|
|
IconButton::new("start", IconName::SparkleAlt)
|
|
.icon_color(Color::Muted)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(|cx| Tooltip::for_action("Generate", &menu::Confirm, cx))
|
|
.on_click(
|
|
cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::StartRequested)),
|
|
),
|
|
]
|
|
}
|
|
CodegenStatus::Pending => {
|
|
vec![
|
|
IconButton::new("cancel", IconName::Close)
|
|
.icon_color(Color::Muted)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(|cx| Tooltip::text("Cancel Assist", cx))
|
|
.on_click(
|
|
cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)),
|
|
),
|
|
IconButton::new("stop", IconName::Stop)
|
|
.icon_color(Color::Error)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(|cx| {
|
|
Tooltip::with_meta(
|
|
"Interrupt Generation",
|
|
Some(&menu::Cancel),
|
|
"Changes won't be discarded",
|
|
cx,
|
|
)
|
|
})
|
|
.on_click(
|
|
cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::StopRequested)),
|
|
),
|
|
]
|
|
}
|
|
CodegenStatus::Error(_) | CodegenStatus::Done => {
|
|
let cancel = IconButton::new("cancel", IconName::Close)
|
|
.icon_color(Color::Muted)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx))
|
|
.on_click(cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)));
|
|
|
|
let has_error = matches!(status, CodegenStatus::Error(_));
|
|
if has_error || self.edited_since_done {
|
|
vec![
|
|
cancel,
|
|
IconButton::new("restart", IconName::RotateCw)
|
|
.icon_color(Color::Info)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(|cx| {
|
|
Tooltip::with_meta(
|
|
"Restart Generation",
|
|
Some(&menu::Confirm),
|
|
"Changes will be discarded",
|
|
cx,
|
|
)
|
|
})
|
|
.on_click(cx.listener(|_, _, cx| {
|
|
cx.emit(PromptEditorEvent::StartRequested);
|
|
})),
|
|
]
|
|
} else {
|
|
vec![
|
|
cancel,
|
|
IconButton::new("accept", IconName::Check)
|
|
.icon_color(Color::Info)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(|cx| {
|
|
Tooltip::for_action("Accept Generated Command", &menu::Confirm, cx)
|
|
})
|
|
.on_click(cx.listener(|_, _, cx| {
|
|
cx.emit(PromptEditorEvent::ConfirmRequested { execute: false });
|
|
})),
|
|
IconButton::new("confirm", IconName::Play)
|
|
.icon_color(Color::Info)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(|cx| {
|
|
Tooltip::for_action(
|
|
"Execute Generated Command",
|
|
&menu::SecondaryConfirm,
|
|
cx,
|
|
)
|
|
})
|
|
.on_click(cx.listener(|_, _, cx| {
|
|
cx.emit(PromptEditorEvent::ConfirmRequested { execute: true });
|
|
})),
|
|
]
|
|
}
|
|
}
|
|
};
|
|
|
|
h_flex()
|
|
.bg(cx.theme().colors().editor_background)
|
|
.border_y_1()
|
|
.border_color(cx.theme().status().info_border)
|
|
.py_2()
|
|
.h_full()
|
|
.w_full()
|
|
.on_action(cx.listener(Self::confirm))
|
|
.on_action(cx.listener(Self::secondary_confirm))
|
|
.on_action(cx.listener(Self::cancel))
|
|
.on_action(cx.listener(Self::move_up))
|
|
.on_action(cx.listener(Self::move_down))
|
|
.child(
|
|
h_flex()
|
|
.w_12()
|
|
.justify_center()
|
|
.gap_2()
|
|
.child(LanguageModelSelector::new(
|
|
{
|
|
let fs = self.fs.clone();
|
|
move |model, cx| {
|
|
update_settings_file::<AssistantSettings>(
|
|
fs.clone(),
|
|
cx,
|
|
move |settings, _| settings.set_model(model.clone()),
|
|
);
|
|
}
|
|
},
|
|
IconButton::new("context", IconName::SettingsAlt)
|
|
.shape(IconButtonShape::Square)
|
|
.icon_size(IconSize::Small)
|
|
.icon_color(Color::Muted)
|
|
.tooltip(move |cx| {
|
|
Tooltip::with_meta(
|
|
format!(
|
|
"Using {}",
|
|
LanguageModelRegistry::read_global(cx)
|
|
.active_model()
|
|
.map(|model| model.name().0)
|
|
.unwrap_or_else(|| "No model selected".into()),
|
|
),
|
|
None,
|
|
"Change Model",
|
|
cx,
|
|
)
|
|
}),
|
|
))
|
|
.children(
|
|
if let CodegenStatus::Error(error) = &self.codegen.read(cx).status {
|
|
let error_message = SharedString::from(error.to_string());
|
|
Some(
|
|
div()
|
|
.id("error")
|
|
.tooltip(move |cx| Tooltip::text(error_message.clone(), cx))
|
|
.child(
|
|
Icon::new(IconName::XCircle)
|
|
.size(IconSize::Small)
|
|
.color(Color::Error),
|
|
),
|
|
)
|
|
} else {
|
|
None
|
|
},
|
|
),
|
|
)
|
|
.child(div().flex_1().child(self.render_prompt_editor(cx)))
|
|
.child(
|
|
h_flex()
|
|
.gap_1()
|
|
.pr_4()
|
|
.children(self.render_token_count(cx))
|
|
.children(buttons),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl FocusableView for PromptEditor {
|
|
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
|
|
self.editor.focus_handle(cx)
|
|
}
|
|
}
|
|
|
|
impl PromptEditor {
|
|
const MAX_LINES: u8 = 8;
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn new(
|
|
id: TerminalInlineAssistId,
|
|
prompt_history: VecDeque<String>,
|
|
prompt_buffer: Model<MultiBuffer>,
|
|
codegen: Model<Codegen>,
|
|
assistant_panel: Option<&View<AssistantPanel>>,
|
|
workspace: Option<WeakView<Workspace>>,
|
|
fs: Arc<dyn Fs>,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
let prompt_editor = cx.new_view(|cx| {
|
|
let mut editor = Editor::new(
|
|
EditorMode::AutoHeight {
|
|
max_lines: Self::MAX_LINES as usize,
|
|
},
|
|
prompt_buffer,
|
|
None,
|
|
false,
|
|
cx,
|
|
);
|
|
editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
|
|
editor.set_placeholder_text("Add a prompt…", cx);
|
|
editor
|
|
});
|
|
|
|
let mut token_count_subscriptions = Vec::new();
|
|
if let Some(assistant_panel) = assistant_panel {
|
|
token_count_subscriptions
|
|
.push(cx.subscribe(assistant_panel, Self::handle_assistant_panel_event));
|
|
}
|
|
|
|
let mut this = Self {
|
|
id,
|
|
height_in_lines: 1,
|
|
editor: prompt_editor,
|
|
edited_since_done: false,
|
|
prompt_history,
|
|
prompt_history_ix: None,
|
|
pending_prompt: String::new(),
|
|
_codegen_subscription: cx.observe(&codegen, Self::handle_codegen_changed),
|
|
editor_subscriptions: Vec::new(),
|
|
codegen,
|
|
fs,
|
|
pending_token_count: Task::ready(Ok(())),
|
|
token_count: None,
|
|
_token_count_subscriptions: token_count_subscriptions,
|
|
workspace,
|
|
};
|
|
this.count_lines(cx);
|
|
this.count_tokens(cx);
|
|
this.subscribe_to_editor(cx);
|
|
this
|
|
}
|
|
|
|
fn subscribe_to_editor(&mut self, cx: &mut ViewContext<Self>) {
|
|
self.editor_subscriptions.clear();
|
|
self.editor_subscriptions
|
|
.push(cx.observe(&self.editor, Self::handle_prompt_editor_changed));
|
|
self.editor_subscriptions
|
|
.push(cx.subscribe(&self.editor, Self::handle_prompt_editor_events));
|
|
}
|
|
|
|
fn prompt(&self, cx: &AppContext) -> String {
|
|
self.editor.read(cx).text(cx)
|
|
}
|
|
|
|
fn count_lines(&mut self, cx: &mut ViewContext<Self>) {
|
|
let height_in_lines = cmp::max(
|
|
2, // Make the editor at least two lines tall, to account for padding and buttons.
|
|
cmp::min(
|
|
self.editor
|
|
.update(cx, |editor, cx| editor.max_point(cx).row().0 + 1),
|
|
Self::MAX_LINES as u32,
|
|
),
|
|
) as u8;
|
|
|
|
if height_in_lines != self.height_in_lines {
|
|
self.height_in_lines = height_in_lines;
|
|
cx.emit(PromptEditorEvent::Resized { height_in_lines });
|
|
}
|
|
}
|
|
|
|
fn handle_assistant_panel_event(
|
|
&mut self,
|
|
_: View<AssistantPanel>,
|
|
event: &AssistantPanelEvent,
|
|
cx: &mut ViewContext<Self>,
|
|
) {
|
|
let AssistantPanelEvent::ContextEdited { .. } = event;
|
|
self.count_tokens(cx);
|
|
}
|
|
|
|
fn count_tokens(&mut self, cx: &mut ViewContext<Self>) {
|
|
let assist_id = self.id;
|
|
let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
|
|
return;
|
|
};
|
|
self.pending_token_count = cx.spawn(|this, mut cx| async move {
|
|
cx.background_executor().timer(Duration::from_secs(1)).await;
|
|
let request =
|
|
cx.update_global(|inline_assistant: &mut TerminalInlineAssistant, cx| {
|
|
inline_assistant.request_for_inline_assist(assist_id, cx)
|
|
})??;
|
|
|
|
let token_count = cx.update(|cx| model.count_tokens(request, cx))?.await?;
|
|
this.update(&mut cx, |this, cx| {
|
|
this.token_count = Some(token_count);
|
|
cx.notify();
|
|
})
|
|
})
|
|
}
|
|
|
|
fn handle_prompt_editor_changed(&mut self, _: View<Editor>, cx: &mut ViewContext<Self>) {
|
|
self.count_lines(cx);
|
|
}
|
|
|
|
fn handle_prompt_editor_events(
|
|
&mut self,
|
|
_: View<Editor>,
|
|
event: &EditorEvent,
|
|
cx: &mut ViewContext<Self>,
|
|
) {
|
|
match event {
|
|
EditorEvent::Edited { .. } => {
|
|
let prompt = self.editor.read(cx).text(cx);
|
|
if self
|
|
.prompt_history_ix
|
|
.map_or(true, |ix| self.prompt_history[ix] != prompt)
|
|
{
|
|
self.prompt_history_ix.take();
|
|
self.pending_prompt = prompt;
|
|
}
|
|
|
|
self.edited_since_done = true;
|
|
cx.notify();
|
|
}
|
|
EditorEvent::BufferEdited => {
|
|
self.count_tokens(cx);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_codegen_changed(&mut self, _: Model<Codegen>, cx: &mut ViewContext<Self>) {
|
|
match &self.codegen.read(cx).status {
|
|
CodegenStatus::Idle => {
|
|
self.editor
|
|
.update(cx, |editor, _| editor.set_read_only(false));
|
|
}
|
|
CodegenStatus::Pending => {
|
|
self.editor
|
|
.update(cx, |editor, _| editor.set_read_only(true));
|
|
}
|
|
CodegenStatus::Done | CodegenStatus::Error(_) => {
|
|
self.edited_since_done = false;
|
|
self.editor
|
|
.update(cx, |editor, _| editor.set_read_only(false));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext<Self>) {
|
|
match &self.codegen.read(cx).status {
|
|
CodegenStatus::Idle | CodegenStatus::Done | CodegenStatus::Error(_) => {
|
|
cx.emit(PromptEditorEvent::CancelRequested);
|
|
}
|
|
CodegenStatus::Pending => {
|
|
cx.emit(PromptEditorEvent::StopRequested);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
|
|
match &self.codegen.read(cx).status {
|
|
CodegenStatus::Idle => {
|
|
if !self.editor.read(cx).text(cx).trim().is_empty() {
|
|
cx.emit(PromptEditorEvent::StartRequested);
|
|
}
|
|
}
|
|
CodegenStatus::Pending => {
|
|
cx.emit(PromptEditorEvent::DismissRequested);
|
|
}
|
|
CodegenStatus::Done => {
|
|
if self.edited_since_done {
|
|
cx.emit(PromptEditorEvent::StartRequested);
|
|
} else {
|
|
cx.emit(PromptEditorEvent::ConfirmRequested { execute: false });
|
|
}
|
|
}
|
|
CodegenStatus::Error(_) => {
|
|
cx.emit(PromptEditorEvent::StartRequested);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
|
|
if matches!(self.codegen.read(cx).status, CodegenStatus::Done) {
|
|
cx.emit(PromptEditorEvent::ConfirmRequested { execute: true });
|
|
}
|
|
}
|
|
|
|
fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext<Self>) {
|
|
if let Some(ix) = self.prompt_history_ix {
|
|
if ix > 0 {
|
|
self.prompt_history_ix = Some(ix - 1);
|
|
let prompt = self.prompt_history[ix - 1].as_str();
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.set_text(prompt, cx);
|
|
editor.move_to_beginning(&Default::default(), cx);
|
|
});
|
|
}
|
|
} else if !self.prompt_history.is_empty() {
|
|
self.prompt_history_ix = Some(self.prompt_history.len() - 1);
|
|
let prompt = self.prompt_history[self.prompt_history.len() - 1].as_str();
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.set_text(prompt, cx);
|
|
editor.move_to_beginning(&Default::default(), cx);
|
|
});
|
|
}
|
|
}
|
|
|
|
fn move_down(&mut self, _: &MoveDown, cx: &mut ViewContext<Self>) {
|
|
if let Some(ix) = self.prompt_history_ix {
|
|
if ix < self.prompt_history.len() - 1 {
|
|
self.prompt_history_ix = Some(ix + 1);
|
|
let prompt = self.prompt_history[ix + 1].as_str();
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.set_text(prompt, cx);
|
|
editor.move_to_end(&Default::default(), cx)
|
|
});
|
|
} else {
|
|
self.prompt_history_ix = None;
|
|
let prompt = self.pending_prompt.as_str();
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.set_text(prompt, cx);
|
|
editor.move_to_end(&Default::default(), cx)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_token_count(&self, cx: &mut ViewContext<Self>) -> Option<impl IntoElement> {
|
|
let model = LanguageModelRegistry::read_global(cx).active_model()?;
|
|
let token_count = self.token_count?;
|
|
let max_token_count = model.max_token_count();
|
|
|
|
let remaining_tokens = max_token_count as isize - token_count as isize;
|
|
let token_count_color = if remaining_tokens <= 0 {
|
|
Color::Error
|
|
} else if token_count as f32 / max_token_count as f32 >= 0.8 {
|
|
Color::Warning
|
|
} else {
|
|
Color::Muted
|
|
};
|
|
|
|
let mut token_count = h_flex()
|
|
.id("token_count")
|
|
.gap_0p5()
|
|
.child(
|
|
Label::new(humanize_token_count(token_count))
|
|
.size(LabelSize::Small)
|
|
.color(token_count_color),
|
|
)
|
|
.child(Label::new("/").size(LabelSize::Small).color(Color::Muted))
|
|
.child(
|
|
Label::new(humanize_token_count(max_token_count))
|
|
.size(LabelSize::Small)
|
|
.color(Color::Muted),
|
|
);
|
|
if let Some(workspace) = self.workspace.clone() {
|
|
token_count = token_count
|
|
.tooltip(|cx| {
|
|
Tooltip::with_meta(
|
|
"Tokens Used by Inline Assistant",
|
|
None,
|
|
"Click to Open Assistant Panel",
|
|
cx,
|
|
)
|
|
})
|
|
.cursor_pointer()
|
|
.on_mouse_down(gpui::MouseButton::Left, |_, cx| cx.stop_propagation())
|
|
.on_click(move |_, cx| {
|
|
cx.stop_propagation();
|
|
workspace
|
|
.update(cx, |workspace, cx| {
|
|
workspace.focus_panel::<AssistantPanel>(cx)
|
|
})
|
|
.ok();
|
|
});
|
|
} else {
|
|
token_count = token_count
|
|
.cursor_default()
|
|
.tooltip(|cx| Tooltip::text("Tokens Used by Inline Assistant", cx));
|
|
}
|
|
|
|
Some(token_count)
|
|
}
|
|
|
|
fn render_prompt_editor(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
|
let settings = ThemeSettings::get_global(cx);
|
|
let text_style = TextStyle {
|
|
color: if self.editor.read(cx).read_only(cx) {
|
|
cx.theme().colors().text_disabled
|
|
} else {
|
|
cx.theme().colors().text
|
|
},
|
|
font_family: settings.buffer_font.family.clone(),
|
|
font_fallbacks: settings.buffer_font.fallbacks.clone(),
|
|
font_size: settings.buffer_font_size.into(),
|
|
font_weight: settings.buffer_font.weight,
|
|
line_height: relative(settings.buffer_line_height.value()),
|
|
..Default::default()
|
|
};
|
|
EditorElement::new(
|
|
&self.editor,
|
|
EditorStyle {
|
|
background: cx.theme().colors().editor_background,
|
|
local_player: cx.theme().players().local(),
|
|
text: text_style,
|
|
..Default::default()
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum CodegenEvent {
|
|
Finished,
|
|
}
|
|
|
|
impl EventEmitter<CodegenEvent> for Codegen {}
|
|
|
|
const CLEAR_INPUT: &str = "\x15";
|
|
const CARRIAGE_RETURN: &str = "\x0d";
|
|
|
|
struct TerminalTransaction {
|
|
terminal: Model<Terminal>,
|
|
}
|
|
|
|
impl TerminalTransaction {
|
|
pub fn start(terminal: Model<Terminal>) -> Self {
|
|
Self { terminal }
|
|
}
|
|
|
|
pub fn push(&mut self, hunk: String, cx: &mut AppContext) {
|
|
// Ensure that the assistant cannot accidentally execute commands that are streamed into the terminal
|
|
let input = Self::sanitize_input(hunk);
|
|
self.terminal
|
|
.update(cx, |terminal, _| terminal.input(input));
|
|
}
|
|
|
|
pub fn undo(&self, cx: &mut AppContext) {
|
|
self.terminal
|
|
.update(cx, |terminal, _| terminal.input(CLEAR_INPUT.to_string()));
|
|
}
|
|
|
|
pub fn complete(&self, cx: &mut AppContext) {
|
|
self.terminal.update(cx, |terminal, _| {
|
|
terminal.input(CARRIAGE_RETURN.to_string())
|
|
});
|
|
}
|
|
|
|
fn sanitize_input(input: String) -> String {
|
|
input.replace(['\r', '\n'], "")
|
|
}
|
|
}
|
|
|
|
pub struct Codegen {
|
|
status: CodegenStatus,
|
|
telemetry: Option<Arc<Telemetry>>,
|
|
terminal: Model<Terminal>,
|
|
generation: Task<()>,
|
|
message_id: Option<String>,
|
|
transaction: Option<TerminalTransaction>,
|
|
}
|
|
|
|
impl Codegen {
|
|
pub fn new(terminal: Model<Terminal>, telemetry: Option<Arc<Telemetry>>) -> Self {
|
|
Self {
|
|
terminal,
|
|
telemetry,
|
|
status: CodegenStatus::Idle,
|
|
generation: Task::ready(()),
|
|
message_id: None,
|
|
transaction: None,
|
|
}
|
|
}
|
|
|
|
pub fn start(&mut self, prompt: LanguageModelRequest, cx: &mut ModelContext<Self>) {
|
|
let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
|
|
return;
|
|
};
|
|
|
|
let model_api_key = model.api_key(cx);
|
|
let http_client = cx.http_client();
|
|
let telemetry = self.telemetry.clone();
|
|
self.status = CodegenStatus::Pending;
|
|
self.transaction = Some(TerminalTransaction::start(self.terminal.clone()));
|
|
self.generation = cx.spawn(|this, mut cx| async move {
|
|
let model_telemetry_id = model.telemetry_id();
|
|
let model_provider_id = model.provider_id();
|
|
let response = model.stream_completion_text(prompt, &cx).await;
|
|
let generate = async {
|
|
let message_id = response
|
|
.as_ref()
|
|
.ok()
|
|
.and_then(|response| response.message_id.clone());
|
|
|
|
let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1);
|
|
|
|
let task = cx.background_executor().spawn({
|
|
let message_id = message_id.clone();
|
|
let executor = cx.background_executor().clone();
|
|
async move {
|
|
let mut response_latency = None;
|
|
let request_start = Instant::now();
|
|
let task = async {
|
|
let mut chunks = response?.stream;
|
|
while let Some(chunk) = chunks.next().await {
|
|
if response_latency.is_none() {
|
|
response_latency = Some(request_start.elapsed());
|
|
}
|
|
let chunk = chunk?;
|
|
hunks_tx.send(chunk).await?;
|
|
}
|
|
|
|
anyhow::Ok(())
|
|
};
|
|
|
|
let result = task.await;
|
|
|
|
let error_message = result.as_ref().err().map(|error| error.to_string());
|
|
report_assistant_event(
|
|
AssistantEvent {
|
|
conversation_id: None,
|
|
kind: AssistantKind::InlineTerminal,
|
|
message_id,
|
|
phase: AssistantPhase::Response,
|
|
model: model_telemetry_id,
|
|
model_provider: model_provider_id.to_string(),
|
|
response_latency,
|
|
error_message,
|
|
language_name: None,
|
|
},
|
|
telemetry,
|
|
http_client,
|
|
model_api_key,
|
|
&executor,
|
|
);
|
|
|
|
result?;
|
|
anyhow::Ok(())
|
|
}
|
|
});
|
|
|
|
this.update(&mut cx, |this, _| {
|
|
this.message_id = message_id;
|
|
})?;
|
|
|
|
while let Some(hunk) = hunks_rx.next().await {
|
|
this.update(&mut cx, |this, cx| {
|
|
if let Some(transaction) = &mut this.transaction {
|
|
transaction.push(hunk, cx);
|
|
cx.notify();
|
|
}
|
|
})?;
|
|
}
|
|
|
|
task.await?;
|
|
anyhow::Ok(())
|
|
};
|
|
|
|
let result = generate.await;
|
|
|
|
this.update(&mut cx, |this, cx| {
|
|
if let Err(error) = result {
|
|
this.status = CodegenStatus::Error(error);
|
|
} else {
|
|
this.status = CodegenStatus::Done;
|
|
}
|
|
cx.emit(CodegenEvent::Finished);
|
|
cx.notify();
|
|
})
|
|
.ok();
|
|
});
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn stop(&mut self, cx: &mut ModelContext<Self>) {
|
|
self.status = CodegenStatus::Done;
|
|
self.generation = Task::ready(());
|
|
cx.emit(CodegenEvent::Finished);
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn complete(&mut self, cx: &mut ModelContext<Self>) {
|
|
if let Some(transaction) = self.transaction.take() {
|
|
transaction.complete(cx);
|
|
}
|
|
}
|
|
|
|
pub fn undo(&mut self, cx: &mut ModelContext<Self>) {
|
|
if let Some(transaction) = self.transaction.take() {
|
|
transaction.undo(cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
enum CodegenStatus {
|
|
Idle,
|
|
Pending,
|
|
Done,
|
|
Error(anyhow::Error),
|
|
}
|