Cargo.lock π
@@ -417,6 +417,7 @@ dependencies = [
"strsim 0.11.1",
"strum",
"telemetry_events",
+ "terminal",
"terminal_view",
"theme",
"tiktoken-rs",
Bennet Bo Fenner and Antonio created
Release Notes:
- N/A
---------
Co-authored-by: Antonio <antonio@zed.dev>
Cargo.lock | 1
assets/keymaps/default-linux.json | 1
assets/keymaps/default-macos.json | 1
crates/assistant/Cargo.toml | 1
crates/assistant/src/assistant.rs | 2
crates/assistant/src/assistant_panel.rs | 156 +
crates/assistant/src/prompts.rs | 24
crates/assistant/src/terminal_inline_assistant.rs | 1122 +++++++++++++++++
crates/terminal/src/terminal.rs | 29
crates/terminal_view/src/terminal_element.rs | 243 ++-
crates/terminal_view/src/terminal_view.rs | 140 ++
11 files changed, 1,583 insertions(+), 137 deletions(-)
@@ -417,6 +417,7 @@ dependencies = [
"strsim 0.11.1",
"strum",
"telemetry_events",
+ "terminal",
"terminal_view",
"theme",
"tiktoken-rs",
@@ -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"],
@@ -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"],
@@ -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
@@ -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<dyn Fs>, client: Arc<Client>, 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| {
@@ -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<Editor>, bool),
+ Terminal(View<TerminalView>),
+}
+
impl EventEmitter<SavedContextPickerEvent> for Picker<SavedContextPickerDelegate> {}
impl SavedContextPickerDelegate {
@@ -369,65 +376,68 @@ impl AssistantPanel {
return;
};
- let context_editor = assistant_panel
- .read(cx)
- .active_context_editor()
- .and_then(|editor| {
- let editor = &editor.read(cx).editor;
- if editor.read(cx).is_focused(cx) {
- Some(editor.clone())
- } else {
- None
- }
- });
-
- let include_context;
- let active_editor;
- if let Some(context_editor) = context_editor {
- active_editor = context_editor;
- include_context = false;
- } else if let Some(workspace_editor) = workspace
- .active_item(cx)
- .and_then(|item| item.act_as::<Editor>(cx))
- {
- active_editor = workspace_editor;
- include_context = true;
- } else {
+ let Some(inline_assist_target) =
+ Self::resolve_inline_assist_target(workspace, &assistant_panel, cx)
+ 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,
- )
- })
+ 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, |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,
- )
- })
+ 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| {
@@ -441,6 +451,52 @@ impl AssistantPanel {
}
}
+ fn resolve_inline_assist_target(
+ workspace: &mut Workspace,
+ assistant_panel: &View<AssistantPanel>,
+ cx: &mut WindowContext,
+ ) -> Option<InlineAssistTarget> {
+ if let Some(terminal_panel) = workspace.panel::<TerminalPanel>(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::<TerminalView>())
+ {
+ return Some(InlineAssistTarget::Terminal(terminal_view));
+ }
+ }
+ }
+ let context_editor = assistant_panel
+ .read(cx)
+ .active_context_editor()
+ .and_then(|editor| {
+ let editor = &editor.read(cx).editor;
+ if editor.read(cx).is_focused(cx) {
+ Some(editor.clone())
+ } else {
+ None
+ }
+ });
+
+ if let Some(context_editor) = context_editor {
+ Some(InlineAssistTarget::Editor(context_editor, false))
+ } else if let Some(workspace_editor) = workspace
+ .active_item(cx)
+ .and_then(|item| item.act_as::<Editor>(cx))
+ {
+ Some(InlineAssistTarget::Editor(workspace_editor, true))
+ } else {
+ None
+ }
+ }
+
fn new_context(&mut self, cx: &mut ViewContext<Self>) -> Option<View<ContextEditor>> {
let workspace = self.workspace.upgrade()?;
@@ -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
+}
@@ -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<dyn Fs>, telemetry: Arc<Telemetry>, 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<TerminalInlineAssistId, TerminalInlineAssist>,
+ prompt_history: VecDeque<String>,
+ telemetry: Option<Arc<Telemetry>>,
+ fs: Arc<dyn Fs>,
+}
+
+impl Global for TerminalInlineAssistant {}
+
+impl TerminalInlineAssistant {
+ pub fn new(fs: Arc<dyn Fs>, telemetry: Arc<Telemetry>) -> 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<TerminalView>,
+ workspace: Option<WeakView<Workspace>>,
+ assistant_panel: Option<&View<AssistantPanel>>,
+ 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<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 => {
+ 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<LanguageModelRequest> {
+ 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::<AssistantPanel>(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<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::identified::<InlineAssistantError>(
+ 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<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 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::<AssistantSettings>(
+ 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<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;
+ 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<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 | 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<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 = 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::<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.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<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 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<Arc<Telemetry>>,
+ terminal: Model<Terminal>,
+ generation: Task<()>,
+ 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(()),
+ transaction: None,
+ }
+ }
+
+ pub fn start(&mut self, prompt: LanguageModelRequest, cx: &mut ModelContext<Self>) {
+ 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>) {
+ 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),
+}
@@ -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<PathBuf> {
+ 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 {
@@ -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<AnyElement>,
gutter: Pixels,
last_hovered_word: Option<HoveredWord>,
+ block_below_cursor_element: Option<AnyElement>,
}
/// 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>,
+ terminal_view: View<TerminalView>,
workspace: WeakView<Workspace>,
focus: FocusHandle,
focused: bool,
cursor_visible: bool,
can_navigate_to_selected_word: bool,
interactivity: Interactivity,
+ block_below_cursor: Option<Arc<BlockProperties>>,
}
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>,
+ terminal_view: View<TerminalView>,
workspace: WeakView<Workspace>,
focus: FocusHandle,
focused: bool,
cursor_visible: bool,
can_navigate_to_selected_word: bool,
+ block_below_cursor: Option<Arc<BlockProperties>>,
) -> 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<AlacPoint>)>,
- cx: &WindowContext<'_>,
+ cx: &WindowContext,
) -> (Vec<LayoutCell>, Vec<LayoutRect>) {
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<Pixels> {
+ 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.));
-
- 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.with_content_mask(Some(ContentMask { bounds }), |cx| {
+ let scroll_top = self.terminal_view.read(cx).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);
- }
+ cx.paint_quad(fill(bounds, layout.background_color));
+ let origin =
+ bounds.origin + Point::new(layout.gutter, px(0.)) - Point::new(px(0.), scroll_top);
- 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);
-
- cx.on_key_event({
- let this = self.terminal.clone();
- move |event: &ModifiersChangedEvent, phase, cx| {
- if phase != DispatchPhase::Bubble {
- return;
- }
+ 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 handled =
- this.update(cx, |term, _| term.try_modifiers_change(&event.modifiers));
+ 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);
+ }
- if handled {
- cx.refresh();
+ 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();
+ }
}
- }
- });
+ });
- for rect in &layout.rects {
- rect.paint(origin, &layout, 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)
+ for (relative_highlighted_range, color) in
+ layout.relative_highlighted_ranges.iter()
{
- 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);
+ 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);
- }
+ 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 self.cursor_visible {
+ if let Some(mut cursor) = cursor {
+ cursor.paint(origin, cx);
+ }
}
- }
- if let Some(mut element) = hyperlink_tooltip {
- element.paint(cx);
- }
- });
+ if let Some(mut element) = block_below_cursor_element {
+ element.paint(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;
}
@@ -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<dyn Send + Fn(&mut BlockContext) -> 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<Terminal>,
@@ -94,6 +106,8 @@ pub struct TerminalView {
can_navigate_to_selected_word: bool,
workspace_id: Option<WorkspaceId>,
show_title: bool,
+ block_below_cursor: Option<Arc<BlockProperties>>,
+ scroll_top: Pixels,
_subscriptions: Vec<Subscription>,
_terminal_subscriptions: Vec<Subscription>,
}
@@ -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>) {
+ 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<Pixels>,
+ cx: &mut ViewContext<Self>,
+ ) {
+ 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<Self>) {
+ 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<Self>) {
+ 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>) {
- 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>) {
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>) {
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>) {
+ 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>) {
+ 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<Self>) -> 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, _)| {