Merge branch 'main' into fix-worktree-drag-reorder

This commit is contained in:
Elliot Thomas 2026-05-05 16:03:22 +01:00 committed by GitHub
commit d23a2ccf81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 370 additions and 61 deletions

View file

@ -1264,6 +1264,7 @@
"ctrl-cmd-space": "terminal::ShowCharacterPalette",
"cmd-c": "terminal::Copy",
"cmd-v": "terminal::Paste",
"ctrl-cmd-v": "terminal::PasteText",
"cmd-f": "buffer_search::Deploy",
"cmd-a": "editor::SelectAll",
"cmd-k": "terminal::Clear",

View file

@ -1221,6 +1221,7 @@
"shift-insert": "terminal::Paste",
"ctrl-v": "terminal::Paste",
"ctrl-shift-v": "terminal::Paste",
"ctrl-alt-v": "terminal::PasteText",
"ctrl-i": "assistant::InlineAssist",
"alt-b": ["terminal::SendText", "\u001bb"],
"alt-f": ["terminal::SendText", "\u001bf"],

View file

@ -30,6 +30,33 @@ mod web_search_tool;
use crate::AgentTool;
use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat};
use serde::{
Deserialize, Deserializer,
de::{DeserializeOwned, Error as _},
};
/// Deserialize a value that may have been provided as a JSON-encoded string
/// instead of the structured value. Some models occasionally stringify nested
/// arguments, so we accept either form.
pub(crate) fn deserialize_maybe_stringified<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where
T: DeserializeOwned,
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum ValueOrJsonString<T> {
Value(T),
String(String),
}
match ValueOrJsonString::<T>::deserialize(deserializer)? {
ValueOrJsonString::Value(value) => Ok(value),
ValueOrJsonString::String(string) => serde_json::from_str::<T>(&string).map_err(|error| {
D::Error::custom(format!("failed to parse stringified value: {error}"))
}),
}
}
pub use apply_code_action_tool::*;
pub use context_server_registry::*;

View file

@ -2,6 +2,7 @@ mod reindent;
mod streaming_fuzzy_matcher;
mod streaming_parser;
use super::deserialize_maybe_stringified;
use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
use super::save_file_tool::SaveFileTool;
use crate::ToolInputPayload;
@ -24,10 +25,7 @@ use language_model::LanguageModelToolResultContent;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
use project::{AgentLocation, Project, ProjectPath};
use schemars::JsonSchema;
use serde::{
Deserialize, Deserializer, Serialize,
de::{DeserializeOwned, Error as _},
};
use serde::{Deserialize, Serialize};
use std::ops::Range;
use std::path::PathBuf;
use std::sync::Arc;
@ -134,26 +132,6 @@ pub struct PartialEdit {
pub new_text: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum ValueOrJsonString<T> {
Value(T),
String(String),
}
fn deserialize_maybe_stringified<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where
T: DeserializeOwned,
D: Deserializer<'de>,
{
match ValueOrJsonString::<T>::deserialize(deserializer)? {
ValueOrJsonString::Value(value) => Ok(value),
ValueOrJsonString::String(string) => serde_json::from_str::<T>(&string).map_err(|error| {
D::Error::custom(format!("failed to parse stringified value: {error}"))
}),
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum EditFileToolOutput {

View file

@ -6,6 +6,7 @@ use gpui::{App, SharedString, Task};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::deserialize_maybe_stringified;
use crate::{AgentTool, ToolCallEventStream, ToolInput};
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
@ -23,6 +24,7 @@ pub enum Timezone {
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct NowToolInput {
/// The timezone to use for the datetime. Use `utc` for UTC, or `local` for the system's local time.
#[serde(deserialize_with = "deserialize_maybe_stringified")]
timezone: Timezone,
}
@ -62,3 +64,28 @@ impl AgentTool for NowTool {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use serde_json::json;
#[gpui::test]
async fn test_stringified_timezone_input_succeeds(cx: &mut TestAppContext) {
let tool = Arc::new(NowTool);
let (mut sender, input) = ToolInput::<NowToolInput>::test();
let (event_stream, _receiver) = ToolCallEventStream::test();
let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
sender.send_full(json!({
"timezone": "\"utc\""
}));
let result = task.await.unwrap();
assert!(
result.starts_with("The current datetime is "),
"unexpected output: {result}"
);
}
}

View file

@ -11,9 +11,10 @@ use git::{
};
use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon};
use gpui::{
Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DragMoveEvent,
ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, PathBuilder, Pixels,
Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement,
Action, Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength,
DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable,
Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollStrategy,
ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement,
UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*,
px, uniform_list,
};
@ -278,6 +279,8 @@ impl SplitState {
actions!(
git_graph,
[
/// Copies the SHA of the selected commit to the clipboard.
CopyCommitSha,
/// Opens the commit view for the selected commit.
OpenCommitView,
/// Focuses the search field.
@ -981,13 +984,20 @@ fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) {
})
}
struct GitGraphContextMenu {
menu: Entity<ContextMenu>,
position: Point<Pixels>,
entry_idx: usize,
_subscription: Subscription,
}
pub struct GitGraph {
focus_handle: FocusHandle,
search_state: SearchState,
graph_data: GraphData,
git_store: Entity<GitStore>,
workspace: WeakEntity<Workspace>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
context_menu: Option<GitGraphContextMenu>,
table_interaction_state: Entity<TableInteractionState>,
column_widths: Entity<RedistributableColumnsState>,
selected_entry_idx: Option<usize>,
@ -1010,6 +1020,7 @@ impl GitGraph {
self.search_state.matches.clear();
self.search_state.selected_index = None;
self.search_state.state.next_state();
self.context_menu = None;
cx.emit(ItemEvent::Edit);
cx.notify();
}
@ -1328,6 +1339,10 @@ impl GitGraph {
git_store.repositories().get(&self.repo_id).cloned()
}
fn has_context_menu(&self) -> bool {
self.context_menu.is_some()
}
/// Checks whether a ref name from git's `%D` decoration
/// format refers to the currently checked-out branch.
fn is_head_ref(ref_name: &str, head_branch_name: &Option<SharedString>) -> bool {
@ -1374,6 +1389,7 @@ impl GitGraph {
});
let row_height = Self::row_height(window, cx);
let has_context_menu = self.has_context_menu();
// We fetch data outside the visible viewport to avoid loading entries when
// users scroll through the git graph
@ -1481,7 +1497,9 @@ impl GitGraph {
div()
.id(ElementId::NamedInteger("commit-subject".into(), idx as u64))
.overflow_hidden()
.tooltip(Tooltip::text(subject))
.when(!has_context_menu, |this| {
this.tooltip(Tooltip::text(subject))
})
.child(
h_flex()
.gap_2()
@ -1851,6 +1869,96 @@ impl GitGraph {
);
}
fn copy_commit_sha(&mut self, entry_index: usize, cx: &mut Context<Self>) {
let Some(commit) = self.graph_data.commits.get(entry_index) else {
return;
};
cx.write_to_clipboard(ClipboardItem::new_string(commit.data.sha.to_string()));
}
fn copy_selected_commit_sha(
&mut self,
_: &CopyCommitSha,
_: &mut Window,
cx: &mut Context<Self>,
) {
let Some(selected_entry_index) = self.selected_entry_idx else {
return;
};
self.copy_commit_sha(selected_entry_index, cx);
}
fn deploy_entry_context_menu(
&mut self,
position: Point<Pixels>,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(commit) = self.graph_data.commits.get(index) else {
return;
};
let short_sha = commit.data.sha.display_short();
let focus_handle = self.focus_handle.clone();
let git_graph = cx.entity();
let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
context_menu
.context(focus_handle)
.header(format!("Commit {short_sha}"))
.entry(
"View Commit",
Some(OpenCommitView.boxed_clone()),
window.handler_for(&git_graph, move |this, window, cx| {
this.open_commit_view(index, window, cx);
}),
)
.entry(
"Copy SHA",
Some(CopyCommitSha.boxed_clone()),
window.handler_for(&git_graph, move |this, _window, cx| {
this.copy_commit_sha(index, cx);
}),
)
});
self.set_context_menu(context_menu, position, index, window, cx);
}
fn set_context_menu(
&mut self,
context_menu: Entity<ContextMenu>,
position: Point<Pixels>,
entry_idx: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe_in(
&context_menu,
window,
|this, _, _: &DismissEvent, window, cx| {
if this.context_menu.as_ref().is_some_and(|context_menu| {
context_menu
.menu
.focus_handle(cx)
.contains_focused(window, cx)
}) {
cx.focus_self(window);
}
this.context_menu.take();
cx.notify();
},
);
self.context_menu = Some(GitGraphContextMenu {
menu: context_menu,
position,
entry_idx,
_subscription: subscription,
});
cx.notify();
}
fn get_remote(
&self,
repository: &Repository,
@ -2434,6 +2542,7 @@ impl GitGraph {
let hovered_entry_idx = self.hovered_entry_idx;
let selected_entry_idx = self.selected_entry_idx;
let context_menu_entry_idx = self.context_menu.as_ref().map(|menu| menu.entry_idx);
let is_focused = self.focus_handle.is_focused(window);
let graph_canvas_bounds = self.graph_canvas_bounds.clone();
@ -2456,8 +2565,10 @@ impl GitGraph {
let absolute_row_idx = first_visible_row + visible_row_idx;
let is_hovered = hovered_entry_idx == Some(absolute_row_idx);
let is_selected = selected_entry_idx == Some(absolute_row_idx);
let is_context_menu_target =
context_menu_entry_idx == Some(absolute_row_idx);
if is_hovered || is_selected {
if is_hovered || is_selected || is_context_menu_target {
let row_y = bounds.origin.y + visible_row_idx as f32 * row_height
- vertical_scroll_offset;
@ -2469,7 +2580,11 @@ impl GitGraph {
},
);
let bg_color = if is_selected { selected_bg } else { hover_bg };
let bg_color = if is_selected || is_context_menu_target {
selected_bg
} else {
hover_bg
};
window.paint_quad(gpui::fill(row_bounds, bg_color));
}
}
@ -2697,6 +2812,31 @@ impl GitGraph {
}
}
fn handle_entry_click(
&mut self,
entry_idx: usize,
event: &ClickEvent,
scroll_strategy: ScrollStrategy,
focus_handle: Option<&FocusHandle>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Right-clicks open the context menu, not the details panel.
if event.is_right_click() {
return;
}
if let Some(focus_handle) = focus_handle {
focus_handle.focus(window, cx);
}
self.select_entry(entry_idx, scroll_strategy, cx);
if event.click_count() >= 2 {
self.open_commit_view(entry_idx, window, cx);
}
}
fn handle_graph_click(
&mut self,
event: &ClickEvent,
@ -2704,13 +2844,34 @@ impl GitGraph {
cx: &mut Context<Self>,
) {
if let Some(row) = self.row_at_position(event.position().y, window, cx) {
self.select_entry(row, ScrollStrategy::Nearest, cx);
if event.click_count() >= 2 {
self.open_commit_view(row, window, cx);
}
self.handle_entry_click(row, event, ScrollStrategy::Nearest, None, window, cx);
}
}
fn handle_entry_secondary_mouse_down(
&mut self,
entry_idx: usize,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.deploy_entry_context_menu(event.position, entry_idx, window, cx);
cx.stop_propagation();
}
fn handle_graph_secondary_mouse_down(
&mut self,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(row) = self.row_at_position(event.position.y, window, cx) else {
return;
};
self.handle_entry_secondary_mouse_down(row, event, window, cx);
}
fn handle_graph_scroll(
&mut self,
event: &ScrollWheelEvent,
@ -2905,6 +3066,8 @@ impl Render for GitGraph {
let row_height = Self::row_height(window, cx);
let selected_entry_idx = self.selected_entry_idx;
let hovered_entry_idx = self.hovered_entry_idx;
let context_menu_entry_idx =
self.context_menu.as_ref().map(|menu| menu.entry_idx);
let weak_self = cx.weak_entity();
let focus_handle = self.focus_handle.clone();
let table_focus_handle =
@ -2923,6 +3086,10 @@ impl Render for GitGraph {
.on_scroll_wheel(cx.listener(Self::handle_graph_scroll))
.on_mouse_move(cx.listener(Self::handle_graph_mouse_move))
.on_click(cx.listener(Self::handle_graph_click))
.on_mouse_down(
MouseButton::Right,
cx.listener(Self::handle_graph_secondary_mouse_down),
)
.on_hover(cx.listener(|this, &is_hovered: &bool, _, cx| {
if !is_hovered && this.hovered_entry_idx.is_some() {
this.hovered_entry_idx = None;
@ -2938,11 +3105,14 @@ impl Render for GitGraph {
.map_row(move |(index, row), window, cx| {
let is_selected = selected_entry_idx == Some(index);
let is_hovered = hovered_entry_idx == Some(index);
let is_context_menu_target =
context_menu_entry_idx == Some(index);
let table_focus_handle = table_focus_handle.clone();
let is_focused = focus_handle.is_focused(window)
|| table_focus_handle.is_focused(window);
let weak = weak_self.clone();
let weak_for_hover = weak.clone();
let weak_for_context_menu = weak.clone();
let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
let selected_bg = if is_focused {
@ -2953,8 +3123,13 @@ impl Render for GitGraph {
row.h(row_height)
.cursor_pointer()
.when(is_selected, |row| row.bg(selected_bg))
.when(is_hovered && !is_selected, |row| row.bg(hover_bg))
.when(is_selected || is_context_menu_target, |row| {
row.bg(selected_bg)
})
.when(
is_hovered && !is_selected && !is_context_menu_target,
|row| row.bg(hover_bg),
)
.on_hover(move |&is_hovered, _, cx| {
weak_for_hover
.update(cx, |this, cx| {
@ -2972,20 +3147,30 @@ impl Render for GitGraph {
.ok();
})
.on_click(move |event, window, cx| {
let click_count = event.click_count();
table_focus_handle.focus(window, cx);
weak.update(cx, |this, cx| {
this.select_entry(
this.handle_entry_click(
index,
event,
ScrollStrategy::Center,
Some(&table_focus_handle),
window,
cx,
);
if click_count >= 2 {
this.open_commit_view(index, window, cx);
}
})
.ok();
})
.on_mouse_down(
MouseButton::Right,
move |event: &MouseDownEvent, window, cx| {
weak_for_context_menu
.update(cx, |this, cx| {
this.handle_entry_secondary_mouse_down(
index, event, window, cx,
);
})
.ok();
},
)
.into_any_element()
})
.uniform_list(
@ -3057,6 +3242,7 @@ impl Render for GitGraph {
.on_action(cx.listener(|this, _: &OpenCommitView, window, cx| {
this.open_selected_commit_view(window, cx);
}))
.on_action(cx.listener(Self::copy_selected_commit_sha))
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(|this, _: &FocusSearch, window, cx| {
this.search_state
@ -3091,12 +3277,12 @@ impl Render for GitGraph {
.child(self.render_search_bar(cx))
.child(div().flex_1().child(content)),
)
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
.children(self.context_menu.as_ref().map(|context_menu| {
deferred(
anchored()
.position(*position)
.position(context_menu.position)
.anchor(Anchor::TopLeft)
.child(menu.clone()),
.child(context_menu.menu.clone()),
)
.with_priority(1)
}))

View file

@ -278,6 +278,7 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider {
protocol,
reasoning_effort_levels: model.reasoning_effort_levels.clone(),
custom_model_api_url: model.custom_model_api_url.clone(),
interleaved_reasoning: model.interleaved_reasoning,
};
let key = format!("{}/{}", subscription.id_prefix(), model.name);
models.insert(key, (custom_model, subscription));
@ -664,7 +665,7 @@ impl LanguageModel for OpenCodeLanguageModel {
false,
self.model.max_output_tokens(),
reasoning_effort,
false,
self.model.interleaved_reasoning(),
);
let stream = self.stream_openai_chat(openai_request, http_client, cx);
async move {

View file

@ -168,6 +168,7 @@ pub enum Model {
protocol: ApiProtocol,
reasoning_effort_levels: Option<Vec<ReasoningEffort>>,
custom_model_api_url: Option<String>,
interleaved_reasoning: bool,
},
}
@ -385,8 +386,6 @@ impl Model {
Self::Gemini3_1Pro | Self::Gemini3Flash => ApiProtocol::Google,
Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => ApiProtocol::Anthropic,
Self::MiniMaxM2_5Free
| Self::Glm5
| Self::Glm5_1
@ -398,6 +397,8 @@ impl Model {
| Self::MimoV2_5
| Self::Qwen3_5Plus
| Self::Qwen3_6Plus
| Self::DeepSeekV4Pro
| Self::DeepSeekV4Flash
| Self::BigPickle
| Self::Nemotron3SuperFree
| Self::Ling2_6FlashFree
@ -407,6 +408,27 @@ impl Model {
}
}
pub fn interleaved_reasoning(&self) -> bool {
match self {
Self::DeepSeekV4Pro
| Self::DeepSeekV4Flash
| Self::KimiK2_5
| Self::KimiK2_6
| Self::MimoV2Omni
| Self::MimoV2_5
| Self::MimoV2_5Pro
| Self::Glm5
| Self::Glm5_1 => true,
Self::Custom {
interleaved_reasoning,
..
} => *interleaved_reasoning,
_ => false,
}
}
pub fn max_token_count(&self) -> u64 {
match self {
// Anthropic models
@ -487,9 +509,6 @@ impl Model {
// Google models
Self::Gemini3_1Pro | Self::Gemini3Flash => Some(65_536),
// Anthropic-compatible models
Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(384_000),
// OpenAI-compatible models
Self::MiniMaxM2_7 => Some(131_072),
Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => Some(131_072),
@ -497,6 +516,7 @@ impl Model {
Self::BigPickle => Some(128_000),
Self::KimiK2_6 | Self::KimiK2_5 => Some(65_536),
Self::Qwen3_5Plus | Self::Qwen3_6Plus => Some(65_536),
Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(384_000),
Self::Nemotron3SuperFree => Some(128_000),
Self::MimoV2_5Pro | Self::MimoV2_5 | Self::MimoV2Pro | Self::MimoV2Omni => {
Some(128_000)
@ -565,14 +585,13 @@ impl Model {
| Self::MiniMaxM2_7
| Self::MimoV2Pro
| Self::MimoV2_5Pro
| Self::DeepSeekV4Pro
| Self::DeepSeekV4Flash
| Self::BigPickle
| Self::Nemotron3SuperFree
| Self::Ling2_6FlashFree
| Self::Hy3PreviewFree => false,
// DeepSeek models (Anthropic protocol) don't support images
Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => false,
Self::Custom { protocol, .. } => matches!(
protocol,
ApiProtocol::Anthropic

View file

@ -18,6 +18,7 @@ use rpc::{
proto::{self, ExternalExtensionAgent},
};
use schemars::JsonSchema;
use semver::Version;
use serde::{Deserialize, Serialize};
use settings::{RegisterSetting, SettingsStore};
use sha2::{Digest, Sha256};
@ -1535,7 +1536,7 @@ impl ExternalAgentServer for LocalRegistryNpxAgent {
let node_runtime = self.node_runtime.clone();
let project_environment = self.project_environment.downgrade();
let registry_id = self.registry_id.clone();
let package = self.package.clone();
let package = bounded_npm_package_spec(&self.package);
let args = self.args.clone();
let distribution_env = self.distribution_env.clone();
let settings_env = self.settings_env.clone();
@ -1554,7 +1555,7 @@ impl ExternalAgentServer for LocalRegistryNpxAgent {
.join(sanitize_path_component(&registry_id));
fs.create_dir(&prefix_dir).await?;
let mut exec_args = vec!["--yes".to_string(), "--".to_string(), package.to_string()];
let mut exec_args = vec!["--yes".to_string(), "--".to_string(), package];
exec_args.extend(args);
let npm_command = node_runtime
@ -1592,6 +1593,30 @@ impl ExternalAgentServer for LocalRegistryNpxAgent {
}
}
/// People are using min-release-age more frequently. Which means a fresh registry will likely have
/// new package versions than the user can install.
/// We set the version to now be a ceiling and not an exact pin instead. This allows npm to resolve
/// the latest version it can find that satisfies the constraint. npm seems to check regularly enough
/// that new versions are available. This does have a few downsides:
/// - The user might have an older cached version of the package that satisfies the constraint, until
/// npm checks for updates again.
/// - The registry args/env may not be valid for the resolved version.
///
/// This is a best-effort attempt to install a version that works without overriding the user's
/// security settings, as the args don't change often. The registry will need to support this better
/// at some point, but until then, this is a best-effort workaround that hopefully solves the issue
/// for most users.
fn bounded_npm_package_spec(package_spec: &str) -> String {
let Some((package_name, version)) = package_spec.rsplit_once('@') else {
return package_spec.to_string();
};
if package_name.is_empty() || Version::parse(version).is_err() {
return package_spec.to_string();
}
format!("{package_name}@<={version}")
}
struct LocalCustomAgent {
project_environment: Entity<ProjectEnvironment>,
command: AgentServerCommand,
@ -1996,6 +2021,26 @@ mod tests {
})
}
#[test]
fn builds_bounded_npm_package_specs() {
assert_eq!(
bounded_npm_package_spec("agent-package@1.2.3"),
"agent-package@<=1.2.3"
);
assert_eq!(
bounded_npm_package_spec("@scope/agent-package@1.2.3-beta.1"),
"@scope/agent-package@<=1.2.3-beta.1"
);
assert_eq!(
bounded_npm_package_spec("@scope/agent-package"),
"@scope/agent-package"
);
assert_eq!(
bounded_npm_package_spec("agent-package@latest"),
"agent-package@latest"
);
}
#[test]
fn detects_supported_archive_suffixes() {
assert!(matches!(

View file

@ -181,6 +181,9 @@ pub struct OpenCodeAvailableModel {
pub custom_model_api_url: Option<String>,
/// Supported reasoning effort levels, for example `["low", "medium", "high"].
pub reasoning_effort_levels: Option<Vec<ReasoningEffort>>,
/// When using OpenAiChat protocol, whether thinking tokens are sent as a dedicated `reasoning_content` field or inline in message text.
#[serde(default)]
pub interleaved_reasoning: bool,
}
#[with_fallible_options]

View file

@ -84,6 +84,8 @@ actions!(
Copy,
/// Pastes from the clipboard.
Paste,
/// Pastes the text from the clipboard.
PasteText,
/// Shows the character palette for special characters.
ShowCharacterPalette,
/// Searches for text in the terminal.

View file

@ -32,9 +32,9 @@ use std::{
};
use task::TaskId;
use terminal::{
Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp,
ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskState,
TaskStatus, Terminal, TerminalBounds, ToggleViMode,
Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, PasteText, ScrollLineDown,
ScrollLineUp, ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette,
TaskState, TaskStatus, Terminal, TerminalBounds, ToggleViMode,
alacritty_terminal::{
index::Point as AlacPoint,
term::{TermMode, point_to_viewport, search::RegexSearch},
@ -508,6 +508,7 @@ impl TerminalView {
.separator()
.action("Copy", Box::new(Copy))
.action("Paste", Box::new(Paste))
.action("Paste Text", Box::new(PasteText))
.action("Select All", Box::new(SelectAll))
.action("Clear", Box::new(Clear))
.when(assistant_enabled, |menu| {
@ -811,7 +812,7 @@ impl TerminalView {
}
///Attempt to paste the clipboard into the terminal
fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
let Some(clipboard) = cx.read_from_clipboard() else {
return;
};
@ -820,6 +821,9 @@ impl TerminalView {
Some(ClipboardEntry::Image(image)) if !image.bytes.is_empty() => {
self.forward_ctrl_v(cx);
}
Some(ClipboardEntry::ExternalPaths(paths)) => {
self.add_paths_to_terminal(paths.paths(), window, cx);
}
_ => {
if let Some(text) = clipboard.text() {
self.terminal
@ -829,6 +833,18 @@ impl TerminalView {
}
}
///Attempt to paste the clipboard text into the terminal
fn paste_text(&mut self, _: &PasteText, _: &mut Window, cx: &mut Context<Self>) {
let Some(clipboard) = cx.read_from_clipboard() else {
return;
};
if let Some(text) = clipboard.text() {
self.terminal
.update(cx, |terminal, _cx| terminal.paste(&text));
}
}
/// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly
/// and attach images using their native workflows.
fn forward_ctrl_v(&self, cx: &mut Context<Self>) {
@ -1226,6 +1242,7 @@ impl Render for TerminalView {
.on_action(cx.listener(TerminalView::send_keystroke))
.on_action(cx.listener(TerminalView::copy))
.on_action(cx.listener(TerminalView::paste))
.on_action(cx.listener(TerminalView::paste_text))
.on_action(cx.listener(TerminalView::clear))
.on_action(cx.listener(TerminalView::scroll_line_up))
.on_action(cx.listener(TerminalView::scroll_line_down))

View file

@ -663,6 +663,7 @@ The Zed agent comes pre-configured with OpenCode models. If you wish to use newe
"max_output_tokens": 98765,
"protocol": "openai_chat",
"reasoning_effort_levels": ["low", "medium", "high"],
"interleaved_reasoning": false,
"subscription": "go",
"custom_model_api_url": "https://example.com/zen"
}
@ -680,6 +681,7 @@ The available configuration options for custom models are:
- `max_output_tokens` (optional): maximum tokens the model can generate, for example `64000`
- `protocol` (required): model API protocol, one of `"anthropic"`, `"openai_responses"`, `"openai_chat"`, or `"google"`
- `reasoning_effort_levels` (optional): list of supported reasoning effort levels, for example `["low", "medium", "high"]`. The latest value in the list is used as the default
- `interleaved_reasoning` (optional, default `false`): if thinking tokens are sent as a dedicated `reasoning_content` field (`true`) or inline in message text (`false`). Applies only when using the `openai_chat` protocol
- `subscription` (optional): `"zen"`, `"go"`, or `"free"` (defaults to `"zen"`)
- `custom_model_api_url` (optional): custom API base URL to use instead of the default OpenCode API