diff --git a/Cargo.lock b/Cargo.lock index c7bc3cc596..206f7e9867 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,6 +417,7 @@ dependencies = [ "strsim 0.11.1", "strum", "telemetry_events", + "terminal", "terminal_view", "theme", "tiktoken-rs", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 7b7b113900..6555370cf9 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -653,6 +653,7 @@ "ctrl-insert": "terminal::Copy", "shift-ctrl-v": "terminal::Paste", "shift-insert": "terminal::Paste", + "ctrl-enter": "assistant::InlineAssist", "up": ["terminal::SendKeystroke", "up"], "pageup": ["terminal::SendKeystroke", "pageup"], "down": ["terminal::SendKeystroke", "down"], diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f25b44fb37..e4ac33b03c 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -688,6 +688,7 @@ "cmd-c": "terminal::Copy", "cmd-v": "terminal::Paste", "cmd-k": "terminal::Clear", + "ctrl-enter": "assistant::InlineAssist", // Some nice conveniences "cmd-backspace": ["terminal::SendText", "\u0015"], "cmd-right": ["terminal::SendText", "\u0005"], diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index c8f84e3b9e..ca96bd4bac 100644 --- a/crates/assistant/Cargo.toml +++ b/crates/assistant/Cargo.toml @@ -56,6 +56,7 @@ smol.workspace = true strsim = "0.11" strum.workspace = true telemetry_events.workspace = true +terminal.workspace = true terminal_view.workspace = true theme.workspace = true tiktoken-rs.workspace = true diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index f8b5047a99..44d27c825e 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -9,6 +9,7 @@ mod prompts; mod search; mod slash_command; mod streaming_diff; +mod terminal_inline_assistant; pub use assistant_panel::{AssistantPanel, AssistantPanelEvent}; use assistant_settings::{AnthropicModel, AssistantSettings, CloudModel, OllamaModel, OpenAiModel}; @@ -289,6 +290,7 @@ pub fn init(fs: Arc, client: Arc, cx: &mut AppContext) { register_slash_commands(cx); assistant_panel::init(cx); inline_assistant::init(fs.clone(), client.telemetry().clone(), cx); + terminal_inline_assistant::init(fs.clone(), client.telemetry().clone(), cx); RustdocStore::init_global(cx); CommandPaletteFilter::update_global(cx, |filter, _cx| { diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index d13a8379fb..a39c14d7bd 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -7,6 +7,7 @@ use crate::{ default_command::DefaultSlashCommand, SlashCommandCompletionProvider, SlashCommandLine, SlashCommandRegistry, }, + terminal_inline_assistant::TerminalInlineAssistant, ApplyEdit, Assist, CompletionProvider, ConfirmCommand, ContextStore, CycleMessageRole, InlineAssist, InlineAssistant, LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, MessageStatus, ModelSelector, QuoteSelection, ResetKey, Role, SavedContext, @@ -58,6 +59,7 @@ use std::{ time::{Duration, Instant}, }; use telemetry_events::AssistantKind; +use terminal_view::{terminal_panel::TerminalPanel, TerminalView}; use ui::{ prelude::*, ButtonLike, ContextMenu, Disclosure, ElevationIndex, KeyBinding, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, Tab, TabBar, Tooltip, @@ -124,6 +126,11 @@ enum SavedContextPickerEvent { Confirmed { path: PathBuf }, } +enum InlineAssistTarget { + Editor(View, bool), + Terminal(View), +} + impl EventEmitter for Picker {} impl SavedContextPickerDelegate { @@ -369,6 +376,103 @@ impl AssistantPanel { return; }; + let Some(inline_assist_target) = + Self::resolve_inline_assist_target(workspace, &assistant_panel, cx) + else { + return; + }; + + if assistant_panel.update(cx, |assistant, cx| assistant.is_authenticated(cx)) { + match inline_assist_target { + InlineAssistTarget::Editor(active_editor, include_context) => { + InlineAssistant::update_global(cx, |assistant, cx| { + assistant.assist( + &active_editor, + Some(cx.view().downgrade()), + include_context.then_some(&assistant_panel), + cx, + ) + }) + } + InlineAssistTarget::Terminal(active_terminal) => { + TerminalInlineAssistant::update_global(cx, |assistant, cx| { + assistant.assist( + &active_terminal, + Some(cx.view().downgrade()), + Some(&assistant_panel), + cx, + ) + }) + } + } + } else { + let assistant_panel = assistant_panel.downgrade(); + cx.spawn(|workspace, mut cx| async move { + assistant_panel + .update(&mut cx, |assistant, cx| assistant.authenticate(cx))? + .await?; + if assistant_panel.update(&mut cx, |panel, cx| panel.is_authenticated(cx))? { + cx.update(|cx| match inline_assist_target { + InlineAssistTarget::Editor(active_editor, include_context) => { + let assistant_panel = if include_context { + assistant_panel.upgrade() + } else { + None + }; + InlineAssistant::update_global(cx, |assistant, cx| { + assistant.assist( + &active_editor, + Some(workspace), + assistant_panel.as_ref(), + cx, + ) + }) + } + InlineAssistTarget::Terminal(active_terminal) => { + TerminalInlineAssistant::update_global(cx, |assistant, cx| { + assistant.assist( + &active_terminal, + Some(workspace), + assistant_panel.upgrade().as_ref(), + cx, + ) + }) + } + })? + } else { + workspace.update(&mut cx, |workspace, cx| { + workspace.focus_panel::(cx) + })?; + } + + anyhow::Ok(()) + }) + .detach_and_log_err(cx) + } + } + + fn resolve_inline_assist_target( + workspace: &mut Workspace, + assistant_panel: &View, + cx: &mut WindowContext, + ) -> Option { + if let Some(terminal_panel) = workspace.panel::(cx) { + if terminal_panel + .read(cx) + .focus_handle(cx) + .contains_focused(cx) + { + if let Some(terminal_view) = terminal_panel + .read(cx) + .pane() + .read(cx) + .active_item() + .and_then(|t| t.downcast::()) + { + return Some(InlineAssistTarget::Terminal(terminal_view)); + } + } + } let context_editor = assistant_panel .read(cx) .active_context_editor() @@ -381,63 +485,15 @@ impl AssistantPanel { } }); - let include_context; - let active_editor; if let Some(context_editor) = context_editor { - active_editor = context_editor; - include_context = false; + Some(InlineAssistTarget::Editor(context_editor, false)) } else if let Some(workspace_editor) = workspace .active_item(cx) .and_then(|item| item.act_as::(cx)) { - active_editor = workspace_editor; - include_context = true; + Some(InlineAssistTarget::Editor(workspace_editor, true)) } else { - return; - }; - - if assistant_panel.update(cx, |panel, cx| panel.is_authenticated(cx)) { - InlineAssistant::update_global(cx, |assistant, cx| { - assistant.assist( - &active_editor, - Some(cx.view().downgrade()), - include_context.then_some(&assistant_panel), - cx, - ) - }) - } else { - let assistant_panel = assistant_panel.downgrade(); - cx.spawn(|workspace, mut cx| async move { - assistant_panel - .update(&mut cx, |assistant, cx| assistant.authenticate(cx))? - .await?; - if assistant_panel - .update(&mut cx, |assistant, cx| assistant.is_authenticated(cx))? - { - cx.update(|cx| { - let assistant_panel = if include_context { - assistant_panel.upgrade() - } else { - None - }; - InlineAssistant::update_global(cx, |assistant, cx| { - assistant.assist( - &active_editor, - Some(workspace), - assistant_panel.as_ref(), - cx, - ) - }) - })? - } else { - workspace.update(&mut cx, |workspace, cx| { - workspace.focus_panel::(cx) - })?; - } - - anyhow::Ok(()) - }) - .detach_and_log_err(cx) + None } } diff --git a/crates/assistant/src/prompts.rs b/crates/assistant/src/prompts.rs index 9f33cf546a..4c99caca82 100644 --- a/crates/assistant/src/prompts.rs +++ b/crates/assistant/src/prompts.rs @@ -109,3 +109,27 @@ pub fn generate_content_prompt( Ok(prompt) } + +pub fn generate_terminal_assistant_prompt( + user_prompt: &str, + shell: Option<&str>, + working_directory: Option<&str>, +) -> String { + let mut prompt = String::new(); + writeln!(&mut prompt, "You are an expert terminal user.").unwrap(); + writeln!(&mut prompt, "You will be given a description of a command and you need to respond with a command that matches the description.").unwrap(); + writeln!(&mut prompt, "Do not include markdown blocks or any other text formatting in your response, always respond with a single command that can be executed in the given shell.").unwrap(); + if let Some(shell) = shell { + writeln!(&mut prompt, "Current shell is '{shell}'.").unwrap(); + } + if let Some(working_directory) = working_directory { + writeln!( + &mut prompt, + "Current working directory is '{working_directory}'." + ) + .unwrap(); + } + writeln!(&mut prompt, "Here is the description of the command:").unwrap(); + prompt.push_str(user_prompt); + prompt +} diff --git a/crates/assistant/src/terminal_inline_assistant.rs b/crates/assistant/src/terminal_inline_assistant.rs new file mode 100644 index 0000000000..13c52a29bb --- /dev/null +++ b/crates/assistant/src/terminal_inline_assistant.rs @@ -0,0 +1,1122 @@ +use crate::{ + assistant_settings::AssistantSettings, humanize_token_count, + prompts::generate_terminal_assistant_prompt, AssistantPanel, AssistantPanelEvent, + CompletionProvider, LanguageModelRequest, LanguageModelRequestMessage, Role, +}; +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, FontStyle, FontWeight, Global, + Model, ModelContext, Subscription, Task, TextStyle, UpdateGlobal, View, WeakView, WhiteSpace, +}; +use language::Buffer; +use settings::{update_settings_file, Settings}; +use std::{ + cmp, + sync::Arc, + time::{Duration, Instant}, +}; +use terminal::Terminal; +use terminal_view::TerminalView; +use theme::ThemeSettings; +use ui::{prelude::*, ContextMenu, PopoverMenu, Tooltip}; +use util::ResultExt; +use workspace::{notifications::NotificationId, Toast, Workspace}; + +pub fn init(fs: Arc, telemetry: Arc, cx: &mut AppContext) { + cx.set_global(TerminalInlineAssistant::new(fs, 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, + prompt_history: VecDeque, + telemetry: Option>, + fs: Arc, +} + +impl Global for TerminalInlineAssistant {} + +impl TerminalInlineAssistant { + pub fn new(fs: Arc, telemetry: Arc) -> Self { + Self { + next_assist_id: TerminalInlineAssistId::default(), + assists: HashMap::default(), + prompt_history: VecDeque::default(), + telemetry: Some(telemetry), + fs, + } + } + + pub fn assist( + &mut self, + terminal_view: &View, + workspace: Option>, + assistant_panel: Option<&View>, + 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("", 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, + 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 => { + self.finish_assist(assist_id, false, cx); + } + PromptEditorEvent::CancelRequested => { + self.finish_assist(assist_id, true, 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 { + let assist = self.assists.get(&assist_id).context("invalid assist")?; + + let model = CompletionProvider::global(cx).model(); + + let shell = std::env::var("SHELL").ok(); + let working_directory = assist + .terminal + .update(cx, |terminal, cx| { + terminal + .model() + .read(cx) + .working_directory() + .map(|path| path.to_string_lossy().to_string()) + }) + .ok() + .flatten(); + + 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::(cx)?; + Some( + assistant_panel + .read(cx) + .active_context(cx)? + .read(cx) + .to_completion_request(cx), + ) + }) + } else { + None + }; + + let prompt = generate_terminal_assistant_prompt( + &assist + .prompt_editor + .clone() + .context("invalid assist")? + .read(cx) + .prompt(cx), + shell.as_deref(), + working_directory.as_deref(), + ); + + let mut messages = Vec::new(); + if let Some(context_request) = context_request { + messages = context_request.messages; + } + + messages.push(LanguageModelRequestMessage { + role: Role::User, + content: prompt, + }); + + Ok(LanguageModelRequest { + model, + messages, + stop: Vec::new(), + temperature: 1.0, + }) + } + + fn finish_assist( + &mut self, + assist_id: TerminalInlineAssistId, + undo: 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(); + assist.codegen.update(cx, |codegen, cx| { + if undo { + codegen.undo(cx); + } else { + 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, + prompt_editor: Option>, + codegen: Model, + workspace: Option>, + include_context: bool, + _subscriptions: Vec, +} + +impl TerminalInlineAssist { + pub fn new( + assist_id: TerminalInlineAssistId, + terminal: &View, + include_context: bool, + prompt_editor: View, + workspace: Option>, + 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::identified::( + assist_id.0, + ); + + workspace.show_toast(Toast::new(id, error), cx); + }) + } + } + } + + if assist.prompt_editor.is_none() { + this.finish_assist(assist_id, false, cx); + } + } + }) + }), + ], + } + } +} + +enum PromptEditorEvent { + StartRequested, + StopRequested, + ConfirmRequested, + CancelRequested, + DismissRequested, + Resized { height_in_lines: u8 }, +} + +struct PromptEditor { + id: TerminalInlineAssistId, + fs: Arc, + height_in_lines: u8, + editor: View, + edited_since_done: bool, + prompt_history: VecDeque, + prompt_history_ix: Option, + pending_prompt: String, + codegen: Model, + _codegen_subscription: Subscription, + editor_subscriptions: Vec, + pending_token_count: Task>, + token_count: Option, + _token_count_subscriptions: Vec, + workspace: Option>, +} + +impl EventEmitter for PromptEditor {} + +impl Render for PromptEditor { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let fs = self.fs.clone(); + + let buttons = match &self.codegen.read(cx).status { + CodegenStatus::Idle => { + vec![ + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)), + ), + IconButton::new("start", IconName::Sparkle) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .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) + .size(ButtonSize::None) + .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) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .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 => { + vec![ + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)), + ), + if self.edited_since_done { + IconButton::new("restart", IconName::RotateCw) + .icon_color(Color::Info) + .icon_size(IconSize::XSmall) + .size(ButtonSize::None) + .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 { + IconButton::new("confirm", IconName::Play) + .icon_color(Color::Info) + .size(ButtonSize::None) + .tooltip(|cx| { + Tooltip::for_action("Execute generated command", &menu::Confirm, cx) + }) + .on_click(cx.listener(|_, _, cx| { + cx.emit(PromptEditorEvent::ConfirmRequested); + })) + }, + ] + } + }; + + h_flex() + .bg(cx.theme().colors().editor_background) + .border_y_1() + .border_color(cx.theme().status().info_border) + .py_1p5() + .h_full() + .w_full() + .on_action(cx.listener(Self::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( + PopoverMenu::new("model-switcher") + .menu(move |cx| { + ContextMenu::build(cx, |mut menu, cx| { + for model in CompletionProvider::global(cx).available_models(cx) + { + menu = menu.custom_entry( + { + let model = model.clone(); + move |_| { + Label::new(model.display_name()) + .into_any_element() + } + }, + { + let fs = fs.clone(); + let model = model.clone(); + move |cx| { + let model = model.clone(); + update_settings_file::( + fs.clone(), + cx, + move |settings| settings.set_model(model), + ); + } + }, + ); + } + menu + }) + .into() + }) + .trigger( + IconButton::new("context", IconName::Settings) + .size(ButtonSize::None) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(move |cx| { + Tooltip::with_meta( + format!( + "Using {}", + CompletionProvider::global(cx) + .model() + .display_name() + ), + None, + "Click to Change Model", + cx, + ) + }), + ) + .anchor(gpui::AnchorCorner::BottomRight), + ) + .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_2() + .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, + prompt_buffer: Model, + codegen: Model, + assistant_panel: Option<&View>, + workspace: Option>, + fs: Arc, + cx: &mut ViewContext, + ) -> 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.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) { + 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, + event: &AssistantPanelEvent, + cx: &mut ViewContext, + ) { + let AssistantPanelEvent::ContextEdited { .. } = event; + self.count_tokens(cx); + } + + fn count_tokens(&mut self, cx: &mut ViewContext) { + let assist_id = self.id; + 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| CompletionProvider::global(cx).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, cx: &mut ViewContext) { + self.count_lines(cx); + } + + fn handle_prompt_editor_events( + &mut self, + _: View, + event: &EditorEvent, + cx: &mut ViewContext, + ) { + 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, cx: &mut ViewContext) { + 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) { + 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) { + 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 | CodegenStatus::Error(_) => { + if self.edited_since_done { + cx.emit(PromptEditorEvent::StartRequested); + } else { + cx.emit(PromptEditorEvent::ConfirmRequested); + } + } + } + } + + fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext) { + 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) { + 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) -> Option { + let model = CompletionProvider::global(cx).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::(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) -> 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.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_size: rems(0.875).into(), + font_weight: FontWeight::NORMAL, + font_style: FontStyle::Normal, + line_height: relative(1.3), + background_color: None, + underline: None, + strikethrough: None, + white_space: WhiteSpace::Normal, + }; + 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 for Codegen {} + +const CLEAR_INPUT: &str = "\x15"; +const CARRIAGE_RETURN: &str = "\x0d"; + +struct TerminalTransaction { + terminal: Model, +} + +impl TerminalTransaction { + pub fn start(terminal: Model) -> Self { + Self { terminal } + } + + pub fn push(&mut self, hunk: String, cx: &mut AppContext) { + // Ensure that the assistant cannot accidently execute commands that are streamed into the terminal + let input = hunk.replace(CARRIAGE_RETURN, " "); + 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()) + }); + } +} + +pub struct Codegen { + status: CodegenStatus, + telemetry: Option>, + terminal: Model, + generation: Task<()>, + transaction: Option, +} + +impl Codegen { + pub fn new(terminal: Model, telemetry: Option>) -> Self { + Self { + terminal, + telemetry, + status: CodegenStatus::Idle, + generation: Task::ready(()), + transaction: None, + } + } + + pub fn start(&mut self, prompt: LanguageModelRequest, cx: &mut ModelContext) { + self.status = CodegenStatus::Pending; + self.transaction = Some(TerminalTransaction::start(self.terminal.clone())); + + let telemetry = self.telemetry.clone(); + let model_telemetry_id = prompt.model.telemetry_id(); + let response = CompletionProvider::global(cx).complete(prompt); + + self.generation = cx.spawn(|this, mut cx| async move { + let generate = async { + let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); + + let task = cx.background_executor().spawn(async move { + let mut response_latency = None; + let request_start = Instant::now(); + let task = async { + let mut response = response.await?; + while let Some(chunk) = response.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()); + if let Some(telemetry) = telemetry { + telemetry.report_assistant_event( + None, + telemetry_events::AssistantKind::Inline, + model_telemetry_id, + response_latency, + error_message, + ); + } + + result?; + anyhow::Ok(()) + }); + + 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.status = CodegenStatus::Done; + self.generation = Task::ready(()); + cx.emit(CodegenEvent::Finished); + cx.notify(); + } + + pub fn complete(&mut self, cx: &mut ModelContext) { + if let Some(transaction) = self.transaction.take() { + transaction.complete(cx); + } + } + + pub fn undo(&mut self, cx: &mut ModelContext) { + if let Some(transaction) = self.transaction.take() { + transaction.undo(cx); + } + } +} + +enum CodegenStatus { + Idle, + Pending, + Done, + Error(anyhow::Error), +} diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index b0da3c1f94..6975f5cc8e 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -945,6 +945,18 @@ impl Terminal { &self.last_content } + pub fn total_lines(&self) -> usize { + let term = self.term.clone(); + let terminal = term.lock_unfair(); + terminal.total_lines() + } + + pub fn viewport_lines(&self) -> usize { + let term = self.term.clone(); + let terminal = term.lock_unfair(); + terminal.screen_lines() + } + //To test: //- Activate match on terminal (scrolling and selection) //- Editor search snapping behavior @@ -999,11 +1011,21 @@ impl Terminal { .push_back(InternalEvent::Scroll(AlacScroll::Delta(1))); } + pub fn scroll_up_by(&mut self, lines: usize) { + self.events + .push_back(InternalEvent::Scroll(AlacScroll::Delta(lines as i32))); + } + pub fn scroll_line_down(&mut self) { self.events .push_back(InternalEvent::Scroll(AlacScroll::Delta(-1))); } + pub fn scroll_down_by(&mut self, lines: usize) { + self.events + .push_back(InternalEvent::Scroll(AlacScroll::Delta(-(lines as i32)))); + } + pub fn scroll_page_up(&mut self) { self.events .push_back(InternalEvent::Scroll(AlacScroll::PageUp)); @@ -1436,6 +1458,13 @@ impl Terminal { }) } + pub fn working_directory(&self) -> Option { + self.pty_info + .current + .as_ref() + .map(|process| process.cwd.clone()) + } + pub fn title(&self, truncate: bool) -> String { const MAX_CHARS: usize = 25; match &self.task { diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 75d14efe06..489bf2bb2d 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1,11 +1,11 @@ use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine}; use gpui::{ - div, fill, point, px, relative, AnyElement, Bounds, DispatchPhase, Element, ElementId, - FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, HighlightStyle, Hitbox, Hsla, - InputHandler, InteractiveElement, Interactivity, IntoElement, LayoutId, Model, ModelContext, - ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine, - StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, UnderlineStyle, - WeakView, WhiteSpace, WindowContext, WindowTextSystem, + div, fill, point, px, relative, size, AnyElement, AvailableSpace, Bounds, ContentMask, + DispatchPhase, Element, ElementId, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, + HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement, + LayoutId, Model, ModelContext, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, + Point, ShapedLine, StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, + UnderlineStyle, View, WeakView, WhiteSpace, WindowContext, WindowTextSystem, }; use itertools::Itertools; use language::CursorShape; @@ -24,11 +24,13 @@ use terminal::{ HoveredWord, IndexedCell, Terminal, TerminalContent, TerminalSize, }; use theme::{ActiveTheme, Theme, ThemeSettings}; -use ui::Tooltip; +use ui::{ParentElement, Tooltip}; use workspace::Workspace; -use std::mem; use std::{fmt::Debug, ops::RangeInclusive}; +use std::{mem, sync::Arc}; + +use crate::{BlockContext, BlockProperties, TerminalView}; /// The information generated during layout that is necessary for painting. pub struct LayoutState { @@ -44,6 +46,7 @@ pub struct LayoutState { hyperlink_tooltip: Option, gutter: Pixels, last_hovered_word: Option, + block_below_cursor_element: Option, } /// Helper struct for converting data between Alacritty's cursor points, and displayed cursor points. @@ -146,12 +149,14 @@ impl LayoutRect { /// We need to keep a reference to the view for mouse events, do we need it for any other terminal stuff, or can we move that to connection? pub struct TerminalElement { terminal: Model, + terminal_view: View, workspace: WeakView, focus: FocusHandle, focused: bool, cursor_visible: bool, can_navigate_to_selected_word: bool, interactivity: Interactivity, + block_below_cursor: Option>, } impl InteractiveElement for TerminalElement { @@ -163,21 +168,26 @@ impl InteractiveElement for TerminalElement { impl StatefulInteractiveElement for TerminalElement {} impl TerminalElement { + #[allow(clippy::too_many_arguments)] pub fn new( terminal: Model, + terminal_view: View, workspace: WeakView, focus: FocusHandle, focused: bool, cursor_visible: bool, can_navigate_to_selected_word: bool, + block_below_cursor: Option>, ) -> TerminalElement { TerminalElement { terminal, + terminal_view, workspace, focused, focus: focus.clone(), cursor_visible, can_navigate_to_selected_word, + block_below_cursor, interactivity: Default::default(), } .track_focus(&focus) @@ -192,7 +202,7 @@ impl TerminalElement { // terminal_theme: &TerminalStyle, text_system: &WindowTextSystem, hyperlink: Option<(HighlightStyle, &RangeInclusive)>, - cx: &WindowContext<'_>, + cx: &WindowContext, ) -> (Vec, Vec) { let theme = cx.theme(); let mut cells = vec![]; @@ -491,12 +501,14 @@ impl TerminalElement { ), ); self.interactivity.on_scroll_wheel({ - let terminal = terminal.clone(); + let terminal_view = self.terminal_view.downgrade(); move |e, cx| { - terminal.update(cx, |terminal, cx| { - terminal.scroll_wheel(e, origin); - cx.notify(); - }) + terminal_view + .update(cx, |terminal_view, cx| { + terminal_view.scroll_wheel(e, origin, cx); + cx.notify(); + }) + .ok(); } }); @@ -538,6 +550,26 @@ impl TerminalElement { ); } } + + fn rem_size(&self, cx: &WindowContext) -> Option { + let settings = ThemeSettings::get_global(cx).clone(); + let buffer_font_size = settings.buffer_font_size(cx); + let rem_size_scale = { + // Our default UI font size is 14px on a 16px base scale. + // This means the default UI font size is 0.875rems. + let default_font_size_scale = 14. / ui::BASE_REM_SIZE_IN_PX; + + // We then determine the delta between a single rem and the default font + // size scale. + let default_font_size_delta = 1. - default_font_size_scale; + + // Finally, we add this delta to 1rem to get the scale factor that + // should be used to scale up the UI. + 1. + default_font_size_delta + }; + + Some(buffer_font_size * rem_size_scale) + } } impl Element for TerminalElement { @@ -558,6 +590,7 @@ impl Element for TerminalElement { .request_layout(global_id, cx, |mut style, cx| { style.size.width = relative(1.).into(); style.size.height = relative(1.).into(); + // style.overflow = point(Overflow::Hidden, Overflow::Hidden); let layout_id = cx.request_layout(style, None); layout_id @@ -572,6 +605,7 @@ impl Element for TerminalElement { _: &mut Self::RequestLayoutState, cx: &mut WindowContext, ) -> Self::PrepaintState { + let rem_size = self.rem_size(cx); self.interactivity .prepaint(global_id, bounds, bounds.size, cx, |_, _, hitbox, cx| { let hitbox = hitbox.unwrap(); @@ -675,8 +709,9 @@ impl Element for TerminalElement { } }); + let scroll_top = self.terminal_view.read(cx).scroll_top; let hyperlink_tooltip = last_hovered_word.clone().map(|hovered_word| { - let offset = bounds.origin + Point::new(gutter, px(0.)); + let offset = bounds.origin + point(gutter, px(0.)) - point(px(0.), scroll_top); let mut element = div() .size_full() .id("terminal-element") @@ -695,6 +730,8 @@ impl Element for TerminalElement { cursor, .. } = &self.terminal.read(cx).last_content; + let mode = *mode; + let display_offset = *display_offset; // searches, highlights to a single range representations let mut relative_highlighted_ranges = Vec::new(); @@ -723,7 +760,7 @@ impl Element for TerminalElement { let cursor = if let AlacCursorShape::Hidden = cursor.shape { None } else { - let cursor_point = DisplayCursor::from(cursor.point, *display_offset); + let cursor_point = DisplayCursor::from(cursor.point, display_offset); let cursor_text = { let str_trxt = cursor_char.to_string(); let len = str_trxt.len(); @@ -768,6 +805,37 @@ impl Element for TerminalElement { ) }; + let block_below_cursor_element = if let Some(block) = &self.block_below_cursor { + let terminal = self.terminal.read(cx); + if terminal.last_content.display_offset == 0 { + let target_line = terminal.last_content.cursor.point.line.0 + 1; + let render = &block.render; + let mut block_cx = BlockContext { + context: cx, + dimensions, + }; + let element = render(&mut block_cx); + let mut element = div().occlude().child(element).into_any_element(); + let available_space = size( + AvailableSpace::Definite(dimensions.width() + gutter), + AvailableSpace::Definite( + block.height as f32 * dimensions.line_height(), + ), + ); + let origin = bounds.origin + + point(px(0.), target_line as f32 * dimensions.line_height()) + - point(px(0.), scroll_top); + cx.with_rem_size(rem_size, |cx| { + element.prepaint_as_root(origin, available_space, cx); + }); + Some(element) + } else { + None + } + } else { + None + }; + LayoutState { hitbox, cells, @@ -776,11 +844,12 @@ impl Element for TerminalElement { dimensions, rects, relative_highlighted_ranges, - mode: *mode, - display_offset: *display_offset, + mode, + display_offset, hyperlink_tooltip, gutter, last_hovered_word, + block_below_cursor_element, } }) } @@ -793,82 +862,92 @@ impl Element for TerminalElement { layout: &mut Self::PrepaintState, cx: &mut WindowContext<'_>, ) { - cx.paint_quad(fill(bounds, layout.background_color)); - let origin = bounds.origin + Point::new(layout.gutter, px(0.)); + cx.with_content_mask(Some(ContentMask { bounds }), |cx| { + let scroll_top = self.terminal_view.read(cx).scroll_top; - let terminal_input_handler = TerminalInputHandler { - terminal: self.terminal.clone(), - cursor_bounds: layout - .cursor - .as_ref() - .map(|cursor| cursor.bounding_rect(origin)), - workspace: self.workspace.clone(), - }; + cx.paint_quad(fill(bounds, layout.background_color)); + let origin = + bounds.origin + Point::new(layout.gutter, px(0.)) - Point::new(px(0.), scroll_top); - self.register_mouse_listeners(origin, layout.mode, &layout.hitbox, cx); - if self.can_navigate_to_selected_word && layout.last_hovered_word.is_some() { - cx.set_cursor_style(gpui::CursorStyle::PointingHand, &layout.hitbox); - } else { - cx.set_cursor_style(gpui::CursorStyle::IBeam, &layout.hitbox); - } + let terminal_input_handler = TerminalInputHandler { + terminal: self.terminal.clone(), + cursor_bounds: layout + .cursor + .as_ref() + .map(|cursor| cursor.bounding_rect(origin)), + workspace: self.workspace.clone(), + }; - let cursor = layout.cursor.take(); - let hyperlink_tooltip = layout.hyperlink_tooltip.take(); - self.interactivity - .paint(global_id, bounds, Some(&layout.hitbox), cx, |_, cx| { - cx.handle_input(&self.focus, terminal_input_handler); + self.register_mouse_listeners(origin, layout.mode, &layout.hitbox, cx); + if self.can_navigate_to_selected_word && layout.last_hovered_word.is_some() { + cx.set_cursor_style(gpui::CursorStyle::PointingHand, &layout.hitbox); + } else { + cx.set_cursor_style(gpui::CursorStyle::IBeam, &layout.hitbox); + } - cx.on_key_event({ - let this = self.terminal.clone(); - move |event: &ModifiersChangedEvent, phase, cx| { - if phase != DispatchPhase::Bubble { - return; + let cursor = layout.cursor.take(); + let hyperlink_tooltip = layout.hyperlink_tooltip.take(); + let block_below_cursor_element = layout.block_below_cursor_element.take(); + self.interactivity + .paint(global_id, bounds, Some(&layout.hitbox), cx, |_, cx| { + cx.handle_input(&self.focus, terminal_input_handler); + + cx.on_key_event({ + let this = self.terminal.clone(); + move |event: &ModifiersChangedEvent, phase, cx| { + if phase != DispatchPhase::Bubble { + return; + } + + let handled = this + .update(cx, |term, _| term.try_modifiers_change(&event.modifiers)); + + if handled { + cx.refresh(); + } } + }); - let handled = - this.update(cx, |term, _| term.try_modifiers_change(&event.modifiers)); + for rect in &layout.rects { + rect.paint(origin, &layout, cx); + } - if handled { - cx.refresh(); + for (relative_highlighted_range, color) in + layout.relative_highlighted_ranges.iter() + { + if let Some((start_y, highlighted_range_lines)) = + to_highlighted_range_lines(relative_highlighted_range, &layout, origin) + { + let hr = HighlightedRange { + start_y, + line_height: layout.dimensions.line_height, + lines: highlighted_range_lines, + color: *color, + corner_radius: 0.15 * layout.dimensions.line_height, + }; + hr.paint(bounds, cx); } } + + for cell in &layout.cells { + cell.paint(origin, &layout, bounds, cx); + } + + if self.cursor_visible { + if let Some(mut cursor) = cursor { + cursor.paint(origin, cx); + } + } + + if let Some(mut element) = block_below_cursor_element { + element.paint(cx); + } + + if let Some(mut element) = hyperlink_tooltip { + element.paint(cx); + } }); - - for rect in &layout.rects { - rect.paint(origin, &layout, cx); - } - - for (relative_highlighted_range, color) in layout.relative_highlighted_ranges.iter() - { - if let Some((start_y, highlighted_range_lines)) = - to_highlighted_range_lines(relative_highlighted_range, &layout, origin) - { - let hr = HighlightedRange { - start_y, //Need to change this - line_height: layout.dimensions.line_height, - lines: highlighted_range_lines, - color: *color, - //Copied from editor. TODO: move to theme or something - corner_radius: 0.15 * layout.dimensions.line_height, - }; - hr.paint(bounds, cx); - } - } - - for cell in &layout.cells { - cell.paint(origin, &layout, bounds, cx); - } - - if self.cursor_visible { - if let Some(mut cursor) = cursor { - cursor.paint(origin, cx); - } - } - - if let Some(mut element) = hyperlink_tooltip { - element.paint(cx); - } - }); + }); } } @@ -951,7 +1030,7 @@ impl InputHandler for TerminalInputHandler { } } -fn is_blank(cell: &IndexedCell) -> bool { +pub fn is_blank(cell: &IndexedCell) -> bool { if cell.c != ' ' { return false; } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index ec9b60b83a..7b9a5e4434 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -8,12 +8,12 @@ use futures::{stream::FuturesUnordered, StreamExt}; use gpui::{ anchored, deferred, div, impl_actions, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, KeyContext, KeyDownEvent, Keystroke, Model, MouseButton, - MouseDownEvent, Pixels, Render, Styled, Subscription, Task, View, VisualContext, WeakView, + MouseDownEvent, Pixels, Render, ScrollWheelEvent, Styled, Subscription, Task, View, + VisualContext, WeakView, }; use language::Bias; use persistence::TERMINAL_DB; use project::{search::SearchQuery, Fs, LocalWorktree, Metadata, Project}; -use settings::SettingsStore; use task::TerminalWorkDir; use terminal::{ alacritty_terminal::{ @@ -23,8 +23,9 @@ use terminal::{ terminal_settings::{TerminalBlink, TerminalSettings, WorkingDirectory}, Clear, Copy, Event, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskStatus, Terminal, + TerminalSize, }; -use terminal_element::TerminalElement; +use terminal_element::{is_blank, TerminalElement}; use ui::{h_flex, prelude::*, ContextMenu, Icon, IconName, Label, Tooltip}; use util::{paths::PathLikeWithPosition, ResultExt}; use workspace::{ @@ -39,10 +40,11 @@ use workspace::{ use anyhow::Context; use dirs::home_dir; use serde::Deserialize; -use settings::Settings; +use settings::{Settings, SettingsStore}; use smol::Timer; use std::{ + cmp, ops::RangeInclusive, path::{Path, PathBuf}, sync::Arc, @@ -79,6 +81,16 @@ pub fn init(cx: &mut AppContext) { .detach(); } +pub struct BlockProperties { + pub height: u8, + pub render: Box AnyElement>, +} + +pub struct BlockContext<'a, 'b> { + pub context: &'b mut WindowContext<'a>, + pub dimensions: TerminalSize, +} + ///A terminal view, maintains the PTY's file handles and communicates with the terminal pub struct TerminalView { terminal: Model, @@ -94,6 +106,8 @@ pub struct TerminalView { can_navigate_to_selected_word: bool, workspace_id: Option, show_title: bool, + block_below_cursor: Option>, + scroll_top: Pixels, _subscriptions: Vec, _terminal_subscriptions: Vec, } @@ -170,6 +184,8 @@ impl TerminalView { can_navigate_to_selected_word: false, workspace_id, show_title: TerminalSettings::get_global(cx).toolbar.title, + block_below_cursor: None, + scroll_top: Pixels::ZERO, _subscriptions: vec![ focus_in, focus_out, @@ -248,27 +264,123 @@ impl TerminalView { } fn clear(&mut self, _: &Clear, cx: &mut ViewContext) { + self.scroll_top = px(0.); self.terminal.update(cx, |term, _| term.clear()); cx.notify(); } + fn max_scroll_top(&self, cx: &AppContext) -> Pixels { + let terminal = self.terminal.read(cx); + + let Some(block) = self.block_below_cursor.as_ref() else { + return Pixels::ZERO; + }; + + let line_height = terminal.last_content().size.line_height; + let mut terminal_lines = terminal.total_lines(); + let viewport_lines = terminal.viewport_lines(); + if terminal.total_lines() == terminal.viewport_lines() { + let mut last_line = None; + for cell in terminal.last_content.cells.iter().rev() { + if !is_blank(cell) { + break; + } + + let last_line = last_line.get_or_insert(cell.point.line); + if *last_line != cell.point.line { + terminal_lines -= 1; + } + *last_line = cell.point.line; + } + } + + let max_scroll_top_in_lines = + (block.height as usize).saturating_sub(viewport_lines.saturating_sub(terminal_lines)); + + max_scroll_top_in_lines as f32 * line_height + } + + fn scroll_wheel( + &mut self, + event: &ScrollWheelEvent, + origin: gpui::Point, + cx: &mut ViewContext, + ) { + let terminal_content = self.terminal.read(cx).last_content(); + + if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 { + let line_height = terminal_content.size.line_height; + let y_delta = event.delta.pixel_delta(line_height).y; + if y_delta < Pixels::ZERO || self.scroll_top > Pixels::ZERO { + self.scroll_top = cmp::max( + Pixels::ZERO, + cmp::min(self.scroll_top - y_delta, self.max_scroll_top(cx)), + ); + cx.notify(); + return; + } + } + + self.terminal + .update(cx, |term, _| term.scroll_wheel(event, origin)); + } + fn scroll_line_up(&mut self, _: &ScrollLineUp, cx: &mut ViewContext) { + let terminal_content = self.terminal.read(cx).last_content(); + if self.block_below_cursor.is_some() + && terminal_content.display_offset == 0 + && self.scroll_top > Pixels::ZERO + { + let line_height = terminal_content.size.line_height; + self.scroll_top = cmp::max(self.scroll_top - line_height, Pixels::ZERO); + return; + } + self.terminal.update(cx, |term, _| term.scroll_line_up()); cx.notify(); } fn scroll_line_down(&mut self, _: &ScrollLineDown, cx: &mut ViewContext) { + let terminal_content = self.terminal.read(cx).last_content(); + if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 { + let max_scroll_top = self.max_scroll_top(cx); + if self.scroll_top < max_scroll_top { + let line_height = terminal_content.size.line_height; + self.scroll_top = cmp::min(self.scroll_top + line_height, max_scroll_top); + } + return; + } + self.terminal.update(cx, |term, _| term.scroll_line_down()); cx.notify(); } fn scroll_page_up(&mut self, _: &ScrollPageUp, cx: &mut ViewContext) { - self.terminal.update(cx, |term, _| term.scroll_page_up()); + if self.scroll_top == Pixels::ZERO { + self.terminal.update(cx, |term, _| term.scroll_page_up()); + } else { + let line_height = self.terminal.read(cx).last_content.size.line_height(); + let visible_block_lines = (self.scroll_top / line_height) as usize; + let viewport_lines = self.terminal.read(cx).viewport_lines(); + let visible_content_lines = viewport_lines - visible_block_lines; + + if visible_block_lines >= viewport_lines { + self.scroll_top = ((visible_block_lines - viewport_lines) as f32) * line_height; + } else { + self.scroll_top = px(0.); + self.terminal + .update(cx, |term, _| term.scroll_up_by(visible_content_lines)); + } + } cx.notify(); } fn scroll_page_down(&mut self, _: &ScrollPageDown, cx: &mut ViewContext) { self.terminal.update(cx, |term, _| term.scroll_page_down()); + let terminal = self.terminal.read(cx); + if terminal.last_content().display_offset < terminal.viewport_lines() { + self.scroll_top = self.max_scroll_top(cx); + } cx.notify(); } @@ -279,6 +391,9 @@ impl TerminalView { fn scroll_to_bottom(&mut self, _: &ScrollToBottom, cx: &mut ViewContext) { self.terminal.update(cx, |term, _| term.scroll_to_bottom()); + if self.block_below_cursor.is_some() { + self.scroll_top = self.max_scroll_top(cx); + } cx.notify(); } @@ -337,6 +452,18 @@ impl TerminalView { &self.terminal } + pub fn set_block_below_cursor(&mut self, block: BlockProperties, cx: &mut ViewContext) { + self.block_below_cursor = Some(Arc::new(block)); + self.scroll_to_bottom(&ScrollToBottom, cx); + cx.notify(); + } + + pub fn clear_block_below_cursor(&mut self, cx: &mut ViewContext) { + self.block_below_cursor = None; + self.scroll_top = Pixels::ZERO; + cx.notify(); + } + fn next_blink_epoch(&mut self) -> usize { self.blink_epoch += 1; self.blink_epoch @@ -761,6 +888,7 @@ impl TerminalView { impl Render for TerminalView { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let terminal_handle = self.terminal.clone(); + let terminal_view_handle = cx.view().clone(); let focused = self.focus_handle.is_focused(cx); @@ -796,11 +924,13 @@ impl Render for TerminalView { // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu div().size_full().child(TerminalElement::new( terminal_handle, + terminal_view_handle, self.workspace.clone(), self.focus_handle.clone(), focused, self.should_show_cursor(focused, cx), self.can_navigate_to_selected_word, + self.block_below_cursor.clone(), )), ) .children(self.context_menu.as_ref().map(|(menu, position, _)| {