Compare commits

...
Author SHA1 Message Date
arookieofc 108da537f4 feat(agents): list and import sessions started outside OpenIDEA (#2640)
* feat(agents): list and import sessions started outside OpenIDEA

Sessions started in a terminal, another editor or the CLIs never showed
up in OpenIDEA, so users had to re-explain their work (#312). The agent
history popup now lists them under Sessions from other apps: Claude Code
transcripts and Codex rollouts on this machine, with title, working
directory and age. Importing one creates a task whose first turn loads
and replays the transcript through the existing session/load path.

* i18n: add zh translation for 'Sessions from other apps'
2026-11-11 12:42:01 +08:00
Deng Liuanddengliu c658b6e59f feat(terminal): highlight visible occurrences of selected text (#2638)
Selecting text in the terminal highlights its other occurrences in the visible rows, like the editor's identifier-under-caret highlight. Only the viewport (plus a few rows of margin) is scanned, matches are cached per logical line and drawn in one overlay layer, recomputation is coalesced to one pass per animation frame, and highlights are cleared during heavy output and redrawn once it settles. Wrapped lines and wide characters map to the right cells; multi-line, very short or whitespace-only selections, the alternate buffer and Find-bar selections are skipped. Settings › Tools › Terminal › Highlight occurrences of selected text turns it off.

Co-authored-by: dengliu <[email protected]>
2026-11-11 12:41:33 +08:00
Deng Liuanddengliu 990a1c7a87 feat(agent): retry session setup after ACP authenticate on auth error (#2637)
When session/load or session/new fails with an authentication error and the agent advertises a non-terminal `agent` authentication method in `initialize`, the IDE now calls ACP `authenticate` with that method once per session setup and retries the failed call before falling back to the sign-in error. Terminal-login methods (`_meta.terminal-auth`) keep using the existing sign-in guidance.

Co-authored-by: dengliu <[email protected]>
2026-11-11 12:37:34 +08:00
l0ng-ai a58a7a0b8c fix(project): keep the project root icon outside workspaces (#2657)
Project type detection only runs for workspace members. A single project
root no longer takes the icon of its first matching build marker, which
labelled a Cargo + Vite repository as a Vite project.
2026-11-11 12:36:12 +08:00
arookieofc c4b47da9d1 fix(maven): link Windows path variants and warn on outside-root picks (#2639)
* fix(maven): link Windows path variants and warn on outside-root picks

The Add Maven Projects dialog hands back whatever form Windows uses:
drive-letter case may differ from the stored root and long paths come
back \?\ -prefixed, so in-root picks were silently dropped and the
button looked dead (#2626). Normalize both sides before comparing
(case-insensitively on Windows) and notify which picks were skipped
because Maven only links POMs inside the open project.

* i18n: tag Maven outside-root notice with tx and add zh entries
2026-11-11 12:33:15 +08:00
l0ng-ai 9d64134b68 fix(agents): keep list selection unbroken in agent replies (#2655)
Draw list markers as non-selectable pseudo-elements instead of native
markers. WebKit paints a selection box around each native marker, which
left a broken stripe down the list gutter when a reply was selected.
2026-11-11 12:32:27 +08:00
l0ng-ai ca945b13d2 feat(plugins): show available updates on the Installed tab (#2654)
Installed plugins with a newer, loadable marketplace version get an Update
badge in the list and an "Update to x.y.z" button in their details, which
installs it from the marketplace.
2026-11-11 12:32:22 +08:00
l0ng-ai 97c58cc808 fix(agents): keep a conversation drag-selection out of the composer (#2653)
Dragging a selection from the last message down past the conversation
highlighted the composer's text field. During such a drag everything outside
the conversation is made unselectable, but WebKit keeps editable text
selectable regardless of user-select, so the selection reached the textarea.
Text fields outside the selection scope now also drop out of hit testing while
the drag lasts.
2026-11-11 12:32:17 +08:00
l0ng-ai e158658afb fix(agents): drop the status bar's Changes toggle (#2652)
The session status bar ended with a Changes toggle that did the same as the
"N changed files" chip right below it. The chip opens and closes the Changes
pane and also shows the line counts, the pane has its own hide button, and the
status-bar toggle was disabled whenever there were no changes, so the status
bar now keeps only the Terminal button. The unused "Show changes" string goes
from the locale catalogs.
2026-11-11 12:32:12 +08:00
l0ng-ai db4bb52694 fix(agents): drop the running tool's name from the working row (#2645)
The tool in progress already shows as the last card of the conversation, and
next to it the row's elapsed time read as that tool's duration rather than the
turn's. The row now shows Working… with the turn's elapsed time, and still
shows retries and the starting state.
2026-11-11 12:31:37 +08:00
l0ng-ai 65bb39e08c test(plugins): give the official examples test a longer command budget (#2643)
The plugin_data_tools harness caps plugin commands at 250 ms to test interruption; the official examples' first command compiles and scans the project and ran over that on Windows runners. That test now uses a 10 s budget.
2026-11-11 12:08:25 +08:00
43 changed files with 1644 additions and 147 deletions

No files matched your search

+11
View File
@@ -178,6 +178,17 @@ impl Negotiated {
pub fn meta(&self, pointer: &str) -> Option<&Value> {
self.capabilities.get("_meta")?.pointer(pointer)
}
/// First authentication method the agent can run over ACP (`type` absent or `"agent"`).
pub fn agent_auth_method(&self) -> Option<&str> {
self.auth_methods.iter().find_map(|m| {
let kind = m.get("type").and_then(Value::as_str).unwrap_or("agent");
(kind == "agent" && m.pointer("/_meta/terminal-auth").is_none())
.then(|| m.get("id").and_then(Value::as_str))
.flatten()
.filter(|id| !id.is_empty())
})
}
}
/// The client side of one agent connection: wraps the task's [`Handler`] and, once
+30
View File
@@ -135,6 +135,36 @@ fn the_shape_decides_the_version_not_the_number() {
assert_eq!((n.generation, n.load_session(), n.session("fork")), (2, false, false));
}
#[test]
fn picks_non_terminal_agent_auth_method() {
let v1 = Negotiated::from_initialize(&json!({
"protocolVersion": 1,
"authMethods": [
{ "id": "cli_login", "_meta": { "terminal-auth": { "command": "copilot", "args": ["login"] } } },
{ "id": "terminal_login", "type": "terminal", "args": ["login"] },
{ "id": "api_key", "type": "env_var" },
{ "id": "github_oauth", "name": "GitHub OAuth" }
]
}));
assert_eq!(v1.agent_auth_method(), Some("github_oauth"));
let v2 = Negotiated::from_initialize(&json!({
"protocolVersion": 2,
"info": { "name": "x", "version": "1" },
"authMethods": [
{ "type": "terminal", "methodId": "cli", "name": "CLI", "args": ["login"] },
{ "type": "agent", "methodId": "oauth", "name": "OAuth" }
]
}));
assert_eq!(v2.agent_auth_method(), Some("oauth"));
let terminal_only = Negotiated::from_initialize(&json!({
"protocolVersion": 1,
"authMethods": [{ "id": "cli", "type": "terminal", "args": ["login"] }]
}));
assert_eq!(terminal_only.agent_auth_method(), None);
}
#[test]
fn meta_and_auth_methods_pass_through() {
// The native shims advertise their extensions in `agentCapabilities._meta`.
+270
View File
@@ -0,0 +1,270 @@
//! Sessions the user started outside OpenIDEA (a terminal, another editor, the CLI):
//! scanned so they can be listed and imported as tasks instead of re-explained (#312).
//!
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
use super::native::claude::history;
use serde::Serialize;
use specta::Type;
use std::path::{Path, PathBuf};
/// A session on this machine that no task holds yet.
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExternalSession {
/// The [`crate::AgentProvider`] id that owns it (`claude`, `codex`).
pub provider: String,
/// The id resuming it names (`--resume`, `thread/resume`).
pub session_id: String,
pub title: String,
/// Where it ran, when the transcript names one.
pub cwd: Option<String>,
/// File modification time, ms since the epoch.
pub updated_ms: f64,
}
/// Modification time of `path` in ms, else 0.
fn mtime(path: &Path) -> f64 {
path.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as f64)
.unwrap_or(0.0)
}
/// Short display text: collapsed whitespace, truncated to `MAX_TITLE` chars.
fn short(text: &str) -> String {
const MAX_TITLE: usize = 80;
let flat: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= MAX_TITLE {
flat
} else {
format!("{}…", flat.chars().take(MAX_TITLE - 1).collect::<String>())
}
}
/// The text of a Claude Code transcript entry's message, when it carries prose.
fn message_text(e: &serde_json::Value) -> Option<String> {
match e.pointer("/message/content") {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(serde_json::Value::Array(blocks)) => {
let text: Vec<&str> = blocks
.iter()
.filter(|b| b.get("type").and_then(serde_json::Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(serde_json::Value::as_str))
.collect();
(!text.is_empty()).then(|| text.join("\n"))
}
_ => None,
}
}
/// Claude Code transcripts: `<config>/projects/*/<id>.jsonl`.
pub fn claude_sessions(config_dir: &Path) -> Vec<ExternalSession> {
let mut out = Vec::new();
let projects = config_dir.join("projects");
let dirs: Vec<PathBuf> = std::fs::read_dir(&projects).into_iter().flatten().flatten().map(|d| d.path()).collect();
for dir in dirs {
let files: Vec<PathBuf> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|d| d.path())
.filter(|p| p.extension().is_some_and(|e| e == "jsonl"))
.collect();
for path in files {
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_owned();
if id.is_empty() {
continue;
}
let entries = history::read(&path).unwrap_or_default();
if entries.is_empty() {
continue;
}
let title = history::title(&entries)
.or_else(|| entries.iter().filter(|e| history::is_turn_start(e)).filter_map(message_text).next())
.map(|t| short(&t))
.unwrap_or_else(|| format!("Claude Code session {}", &id[..id.len().min(8)]));
let cwd = entries
.iter()
.filter_map(|e| e.get("cwd").and_then(serde_json::Value::as_str))
.next()
.map(str::to_owned);
out.push(ExternalSession {
provider: "claude".to_owned(),
session_id: id,
title,
cwd,
updated_ms: mtime(&path),
});
}
}
out
}
/// The `session_meta` line of a Codex rollout file: `(id, cwd)`, across shapes.
fn codex_meta(line: &serde_json::Value) -> Option<(String, Option<String>)> {
let payload =
line.get("payload").filter(|p| p.get("type").and_then(serde_json::Value::as_str) == Some("session_meta"));
let inner = payload.and_then(|p| p.get("payload")).or_else(|| {
line.get("type").and_then(serde_json::Value::as_str).filter(|t| *t == "session_meta").and(Some(line))
})?;
let id = inner.get("id").and_then(serde_json::Value::as_str)?.to_owned();
let cwd = inner.get("cwd").and_then(serde_json::Value::as_str).map(str::to_owned);
Some((id, cwd))
}
/// A prose line of a Codex rollout file (a user message), across shapes.
fn codex_user_text(line: &serde_json::Value) -> Option<String> {
let payload = line.get("payload")?;
let ty = payload.get("type").and_then(serde_json::Value::as_str)?;
if !(ty == "user_message" || ty == "userMessage" || ty == "event_msg") {
return None;
}
let inner = payload.get("payload").unwrap_or(&serde_json::Value::Null);
for v in [payload, inner] {
if let Some(s) = v.get("message").and_then(serde_json::Value::as_str) {
return Some(s.to_owned());
}
if let Some(s) = v.get("text").and_then(serde_json::Value::as_str) {
return Some(s.to_owned());
}
}
None
}
/// One rollout file's session, or `None` when it names no `session_meta`.
fn codex_file(path: &Path) -> Option<ExternalSession> {
let text = std::fs::read_to_string(path).ok()?;
let mut id: Option<(String, Option<String>)> = None;
let mut title: Option<String> = None;
// `session_meta` is first, but cap the scan: giant rollouts must not stall listing.
for line in text.lines().take(400) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
if id.is_none()
&& let Some(meta) = codex_meta(&v)
{
id = Some(meta);
}
if title.is_none()
&& let Some(t) = codex_user_text(&v)
{
title = Some(short(&t));
}
if id.is_some() && title.is_some() {
break;
}
}
let (session_id, cwd) = id?;
Some(ExternalSession {
provider: "codex".to_owned(),
title: title.unwrap_or_else(|| format!("Codex session {}", &session_id[..session_id.len().min(8)])),
session_id,
cwd,
updated_ms: mtime(path),
})
}
/// Collects `*.jsonl` below `dir`, up to 4 levels (`sessions/YYYY/MM/DD/<rollout>`).
fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth == 0 {
return;
}
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
rollout_files(&path, depth - 1, out);
} else if path.extension().is_some_and(|e| e == "jsonl") {
out.push(path);
}
}
}
/// Codex sessions: `<home>/.codex/sessions/**`.
pub fn codex_sessions(codex_home: &Path) -> Vec<ExternalSession> {
let mut files = Vec::new();
rollout_files(&codex_home.join("sessions"), 4, &mut files);
files.iter().filter_map(|p| codex_file(p)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::Write;
fn write(path: &Path, lines: &[serde_json::Value]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut f = std::fs::File::create(path).unwrap();
for line in lines {
writeln!(f, "{line}").unwrap();
}
}
fn claude_user(uuid: &str, text: &str) -> serde_json::Value {
json!({"type": "user", "uuid": uuid, "parentUuid": null,
"message": {"role": "user", "content": text}, "cwd": "/work/app"})
}
#[test]
fn lists_claude_transcripts_with_titles() {
let tmp = tempfile::tempdir().unwrap();
write(
&tmp.path().join("projects/-work-app/abc123.jsonl"),
&[
claude_user("u1", "fix the login bug"),
json!({"type": "assistant", "uuid": "a1", "parentUuid": "u1",
"message": {"role": "assistant", "content": []}}),
json!({"type": "ai-title", "aiTitle": "Login fix"}),
],
);
write(&tmp.path().join("projects/-work-app/empty.jsonl"), &[]);
let sessions = claude_sessions(tmp.path());
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("claude", "abc123"));
assert_eq!(s.title, "Login fix");
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
assert!(s.updated_ms > 0.0);
}
#[test]
fn claude_title_falls_back_to_the_first_prompt() {
let tmp = tempfile::tempdir().unwrap();
write(&tmp.path().join("projects/-x/def456.jsonl"), &[claude_user("u1", "hello there")]);
let sessions = claude_sessions(tmp.path());
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].title, "hello there");
}
#[test]
fn lists_codex_rollouts_with_meta() {
let tmp = tempfile::tempdir().unwrap();
write(
&tmp.path().join("sessions/2026/01/02/rollout-01.jsonl"),
&[
json!({"timestamp": "t", "payload": {"type": "session_meta",
"payload": {"id": "thread-1", "cwd": "/work/app"}}}),
json!({"timestamp": "t", "payload": {"type": "user_message",
"payload": {"message": "add tests"}}}),
],
);
write(&tmp.path().join("sessions/notes.jsonl"), &[json!({"nope": true})]);
let sessions = codex_sessions(tmp.path());
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
assert_eq!(s.title, "add tests");
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
}
#[test]
fn missing_directories_list_nothing() {
let tmp = tempfile::tempdir().unwrap();
assert!(claude_sessions(&tmp.path().join("nope")).is_empty());
assert!(codex_sessions(&tmp.path().join("nope")).is_empty());
}
}
+122 -5
View File
@@ -10,6 +10,7 @@
mod acp;
mod config;
mod conversation;
mod external;
pub mod mcp;
mod model;
mod native;
@@ -19,6 +20,7 @@ mod skills;
mod timeline;
mod worktree;
pub use external::ExternalSession;
pub use model::*;
use conversation::{Conversation, SavedSubagent, Session};
@@ -585,6 +587,91 @@ impl AgentManager {
Ok(timeline.entries.clone())
}
/// Session ids the tasks already hold (native drivers reuse the CLI's own id).
fn imported_session_ids(&self) -> HashSet<String> {
self.tasks.read().values().filter_map(|t| t.state.lock().session_id.clone()).collect()
}
/// Sessions on this machine that no task holds yet, newest first (#312).
pub fn external_sessions(&self) -> Vec<ExternalSession> {
let mut out = Vec::new();
if let Some(spec) = self.shared.spec("claude")
&& let Some(dir) = native::claude::cli::config_dir(&spec.env)
{
out.extend(external::claude_sessions(&dir));
}
let home = self.shared.paths.home_dir.clone().or_else(home_dir);
if let Some(home) = home {
out.extend(external::codex_sessions(&home.join(".codex")));
}
let known = self.imported_session_ids();
out.retain(|s| !known.contains(&s.session_id));
out.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms));
out.truncate(50);
out
}
/// Imports an external session as a task: its first turn loads and replays it (#312).
pub fn import_session(&self, provider: &str, session_id: &str) -> IdeResult<AgentTask> {
if !matches!(provider, "claude" | "codex") {
return Err(IdeError::invalid(format!("cannot import {provider} sessions")));
}
let spec = self.shared.spec(provider).ok_or_else(|| IdeError::invalid(format!("unknown agent {provider}")))?;
if self.imported_session_ids().contains(session_id) {
return Err(IdeError::invalid("that session is already a task"));
}
let found = self
.external_sessions()
.into_iter()
.find(|s| s.provider == provider && s.session_id == session_id)
.ok_or_else(|| IdeError::not_found(format!("unknown {provider} session {session_id}")))?;
let root = self.shared.paths.project_root.clone();
let cwd = found.cwd.map(PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| root.clone());
let config = self.shared.known_config(&spec.id);
let now = now_ms();
let id = uuid::Uuid::new_v4().simple().to_string();
let task = AgentTask {
project_root: Some(IpcPath(root)),
id,
title: found.title,
provider: spec.id.clone(),
model: model_of(&config),
status: AgentStatus::Ready,
isolation: Isolation::Local,
cwd: IpcPath(cwd),
branch: None,
base: None,
base_commit: None,
error: None,
created_ms: now,
updated_ms: now,
config,
commands: self.shared.known_commands(&spec.id),
usage: None,
queue: Vec::new(),
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
resets_ms: None,
retry: None,
background: Box::default(),
};
let file = TaskFile {
task: task.clone(),
session_id: Some(session_id.to_owned()),
entries: Vec::new(),
wanted: Vec::new(),
before: BTreeMap::new(),
saved: None,
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: true,
};
let handle = Task::restore(file, self.shared.clone());
self.tasks.write().insert(task.id.clone(), handle.clone());
handle.emit_task();
handle.persist();
Ok(task)
}
/// Creates the task (and its worktree) and sends `req.prompt` as its first message.
pub async fn create(&self, req: NewAgentTask) -> IdeResult<AgentTask> {
let spec = self
@@ -1623,6 +1710,30 @@ impl Task {
})
}
/// Runs `method` and, on an auth error, tries the agent's own ACP `authenticate` once before retrying.
async fn request_with_auth(
&self,
peer: &acp::Conn,
init: &acp::Negotiated,
tried_auth: &mut bool,
method: &str,
params: Value,
) -> Result<Value, RpcError> {
let first = peer.request(method, params.clone(), None).await;
if !*tried_auth
&& let Err(RpcError::Server { code, ref message, .. }) = first
&& acp::auth_required(code, message)
&& let Some(method_id) = init.agent_auth_method()
{
*tried_auth = true;
match peer.request::<_, Value>("authenticate", json!({ "methodId": method_id }), None).await {
Ok(_) => return peer.request(method, params, None).await,
Err(e) => tracing::debug!(task = %self.id, method_id, error = %e, "agent authenticate failed"),
}
}
first
}
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
let _guard = self.connecting.lock().await;
if let Some(live) = self.live.lock().clone()
@@ -1665,12 +1776,12 @@ impl Task {
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
let mut tried_auth = false;
let mut session = None;
if let (Some(id), true) = (saved, can_load) {
self.replaying.store(true, Ordering::Release);
let loaded: Result<Value, RpcError> = peer
.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers }), None)
.await;
let params = json!({ "sessionId": &id, "cwd": &cwd_s, "mcpServers": &mcp_servers });
let loaded = self.request_with_auth(&peer, &init, &mut tried_auth, "session/load", params).await;
tokio::time::sleep(REPLAY_GRACE).await;
self.replaying.store(false, Ordering::Release);
match loaded {
@@ -1682,8 +1793,9 @@ impl Task {
let (session_id, result) = match session {
Some(s) => s,
None => {
let v: Value = peer
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": mcp_servers }), None)
let params = json!({ "cwd": &cwd_s, "mcpServers": &mcp_servers });
let v = self
.request_with_auth(&peer, &init, &mut tried_auth, "session/new", params)
.await
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
@@ -2429,6 +2541,11 @@ fn now_ms() -> f64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64)
}
/// The user's home, for tools that keep state there (Codex sessions).
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
}
fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
+1 -1
View File
@@ -135,7 +135,7 @@ impl Cli for Process {
}
/// Claude Code's configuration directory (`CLAUDE_CONFIG_DIR`, else `~/.claude`).
pub(super) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
pub(crate) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
let set = env
.iter()
.find(|(k, _)| k == "CLAUDE_CONFIG_DIR")
@@ -35,7 +35,7 @@ pub(super) fn escape(cwd: &Path) -> String {
cwd.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
}
pub(super) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
pub(crate) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
let text = std::fs::read_to_string(path)?;
Ok(text.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
}
@@ -111,7 +111,7 @@ fn block_ids<'a>(e: &'a Value, ty: &'a str, key: &'a str) -> impl Iterator<Item
/// Whether `e` is a message that started a turn: the user's own, not a tool result, a
/// steer, or text Claude Code added.
pub(super) fn is_turn_start(e: &Value) -> bool {
pub(crate) fn is_turn_start(e: &Value) -> bool {
if kind(e) != "user" || e.get("isMeta").and_then(Value::as_bool) == Some(true) {
return false;
}
@@ -149,7 +149,7 @@ pub(super) fn cut(chain: &[&Value], turns: usize) -> Result<Option<String>, Stri
}
/// The newest title Claude Code gave the session.
pub(super) fn title(entries: &[Value]) -> Option<String> {
pub(crate) fn title(entries: &[Value]) -> Option<String> {
entries
.iter()
.rev()
+2 -2
View File
@@ -7,8 +7,8 @@
//! <id>.json`): loading that session, from this connection or a fresh one, starts it that
//! way.
mod cli;
mod history;
pub(crate) mod cli;
pub(crate) mod history;
mod settings;
mod tools;
mod translate;
+101 -3
View File
@@ -35,6 +35,9 @@ struct FakeAgent {
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
auth_on_load: bool,
authenticate_method: Option<String>,
authenticated: AtomicBool,
request_error: Option<(i64, String)>,
auth_methods: Vec<Value>,
/// Hold the late mode reply until the prompt's permission request is already waiting.
@@ -127,9 +130,9 @@ impl Handler for FakeAgent {
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push(method.to_owned());
if (method == "session/new" && self.auth_on_new)
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
{
let needs_auth = !self.authenticated.load(Ordering::Acquire)
&& ((method == "session/new" && self.auth_on_new) || (method == "session/load" && self.auth_on_load));
if needs_auth || (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth"))) {
return Err(self
.request_error
.clone()
@@ -176,6 +179,16 @@ impl Handler for FakeAgent {
self.say(&session, "replayed history");
Ok(json!({}))
}
"authenticate" if self.authenticate_method.is_some() => {
let id = params.get("methodId").and_then(Value::as_str).unwrap_or_default();
self.calls.lock().push(format!("authenticate {id}"));
if Some(id) == self.authenticate_method.as_deref() {
self.authenticated.store(true, Ordering::Release);
Ok(json!({}))
} else {
Err((codes::INVALID_PARAMS, format!("unexpected methodId {id}")))
}
}
"session/set_config_option" | "session/set_mode" => {
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
@@ -696,6 +709,91 @@ async fn registry_sign_in_errors_use_the_advertised_terminal_login() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn authenticates_and_retries_session_setup_once_on_auth_error() {
let tmp = tempfile::tempdir().unwrap();
let methods = vec![
json!({"id": "cli_login", "_meta": {"terminal-auth": {"command": "copilot", "args": ["login"]}}}),
json!({"id": "github_oauth", "name": "GitHub OAuth"}),
];
let agent = Arc::new(FakeAgent {
auth_on_new: true,
authenticate_method: Some("github_oauth".into()),
request_error: Some((-32000, "Authentication required".into())),
auth_methods: methods.clone(),
..Default::default()
});
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "copilot".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert_eq!(
agent
.calls
.lock()
.iter()
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
.collect::<Vec<_>>(),
["session/new", "authenticate", "authenticate github_oauth", "session/new", "session/prompt"],
);
// Resuming after an IDE restart also authenticates once and retries `session/load`.
mgr.shutdown();
let agent2 = Arc::new(FakeAgent {
auth_on_load: true,
auth_on_new: true,
authenticate_method: Some("github_oauth".into()),
request_error: Some((-32000, "Authentication required".into())),
auth_methods: methods.clone(),
..Default::default()
});
let mgr2 = manager(tmp.path(), agent2.clone());
mgr2.prompt(&task.id, text("again"), false).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
wait_for(&mgr2, &task.id, AgentStatus::Ready).await;
assert_eq!(
agent2
.calls
.lock()
.iter()
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
.collect::<Vec<_>>(),
["session/load", "authenticate", "authenticate github_oauth", "session/load", "session/prompt"],
);
// If `authenticate` fails during `session/load`, fallback to `session/new` does not try it again.
mgr2.shutdown();
let agent3 = Arc::new(FakeAgent {
auth_on_load: true,
auth_on_new: true,
request_error: Some((-32000, "Authentication required".into())),
auth_methods: methods,
..Default::default()
});
let mgr3 = manager(tmp.path(), agent3.clone());
mgr3.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr3, &task.id, AgentStatus::Failed).await;
assert_eq!(
agent3
.calls
.lock()
.iter()
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
.collect::<Vec<_>>(),
["session/load", "authenticate", "session/new"],
);
mgr3.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let tmp = tempfile::tempdir().unwrap();
+2
View File
@@ -322,6 +322,7 @@ pub struct TerminalSettings {
pub override_ide_shortcuts: bool,
pub shell_integration: bool,
pub highlight_hyperlinks: bool,
pub highlight_selection: bool,
pub option_as_meta: bool,
/// Escape moves focus to the editor instead of reaching the shell.
pub escape_to_editor: bool,
@@ -354,6 +355,7 @@ impl Default for TerminalSettings {
override_ide_shortcuts: true,
shell_integration: true,
highlight_hyperlinks: true,
highlight_selection: true,
option_as_meta: false,
escape_to_editor: false,
cursor_shape: "block".into(),
+16 -1
View File
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
McpScope, McpServer, McpServerInput, NewAgentTask,
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
@@ -181,6 +181,17 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
host.project(window)?.agents.entries(&task, subagent)
}
/// Sessions on this machine that no task holds yet, newest first (#312).
pub fn external_sessions(host: &Host, window: &str) -> IdeResult<Vec<ExternalSession>> {
Ok(host.project(window)?.agents.external_sessions())
}
/// Imports an external session as a task: its first turn loads and replays it (#312).
pub fn import_session(host: &Host, window: &str, provider: String, session_id: String) -> IdeResult<AgentTask> {
host.require_trust(window)?;
host.project(window)?.agents.import_session(&provider, &session_id)
}
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
host.require_trust(window)?;
let project = host.project(window)?;
@@ -398,6 +409,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await });
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await });
d.add("agent_external_sessions", |c| async move { external_sessions(&c.host, &c.window) });
d.add("agent_import_session", |c| async move {
import_session(&c.host, &c.window, c.arg("provider")?, c.arg("sessionId")?)
});
d.add("agent_prompt", |c| async move {
prompt(&c.host, &c.window, c.arg("task")?, c.arg("prompt")?, c.arg("now")?).await
});
+6 -2
View File
@@ -27,6 +27,9 @@ struct Harness {
}
impl Harness {
fn new() -> Self {
Self::with_timeout(Duration::from_millis(250))
}
fn with_timeout(timeout: Duration) -> Self {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
@@ -56,7 +59,7 @@ impl Harness {
PluginHost::new(
tmp.path().join("plugins"),
Arc::new(move |w: Option<&str>, e| out.lock().unwrap().push((w.unwrap_or_default().to_owned(), e))),
Limits { timeout: Duration::from_millis(250), ..Default::default() },
Limits { timeout, ..Default::default() },
)
.unwrap(),
);
@@ -202,7 +205,8 @@ async fn database_imports_are_denied_without_the_optional_grant() {
#[tokio::test(flavor = "multi_thread")]
async fn official_api_examples_install_and_render_real_component_contributions() {
let f = Harness::new();
// The real examples' first calls compile and scan; 250 ms is too tight on Windows runners.
let f = Harness::with_timeout(Duration::from_secs(10));
f.host.set_project_trust(&f.root, true, false).await.unwrap();
let official = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official");
for name in ["blame-annotations", "todo-finder"] {
+35
View File
@@ -59,3 +59,38 @@ for (const { width, split } of [{ width: 220 }, { width: 280 }, { width: 520 },
await expect(terminal.locator(".xterm-helper-textarea:focus")).toHaveCount(1);
});
}
test("terminal highlights visible occurrences of selected text in a single overlay layer", async ({
workbench: page,
}) => {
await page.evaluate(async () => {
const path = "/src/workbench/toolwindows/store.ts";
const layout = await import(path);
layout.showToolWindow("terminal", true);
});
const terminal = page.getByRole("region", { name: "Terminal", exact: true });
await expect(terminal.locator(".xterm-helper-textarea")).toBeFocused();
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise((r) => requestAnimationFrame(r));
const path = "/src/features/terminal/runtime.ts";
const { activeSession } = await import(path);
const s = activeSession()!;
s.term.reset();
await new Promise<void>((resolve) => s.term.write("needle other needle\r\nneedle gamma gamma\r\n", resolve));
s.term.select(0, 0, 6);
});
await expect(terminal.locator(".openidea-terminal-selection-matches")).toHaveCount(1);
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(2);
if (process.env.OPENIDEA_TERMINAL_SHOTS)
await terminal.screenshot({ path: `${process.env.OPENIDEA_TERMINAL_SHOTS}/terminal-selection-matches.png` });
await page.evaluate(async () => {
const path = "/src/features/terminal/runtime.ts";
const { activeSession } = await import(path);
activeSession()!.term.clearSelection();
});
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(0);
});
+20 -1
View File
@@ -4,7 +4,7 @@ use crate::state::AppState;
use ide_agent::registry::AgentRegistry;
use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
McpServer, McpServerInput, NewAgentTask,
ExternalSession, McpServer, McpServerInput, NewAgentTask,
};
use ide_core::{IdeResult, IpcPath, RequestId};
use ide_host::ops::agent;
@@ -166,6 +166,25 @@ pub async fn agent_create(window: Window, state: State<'_, AppState>, request: N
agent::create(&state.host, window.label(), request).await
}
/// Sessions on this machine that no task holds yet, newest first (#312).
#[tauri::command]
#[specta::specta]
pub fn agent_external_sessions(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ExternalSession>> {
agent::external_sessions(&state.host, window.label())
}
/// Imports an external session as a task: its first turn loads and replays it (#312).
#[tauri::command]
#[specta::specta]
pub fn agent_import_session(
window: Window,
state: State<'_, AppState>,
provider: String,
session_id: String,
) -> IdeResult<AgentTask> {
agent::import_session(&state.host, window.label(), provider, session_id)
}
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
#[tauri::command]
#[specta::specta]
+2
View File
@@ -128,6 +128,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
agent::agent_commands,
agent::agent_entries,
agent::agent_create,
agent::agent_external_sessions,
agent::agent_import_session,
agent::agent_prompt,
agent::agent_unqueue,
agent::agent_send_queued_now,
+18
View File
@@ -162,6 +162,10 @@ export const commands = {
/** Entries of `task`'s conversation, or of its subagent `subagent`'s. */
agentEntries: (task: string, subagent: string | null) => typedError<AgentEntry[], IdeError>(__TAURI_INVOKE("agent_entries", { task, subagent })),
agentCreate: (request: NewAgentTask) => typedError<AgentTask, IdeError>(__TAURI_INVOKE("agent_create", { request })),
/** Sessions on this machine that no task holds yet, newest first (#312). */
agentExternalSessions: () => typedError<ExternalSession[], IdeError>(__TAURI_INVOKE("agent_external_sessions")),
/** Imports an external session as a task: its first turn loads and replays it (#312). */
agentImportSession: (provider: string, sessionId: string) => typedError<AgentTask, IdeError>(__TAURI_INVOKE("agent_import_session", { provider, sessionId })),
/** Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. */
agentPrompt: (task: string, prompt: AgentPrompt, now: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_prompt", { task, prompt, now })),
/** Drops a queued message and returns it (to edit it). */
@@ -4299,6 +4303,19 @@ export type ExternalFormatterSettings = {
runOnSave?: boolean,
};
/** A session on this machine that no task holds yet. */
export type ExternalSession = {
/** The [`crate::AgentProvider`] id that owns it (`claude`, `codex`). */
provider: string,
/** The id resuming it names (`--resume`, `thread/resume`). */
sessionId: string,
title: string,
/** Where it ran, when the transcript names one. */
cwd: string | null,
/** File modification time, ms since the epoch. */
updatedMs: number | null,
};
/** Tools › External Tools entry. */
export type ExternalTool = {
id?: string,
@@ -8811,6 +8828,7 @@ export type TerminalSettings = {
overrideIdeShortcuts?: boolean,
shellIntegration?: boolean,
highlightHyperlinks?: boolean,
highlightSelection?: boolean,
optionAsMeta?: boolean,
/** Escape moves focus to the editor instead of reaching the shell. */
escapeToEditor?: boolean,
+12
View File
@@ -187,3 +187,15 @@
border-bottom: 1px solid var(--border);
font-weight: 600;
}
/* Imported sessions below the task list: compact rows, not growing space. */
.external {
flex: none;
overflow-y: auto;
padding: 4px 12px 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.external > .row {
flex: none;
}
+45 -1
View File
@@ -25,7 +25,7 @@ import {
sessionKey,
type TaskGroup,
} from "./model";
import { openHome, useAgents } from "./store";
import { importExternalSession, openHome, refreshExternalSessions, useAgents } from "./store";
import { taskMenu } from "./taskActions";
import { SessionEditor } from "./SessionEditor";
import { HomeEditor } from "./HomeEditor";
@@ -93,6 +93,19 @@ export function AgentsView() {
const inline = docked !== null || homeOpen;
const historyRef = useRef<HTMLButtonElement>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const external = useAgents((s) => s.external);
const externalError = useAgents((s) => s.externalError);
const [importing, setImporting] = useState<string | null>(null);
// Sessions started elsewhere (a terminal, another editor) list on demand (#312).
useEffect(() => {
if (historyOpen) void refreshExternalSessions();
}, [historyOpen]);
const importSession = (provider: string, sessionId: string) => {
setImporting(`${provider}:${sessionId}`);
void importExternalSession(provider, sessionId)
.catch(() => {})
.finally(() => setImporting(null));
};
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["g:archived"]));
const [selected, setSelected] = useState<Set<string>>(() => {
const task = sessionTask(maybeEditorService()?.activeInput?.());
@@ -285,6 +298,37 @@ export function AgentsView() {
<T>Agent sessions</T>
</div>
{taskList}
{(external.length > 0 || externalError) && (
<>
<div className={styles.historyTitle}>
<T>Sessions from other apps</T>
</div>
{externalError ? (
<div className={styles.empty}>{externalError}</div>
) : (
<div className={styles.external}>
{external.map((s) => {
const key = `${s.provider}:${s.sessionId}`;
return (
<div key={key} className={styles.row} title={s.cwd ?? s.sessionId}>
<Icon name={providerIcon(s.provider)} size={16} label={providerName(s.provider)} />
<span className={styles.title}>{s.title}</span>
<span className={styles.age}>{age(s.updatedMs ?? 0)}</span>
<button
type="button"
className={styles.link}
disabled={importing === key}
onClick={() => importSession(s.provider, s.sessionId)}
>
<T>Import</T>
</button>
</div>
);
})}
</div>
)}
</>
)}
</Popup>
)}
</div>
+28 -38
View File
@@ -54,10 +54,6 @@
align-items: center;
gap: 2px;
}
.statusActions > .changesToggle {
height: var(--h-control-small);
padding: 0 4px;
}
.statusText {
flex: 1 1 0;
min-width: 0;
@@ -471,6 +467,31 @@
.markdown :global(ol) {
margin: 0 0 8px;
padding-left: 20px;
list-style: none;
}
/* Markers are drawn as pseudo-elements: WebKit paints a selection box around a native marker,
leaving a broken stripe down the gutter when a list is selected. */
.markdown :global(li) {
position: relative;
}
.markdown :global(li)::before {
position: absolute;
right: calc(100% + 6px);
-webkit-user-select: none;
user-select: none;
}
.markdown :global(ul > li)::before {
content: "•";
}
.markdown :global(ul ul > li)::before {
content: "◦";
}
.markdown :global(ul ul ul > li)::before {
content: "▪";
}
.markdown :global(ol > li)::before {
content: counter(list-item) ".";
font-variant-numeric: tabular-nums;
}
.markdown :global(a) {
color: var(--link);
@@ -521,7 +542,9 @@
}
.markdown :global(li:has(> input[type="checkbox"])) {
margin-left: -20px;
list-style: none;
}
.markdown :global(li:has(> input[type="checkbox"]))::before {
content: none;
}
.markdown > :global(:first-child) {
margin-top: 0;
@@ -1230,33 +1253,6 @@
text-overflow: ellipsis;
}
.changesToggle {
display: inline-flex;
align-items: center;
gap: 4px;
height: var(--h-control);
padding: 0 8px;
border: 0;
border-radius: var(--radius-small);
background: transparent;
color: var(--fg-subtle);
font: inherit;
font-size: var(--fs-small);
cursor: pointer;
}
.changesToggle:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--fg-strong);
}
.changesToggle[aria-pressed="true"] {
background: var(--bg-selected);
color: var(--fg-strong);
}
.changesToggle:disabled {
opacity: 0.4;
cursor: default;
}
/* An open AskUserQuestion: every question with its options, answered at once above the composer. */
.questionCard {
flex: none;
@@ -1546,12 +1542,6 @@
.planCurrent {
color: var(--fg);
}
.trayActivity {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A one-button toolbar, not a round bubble: the popup's large arc would turn it into a circle. */
.quotePopup {
display: flex;
+5 -47
View File
@@ -75,7 +75,7 @@ import { ConfigPickers } from "./ConfigPickers";
import { findRanges, formatDuration, formatSentAt } from "./conversation";
import { ImagePreview, imageSrc } from "./ImagePreview";
import { useAgentSettings } from "./agentSettings";
import { currentTool, foldedTurns, summarizeWork, workParts, type FoldedTurn, type WorkSummary } from "./timeline";
import { foldedTurns, summarizeWork, workParts, type FoldedTurn, type WorkSummary } from "./timeline";
import { useRevealedText } from "./reveal";
import { AgentReply, MessageActions, ReplyActions } from "./AgentReply";
import {
@@ -272,20 +272,8 @@ function useLocalHead(status: AgentTask["status"] | null, root: string | null):
return local ? head : null;
}
/** The task's terminal and changes, at the end of its status line. */
function StatusActions({
task,
changes,
changesOpen,
onToggleChanges,
}: {
task: AgentTask;
changes: AgentChange[] | null;
changesOpen: boolean;
onToggleChanges: () => void;
}) {
const tx = useTx();
const count = changes?.length ?? 0;
/** The task's terminal, at the end of its status line. */
function StatusActions({ task }: { task: AgentTask }) {
return (
<span className={styles.statusActions}>
<IconButton
@@ -294,18 +282,6 @@ function StatusActions({
title="Terminal"
onClick={() => void executeAction("terminal.openAt", task.cwd)}
/>
<button
type="button"
className={styles.changesToggle}
aria-pressed={changesOpen}
disabled={count === 0 && !changesOpen}
aria-label={changesOpen ? tx("Hide changes") : tx("Show changes")}
title={changesOpen ? tx("Hide changes") : tx("Show changes")}
onClick={onToggleChanges}
>
<Icon name="toolwindow-changes" size={14} />
{count > 0 && <span>{count}</span>}
</button>
</span>
);
}
@@ -315,16 +291,10 @@ function SessionStatus({
task,
entries,
head,
changes,
changesOpen,
onToggleChanges,
}: {
task: AgentTask;
entries: AgentEntry[] | null;
head: string | null;
changes: AgentChange[] | null;
changesOpen: boolean;
onToggleChanges: () => void;
}) {
const tx = useTx();
const project = useProject((s) => s.project);
@@ -357,7 +327,7 @@ function SessionStatus({
{task.status === "limited" && task.resetsMs != null && ` · ${tx`resets ${formatSentAt(task.resetsMs)}`}`}
</span>
<Meters task={task} entries={entries} />
<StatusActions task={task} changes={changes} changesOpen={changesOpen} onToggleChanges={onToggleChanges} />
<StatusActions task={task} />
</div>
);
}
@@ -2388,7 +2358,6 @@ function Tray({
const added = changes?.reduce((n, c) => n + c.added, 0) ?? 0;
const removed = changes?.reduce((n, c) => n + c.removed, 0) ?? 0;
const busy = task.status === "running" || task.status === "starting";
const tool = entries && busy ? currentTool(entries) : null;
const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user");
return (
<div className={styles.tray}>
@@ -2445,10 +2414,6 @@ function Tray({
<Spinner size={12} />
{task.retry ? (
<Retrying retry={task.retry} />
) : tool ? (
<span className={styles.trayActivity} title={tool.title}>
{shortTitle(task, tool.title || tool.kind)}
</span>
) : (
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
)}
@@ -2559,14 +2524,7 @@ function SessionComposer({
);
return (
<div className={styles.composerArea}>
<SessionStatus
task={task}
entries={entries}
head={head}
changes={changes}
changesOpen={changesOpen}
onToggleChanges={onToggleChanges}
/>
<SessionStatus task={task} entries={entries} head={head} />
{task.status === "archived" && (
<div className={styles.banner}>
<span className={styles.bannerText}>
+59 -2
View File
@@ -22,6 +22,15 @@ const backend = vi.hoisted(() => ({
fetched: [] as string[],
entryError: null as string | null,
entryWait: null as Promise<void> | null,
external: [] as Array<{
provider: string;
sessionId: string;
title: string;
cwd: string | null;
updatedMs: number;
}>,
imported: [] as Array<[string, string]>,
externalError: null as string | null,
}));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
@@ -40,6 +49,16 @@ vi.mock("@/platform/ipc", async (original) => ({
backend.answers.push([task, permission, option]);
return { status: "ok", data: null };
},
agentExternalSessions: async () => {
if (backend.externalError !== null) throw new Error(backend.externalError);
return { status: "ok", data: backend.external };
},
agentImportSession: async (provider: string, sessionId: string) => {
backend.imported.push([provider, sessionId]);
const found = backend.external.find((e) => e.provider === provider && e.sessionId === sessionId);
if (!found) throw new Error("unknown session");
return { status: "ok", data: { ...task("imported"), id: "imported", title: found.title, provider } };
},
},
}));
vi.mock("@tauri-apps/api/webview", () => ({
@@ -118,6 +137,9 @@ beforeEach(() => {
disconnect();
localStorage.clear();
backend.changes = [];
backend.external = [];
backend.imported = [];
backend.externalError = null;
backend.entries = new Map([
["A", [{ type: "agent", text: "Answer A" }, subagent]],
[entriesKey("A", "child"), [{ type: "agent", text: "Child answer" }]],
@@ -129,6 +151,8 @@ beforeEach(() => {
homeOpen: false,
tasks: new Map([task("A"), task("B")].map((t) => [t.id, t])),
entries: new Map(backend.entries),
external: [],
externalError: null,
});
});
@@ -1124,14 +1148,14 @@ describe("session state across tasks and hosts", () => {
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
render(<AgentsView />);
await screen.findByText("Answer A");
await waitFor(() => expect(screen.getByRole("button", { name: "Show changes" })).toBeEnabled());
await screen.findByRole("button", { name: /1 changed file/ });
expect(screen.queryByRole("complementary", { name: "Changes" })).toBeNull();
const markVisible = vi.spyOn(await import("./store"), "markVisible");
vi.spyOn(document, "hasFocus").mockReturnValue(true);
const message = screen.getByRole("textbox", { name: "Message" });
fireEvent.change(message, { target: { value: "Draft to keep" } });
const conversation = stream();
fireEvent.click(screen.getByRole("button", { name: "Show changes" }));
fireEvent.click(screen.getByRole("button", { name: /1 changed file/ }));
expect(screen.getByRole("complementary", { name: "Changes" })).toBeVisible();
expect(screen.getByText("Answer A")).not.toBeVisible();
expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
@@ -1256,3 +1280,36 @@ describe("meters in the task header", () => {
expect(screen.getByText("$0.42")).toBeInTheDocument();
});
});
describe("sessions from other apps", () => {
const openHistory = async () => {
act(() => useAgents.setState({ docked: "A" }));
render(<AgentsView />);
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
await waitFor(() => expect(screen.getByText("Sessions from other apps")).toBeInTheDocument());
};
it("lists external sessions in the history popup and imports one as a task", async () => {
backend.external = [
{ provider: "claude", sessionId: "abc", title: "Login fix", cwd: "/work/app", updatedMs: Date.now() },
{ provider: "codex", sessionId: "thread-1", title: "Add tests", cwd: null, updatedMs: 0 },
];
await openHistory();
expect(screen.getByText("Login fix")).toBeInTheDocument();
expect(screen.getByText("Add tests")).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getAllByRole("button", { name: "Import" })[0]!);
});
expect(backend.imported).toEqual([["claude", "abc"]]);
const state = useAgents.getState();
expect(state.tasks.get("imported")?.title).toBe("Login fix");
expect(state.external.map((e) => e.sessionId)).toEqual(["thread-1"]);
});
it("shows listing errors instead of the section", async () => {
backend.externalError = "no home";
await openHistory();
expect(screen.getByText("no home")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Import" })).toBeNull();
});
});
+27
View File
@@ -12,6 +12,7 @@ import type {
AgentLimit,
AgentProvider,
AgentTask,
ExternalSession,
Isolation,
} from "@/bindings";
import { useProject } from "@/platform/project";
@@ -77,6 +78,10 @@ interface AgentsState {
lastComposer: string;
/** First messages of past tasks, newest first (New Task page ↑). */
history: string[];
/** Sessions on this machine that no task holds yet (#312). */
external: ExternalSession[];
/** Why the external sessions could not be listed. */
externalError: string | null;
/** The composer asked to take focus (`seq` tells repeated requests apart). */
focus: { key: string; seq: number } | null;
/** Task shown inside the Agents tool window instead of an editor tab. */
@@ -138,6 +143,8 @@ export const useAgents = create<AgentsState>(() => ({
composers: new Map(),
lastComposer: HOME,
history: load<string[]>(HISTORY_KEY, []),
external: [],
externalError: null,
focus: null,
docked: null,
sessionLocation: load<unknown>(SESSION_LOCATION_KEY, null) === "toolWindow" ? "toolWindow" : "editor",
@@ -715,6 +722,26 @@ export async function startTask(
if ((await editorService().closeInput?.(HOME_KEY)) !== false) openTask(task.id);
}
/** Sessions on this machine that no task holds yet (a terminal, another editor: #312). */
export async function refreshExternalSessions() {
try {
const external = await call(commands.agentExternalSessions());
useAgents.setState({ external, externalError: null });
} catch (e) {
useAgents.setState({ externalError: e instanceof Error ? e.message : String(e) });
}
}
/** Imports an external session as a task and opens it: its first turn loads it (#312). */
export async function importExternalSession(provider: string, sessionId: string) {
const task = await call(commands.agentImportSession(provider, sessionId));
useAgents.setState((s) => ({
tasks: s.tasks.has(task.id) ? s.tasks : new Map(s.tasks).set(task.id, task),
external: s.external.filter((e) => !(e.provider === provider && e.sessionId === sessionId)),
}));
openTask(task.id);
}
function remember(text: string) {
if (!text) return;
const history = [text, ...useAgents.getState().history.filter((h) => h !== text)].slice(0, HISTORY_SIZE);
+1 -6
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AgentEntry } from "@/bindings";
import { currentTool, foldedTurns, summarizeWork, workParts } from "./timeline";
import { foldedTurns, summarizeWork, workParts } from "./timeline";
const user = (text: string, extra: Partial<Extract<AgentEntry, { type: "user" }>> = {}): AgentEntry => ({
type: "user",
@@ -45,11 +45,6 @@ describe("summarizeWork", () => {
{ text: "1 other tool call", count: 1 },
]);
});
it("finds the call in progress", () => {
expect(currentTool([tool("read", { status: "running" }), tool("search")])?.kind).toBe("read");
expect(currentTool([tool("read")])).toBeNull();
});
});
describe("foldedTurns", () => {
-9
View File
@@ -91,15 +91,6 @@ export function workParts(s: WorkSummary): Array<{ text: string; count: number }
return parts.filter(([, , n]) => n > 0).map(([one, many, count]) => ({ text: count === 1 ? one : many, count }));
}
/** The running tool call of a run, if any: what the agent is doing right now. */
export function currentTool(entries: readonly AgentEntry[]): ToolEntry | null {
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i]!;
if (e.type === "tool" && (e.status === "running" || e.status === "pending")) return e;
}
return null;
}
/** A finished turn: the message that asked it at `user`, its work in `[user + 1, reply)`. */
export interface FoldedTurn {
user: number;
+40
View File
@@ -5,6 +5,7 @@ import { setContext } from "@/platform/context";
import type { BuildModel, GradleModel, ProjectInfo, RunConfig } from "@/platform/ipc";
import { emitLocalIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { usePlatform } from "@/platform/platformInfo";
import { GradleView, MavenView } from "./BuildToolView";
import { activate } from "./index";
import { expandableKeys, gradleChildren, gradleRoots, gradleProjectKey } from "./model";
@@ -20,6 +21,7 @@ const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
menu: vi.fn(),
link: vi.fn(),
notify: vi.fn(),
unlink: vi.fn(),
show: vi.fn(),
sync: vi.fn(),
@@ -52,6 +54,7 @@ vi.mock("@tauri-apps/api/core", async (original) => ({
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
toolWindowService: () => ({ show: mocks.show }),
notifications: () => ({ notify: mocks.notify }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open }));
vi.mock("@/workbench/messages", () => ({ confirm: mocks.confirm }));
@@ -243,6 +246,43 @@ it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/
},
);
it("links `\\\\?\\`-prefixed and differently-cased picks on Windows", async () => {
const os = usePlatform.getState().os;
usePlatform.setState({ os: "windows" });
try {
useProject.setState({ project: { root: "C:\\Projects\\demo" } as ProjectInfo });
mocks.get.mockResolvedValue({
languages: { java: { maven: { unlinkedProjects: [], linkedProjects: [] } } },
});
mocks.open.mockResolvedValue(["\\\\?\\C:\\Projects\\demo\\svc\\pom.xml", "c:/Projects/demo/web/pom.xml"]);
await act(async () => {
await executeAction("maven.addProjects");
});
expect(mocks.set).toHaveBeenCalledWith(
"project",
"languages.java.maven.linkedProjects",
'["svc/pom.xml","web/pom.xml"]',
);
} finally {
usePlatform.setState({ os });
}
});
it("warns about picks outside the project instead of silently doing nothing", async () => {
mocks.open.mockResolvedValue("/other/pom.xml");
await act(async () => {
await executeAction("maven.addProjects");
});
expect(mocks.set).not.toHaveBeenCalled();
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({
level: "warning",
title: expect.stringContaining("not added"),
body: expect.stringContaining("/other/pom.xml"),
}),
);
});
it("links picked POMs that no reactor lists as independent top-level projects", async () => {
mocks.get.mockResolvedValue({
languages: { java: { maven: { unlinkedProjects: ["b/pom.xml"], linkedProjects: ["tools/pom.xml"] } } },
+45 -9
View File
@@ -26,6 +26,7 @@ import {
import { buildToolRecovery } from "@/platform/buildToolRecovery";
import { useBuildModel } from "@/platform/buildModel";
import { useProject } from "@/platform/project";
import { hostOs } from "@/platform/remote";
import { editorService, notifications } from "@/workbench/contributions";
import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import {
@@ -294,16 +295,40 @@ export async function unlinkProjects(pom: string | null) {
await setLinked(root, [pom], false);
}
/** Project-relative `pom.xml` paths of `picked` inside `root`; others are skipped. */
function projectPoms(root: string, picked: readonly string[]): string[] {
const base = root.replace(/\\/g, "/").replace(/\/$/, "");
return picked.flatMap((p) => {
const path = p.replace(/\\/g, "/");
if (!path.startsWith(`${base}/`)) return [];
/** A picked path in one comparable form: slashes unified, `\\?\` prefixes stripped. */
function comparablePath(p: string) {
const slashes = p.replace(/\\/g, "/");
const unc = /^\/\/\?\/UNC\//i.exec(slashes);
if (unc) return `//${slashes.slice(unc[0].length)}`;
return slashes.replace(/^\/\/\?\//, "");
}
/**
* Splits `picked` into project-relative `pom.xml` paths inside `root` and the rest
* (outside the project, not a `pom.xml`, or escaping via `.`/`..`). Drive-letter
* case and `\\?\` prefixes must not matter on Windows, where the dialog may hand
* back either form (#2626).
*/
function projectPoms(root: string, picked: readonly string[]): { inside: string[]; outside: string[] } {
const base = comparablePath(root).replace(/\/$/, "");
const ignoreCase = hostOs() === "windows";
const inside: string[] = [];
const outside: string[] = [];
for (const p of picked) {
const path = comparablePath(p);
const underRoot = ignoreCase
? path.slice(0, base.length + 1).toLowerCase() === `${base.toLowerCase()}/`
: path.startsWith(`${base}/`);
if (!underRoot) {
outside.push(p);
continue;
}
const pom = path.slice(base.length + 1);
const parts = pom.split("/");
return parts.some((part) => part === ".." || part === ".") || parts.pop() !== "pom.xml" ? [] : [pom];
});
if (parts.some((part) => part === ".." || part === ".") || parts.pop() !== "pom.xml") outside.push(p);
else inside.push(pom);
}
return { inside, outside };
}
export async function addProjects() {
@@ -317,8 +342,19 @@ export async function addProjects() {
defaultPath: root,
filters: [{ name: "pom.xml", extensions: ["xml"] }],
});
const poms = projectPoms(root, typeof picked === "string" ? [picked] : (picked ?? []));
const paths = typeof picked === "string" ? [picked] : (picked ?? []);
if (!paths.length) return;
const { inside: poms, outside } = projectPoms(root, paths);
if (poms.length) await setLinked(root, poms, true);
// Dropping every pick silently looks exactly like the button doing nothing (#2626).
if (outside.length)
notifications().notify({
group: "tools",
level: "warning",
title: tx`Some Maven projects were not added`,
body: tx`These are outside the open project, and Maven only links POMs inside it: ${outside.slice(0, 3).join(", ")}${outside.length > 3 ? ` (+${outside.length - 3} more)` : ""}`,
key: "maven-add-outside-root",
});
}
/** Links independent Gradle builds without changing their files or build scripts. */
+23
View File
@@ -129,6 +129,15 @@ export function catalogState(
const vendorOf = (authors: readonly string[]) => authors.join(", ");
/** The marketplace's newer version of an installed plugin, if there is one this IDE can load. */
function useMarketUpdate(plugin: PluginInfo): MarketPlugin | undefined {
return usePlugins((s) =>
plugin.source === "installed"
? s.market?.find((m) => m.id === plugin.id && m.compatible && compareVersions(m.version, plugin.version) > 0)
: undefined,
);
}
function PluginIcon({ id, icon, large }: { id: string; icon: PluginIconData | null | undefined; large?: boolean }) {
return (
<span className={clsx(styles.icon, large && styles.iconLarge)} aria-hidden>
@@ -148,6 +157,7 @@ function ListHeader({ title, count }: { title: string; count: number }) {
function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; selected: boolean; onSelect: () => void }) {
const busy = usePlugins((s) => !!s.busy[plugin.id]);
const update = useMarketUpdate(plugin);
return (
<li className={clsx(styles.row, selected && styles.rowSelected, !plugin.enabled && styles.rowDisabled)}>
<button
@@ -166,6 +176,7 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele
{plugin.source !== "installed" && (
<span className={styles.devTag}>{plugin.source === "linked" ? tx("linked") : tx("dev")}</span>
)}
{update && <Badge tone="accent">{tx("Update")}</Badge>}
{plugin.status === "failed" && (
<Badge dot tone="danger">
<T>Failed</T>
@@ -544,6 +555,8 @@ function Homepage({ value }: { value: string }) {
function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
const busy = usePlugins((s) => !!s.busy[plugin.id]);
const installing = usePlugins((s) => s.installing);
const update = useMarketUpdate(plugin);
const status = STATUS[plugin.status];
const [confirmUninstall, setConfirmUninstall] = useState(false);
@@ -646,6 +659,16 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
</div>
<div className={styles.headerActions}>
{busy && <Spinner size={14} label="Working" />}
{update && (
<Button
size="small"
variant="primary"
disabled={busy || installing}
onClick={() => void installFromMarket(update.id)}
>
{tx`Update to ${update.version}`}
</Button>
)}
<Button
size="small"
variant={plugin.enabled ? "secondary" : "primary"}
@@ -1354,6 +1354,19 @@ describe("marketplace", () => {
await waitFor(() => expect(consents.map(([id]) => id)).toEqual(["a", "b"]));
});
it("marks installed plugins with updates and updates one from its details", async () => {
setConsentPrompt(async () => ({ optional: [] }));
const consents = updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
const user = userEvent.setup();
render(<PluginsPage />);
await waitFor(() => expect(usePlugins.getState().market).toHaveLength(3));
const list = screen.getByRole("list", { name: "Installed plugins" });
expect(within(list).getAllByText("Update")).toHaveLength(2);
await user.click(within(list).getAllByRole("button", { name: /^b/i })[0]!);
await user.click(await screen.findByRole("button", { name: "Update to 2.0.0" }));
await waitFor(() => expect(consents.map(([id]) => id)).toEqual(["b"]));
});
it("announces updates with actions to install them or show the plugins", async () => {
updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
handlePluginEvent({
+10 -4
View File
@@ -45,6 +45,12 @@ describe("automatic project types", () => {
expect([...useProjectTree.getState().projectTypes.values()]).toEqual(Array(9).fill("go"));
expect(backend.fsReadDir).toHaveBeenCalledTimes(9);
});
it("leaves a single project root alone", async () => {
initProjectTree("/app");
await new Promise((resolve) => setTimeout(resolve, 20));
expect(backend.fsReadDir).not.toHaveBeenCalled();
expect(useProjectTree.getState().projectTypes.size).toBe(0);
});
it("refreshes only affected markers and clears a removed project type", async () => {
initProjectTree("/ws", "Workspace", ["/a", "/b"]);
await waitFor(() => expect(useProjectTree.getState().projectTypes.size).toBe(2));
@@ -66,9 +72,9 @@ describe("automatic project types", () => {
finish = resolve;
}),
);
initProjectTree("/old");
initProjectTree("/old", "Old", ["/old"]);
await waitFor(() => expect(finish).toBeDefined());
initProjectTree("/new");
initProjectTree("/new", "New", ["/new"]);
await waitFor(() => expect(useProjectTree.getState().projectTypes.get("/new")).toBe("node"));
finish(ok([entry("pom.xml")]));
await Promise.resolve();
@@ -85,7 +91,7 @@ describe("automatic project types", () => {
entry("README.md"),
]),
);
initProjectTree("C:/work/app");
initProjectTree("C:/work", "Work", ["C:/work/app"]);
await waitFor(() => expect(useProjectTree.getState().projectTypes.get("C:/work/app")).toBe("gradle"));
expect(backend.fsReadDir).toHaveBeenCalledExactlyOnceWith("C:/work/app");
expect(backend.fsReadText).not.toHaveBeenCalled();
@@ -113,7 +119,7 @@ describe("automatic project types", () => {
{ ...entry("package.json", "symlink"), targetKind: "file" },
]),
);
initProjectTree("/linked");
initProjectTree("/ws", "Workspace", ["/linked"]);
await waitFor(() => expect(useProjectTree.getState().projectTypes.get("/linked")).toBe("node"));
});
});
+3 -4
View File
@@ -1,4 +1,4 @@
/** Bounded, read-only detection for content roots, including collapsed workspace members. */
/** Bounded, read-only detection for workspace members, collapsed ones included; a single project root keeps its own icon. */
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { detectProjectType, isProjectMarker } from "./projectType";
import { useProjectTree } from "./store";
@@ -16,9 +16,8 @@ export function initProjectTypes(): () => void {
let reset: (() => void) | undefined;
const load = () => {
reset?.();
const { root, members } = useProjectTree.getState();
if (!root || !isTauri()) return;
const roots = members ?? [root];
const { root, members: roots } = useProjectTree.getState();
if (!root || !roots || !isTauri()) return;
const pending = new Set(roots);
const versions = new Map<string, number>();
let active = 0;
+1
View File
@@ -238,6 +238,7 @@ function TerminalOptions() {
/>
<BoundToggle k="terminal.shellIntegration" label="Shell integration" />
<BoundToggle k="terminal.highlightHyperlinks" label="Highlight hyperlinks" />
<BoundToggle k="terminal.highlightSelection" label="Highlight occurrences of selected text" />
{isMac && <BoundToggle k="terminal.optionAsMeta" label="Use Option as Meta key" />}
</>
}
+17
View File
@@ -113,6 +113,23 @@
.pane :global(.xterm-viewport) {
background-color: var(--bg-island, var(--bg-app)) !important;
}
.pane :global(.xterm-screen) {
isolation: isolate;
}
.pane :global(.openidea-terminal-selection-matches) {
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
}
.pane :global(.openidea-terminal-selection-match) {
position: absolute;
box-sizing: border-box;
border-radius: 2px;
background-color: var(--bg-identifier-under-caret, color-mix(in srgb, var(--fg) 9%, transparent));
outline: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
outline-offset: -1px;
}
.pane :global(.xterm .xterm-scrollable-element > .scrollbar > .slider) {
box-sizing: border-box;
border: 2px solid transparent;
+172
View File
@@ -0,0 +1,172 @@
// @vitest-environment jsdom
import { Terminal } from "@xterm/xterm";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
findVisibleSelectionMatches,
installSelectionHighlights,
OVERLAY_CONTAINER_CLASS,
OVERLAY_MATCH_CLASS,
SelectionLineCache,
} from "./highlight";
function writeAsync(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => term.write(data, resolve));
}
describe("terminal selection occurrence highlights", () => {
const terms: Array<{ term: Terminal; host: HTMLDivElement }> = [];
const createTerm = (cols = 40, rows = 5) => {
window.matchMedia ??= (() =>
({
matches: false,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
}) as unknown as MediaQueryList) as typeof window.matchMedia;
const host = document.createElement("div");
document.body.appendChild(host);
const term = new Terminal({ allowProposedApi: true, cols, rows, scrollback: 200 });
term.open(host);
terms.push({ term, host });
return term;
};
afterEach(() => {
while (terms.length) {
const { term, host } = terms.pop()!;
term.dispose();
host.remove();
}
});
it("scans only visible rows (plus margin), never the distant scrollback", async () => {
const term = createTerm(40, 4);
for (let i = 0; i < 40; i++) {
await writeAsync(term, `line-${i} req-42 req-42\r\n`);
}
const lastRow = term.buffer.active.baseY;
// Select the first "req-42" on the last visible row (col 8, len 6)
term.select(8, lastRow, 6);
expect(term.getSelection()).toBe("req-42");
const cache = new SelectionLineCache();
const matches = findVisibleSelectionMatches(term, cache, 2);
// Distant scrollback rows (e.g. row 0..20) must never be scanned or returned
expect(matches.every((m) => m.row >= term.buffer.active.viewportY - 2)).toBe(true);
expect(cache.scans).toBeLessThanOrEqual(8);
});
it("caches per line and rescans only invalidated rows", async () => {
const term = createTerm(40, 5);
await writeAsync(term, "pid-99 other pid-99\r\npid-99 second\r\nthird line\r\n");
term.select(0, 0, 6);
const cache = new SelectionLineCache();
const first = findVisibleSelectionMatches(term, cache, 2);
expect(first).toEqual([
{ row: 0, col: 13, size: 6 },
{ row: 1, col: 0, size: 6 },
]);
const initialScans = cache.scans;
expect(initialScans).toBeGreaterThan(0);
// Second pass with unchanged lines hits the cache (0 new scans)
const second = findVisibleSelectionMatches(term, cache, 2);
expect(second).toEqual(first);
expect(cache.scans).toBe(initialScans);
// Invalidating row 1 rescans only row 1
cache.invalidateRange(1, 1);
findVisibleSelectionMatches(term, cache, 2);
expect(cache.scans).toBe(initialScans + 1);
});
it("maps wrapped lines (isWrapped) and wide CJK characters to exact cell columns", async () => {
const term = createTerm(12, 6);
// "中文ab中文" -> '中'(2) '文'(2) 'a'(1) 'b'(1) '中'(2) '文'(2) = 10 cols
await writeAsync(term, "中文ab中文\r\n");
// Select first "中文" (cols 0..4)
term.select(0, 0, 4);
expect(term.getSelection()).toBe("中文");
const cjkMatches = findVisibleSelectionMatches(term);
expect(cjkMatches).toEqual([{ row: 0, col: 6, size: 4 }]);
// Wrapped line across 12-column terminal: "0123456789tok_en_012345tok_en"
term.reset();
await writeAsync(term, "0123456789tok_en_012345tok_en\r\n");
// First "tok_en" starts at col 10 of row 0 and wraps 4 cols into row 1 (size 6)
term.select(10, 0, 6);
expect(term.getSelection()).toBe("tok_en");
const wrappedMatches = findVisibleSelectionMatches(term);
expect(wrappedMatches).toEqual([{ row: 1, col: 11, size: 6 }]);
});
it("skips multi-line, short, or whitespace-only selections and turns off in alternate buffer", async () => {
const term = createTerm(40, 6);
await writeAsync(term, "a a a\r\n \r\nfoo bar foo\r\nfoo baz\r\n");
term.select(0, 0, 1);
expect(findVisibleSelectionMatches(term)).toEqual([]);
term.select(0, 1, 3);
expect(findVisibleSelectionMatches(term)).toEqual([]);
term.selectLines(2, 3);
expect(findVisibleSelectionMatches(term)).toEqual([]);
term.select(0, 2, 3);
expect(findVisibleSelectionMatches(term)).toHaveLength(2);
// Enter alternate screen buffer (vim / htop)
await writeAsync(term, "\x1b[?1049hfoo bar foo\r\n");
expect(term.buffer.active.type).toBe("alternate");
expect(findVisibleSelectionMatches(term)).toEqual([]);
});
it("draws highlights in one overlay layer, coalesces frames, and clears during heavy output", async () => {
const term = createTerm(40, 6);
const registerSpy = vi.spyOn(term, "registerDecoration");
let enabled = true;
const controller = installSelectionHighlights(term, {
isEnabled: () => enabled,
});
await writeAsync(term, "err_code other err_code\r\nerr_code third\r\n");
term.select(0, 0, 8);
controller.refresh();
// Zero registerDecoration calls: uses one overlay container instead
expect(registerSpy).not.toHaveBeenCalled();
const overlay = term.element?.querySelector(`.${OVERLAY_CONTAINER_CLASS}`);
expect(overlay).not.toBeNull();
const visibleBoxes = () =>
[...(overlay?.querySelectorAll<HTMLElement>(`.${OVERLAY_MATCH_CLASS}`) ?? [])].filter(
(el) => el.style.display !== "none",
);
expect(visibleBoxes()).toHaveLength(2);
// Disabling the setting clears the overlay
enabled = false;
controller.refresh();
expect(visibleBoxes()).toHaveLength(0);
enabled = true;
controller.refresh();
expect(visibleBoxes()).toHaveLength(2);
// Heavy output burst clears the overlay immediately and redraws once settled
await writeAsync(term, "streaming line 1\r\n");
await writeAsync(term, "streaming line 2 err_code\r\n");
expect(visibleBoxes()).toHaveLength(0);
await new Promise((r) => setTimeout(r, 220));
controller.refresh();
expect(visibleBoxes().length).toBeGreaterThanOrEqual(2);
controller.dispose();
});
});
+470
View File
@@ -0,0 +1,470 @@
/**
* Viewport-scoped selection-occurrence highlights in the terminal.
*
* - Scans only the visible rows (plus a small margin), never the scrollback.
* - Caches per logical line and rescans only rows invalidated by `onRender`.
* - Draws all visible matches in one overlay layer (`.openidea-terminal-selection-matches`)
* inside `.xterm-screen` instead of calling `registerDecoration` per match.
* - Coalesces `onRender` / `onScroll` / `onSelectionChange` to one pass per
* animation frame; clears highlights during heavy output bursts and redraws
* once output settles.
* - Handles wrapped lines (`isWrapped`) and wide (CJK / emoji) characters.
* - Skips multi-line, short (< 2 chars), long (> 200 chars), or whitespace-only
* selections, turns off in the alternate buffer (`vim`, `htop`), and respects
* `terminal.highlightSelection`.
*/
import type { IBufferLine, IDisposable, Terminal } from "@xterm/xterm";
import { workflowSettings } from "@/platform/workflowSettings";
export const MIN_SELECTION_LENGTH = 2;
export const MAX_SELECTION_LENGTH = 200;
export const VIEWPORT_MARGIN_ROWS = 4;
const MAX_WRAP_ROWS = 16;
const MAX_VISIBLE_MATCHES = 200;
const HEAVY_WRITE_WINDOW_MS = 80;
const HEAVY_SETTLE_MS = 150;
export const OVERLAY_CONTAINER_CLASS = "openidea-terminal-selection-matches";
export const OVERLAY_MATCH_CLASS = "openidea-terminal-selection-match";
export interface TerminalBufferMatch {
row: number;
col: number;
size: number;
}
interface CachedLogicalLine {
span: number;
matches: TerminalBufferMatch[];
}
export class SelectionLineCache {
private needle = "";
private cols = 0;
private readonly lines = new Map<number, CachedLogicalLine>();
/** Number of logical-line scans performed (exposed for unit tests). */
scans = 0;
syncKey(needle: string, cols: number) {
if (this.needle !== needle || this.cols !== cols) {
this.needle = needle;
this.cols = cols;
this.lines.clear();
}
}
get(baseRow: number): CachedLogicalLine | undefined {
return this.lines.get(baseRow);
}
set(baseRow: number, entry: CachedLogicalLine) {
this.lines.set(baseRow, entry);
}
invalidateRange(startRow: number, endRow: number) {
for (const [baseRow, entry] of this.lines) {
if (baseRow <= endRow && baseRow + entry.span - 1 >= startRow) {
this.lines.delete(baseRow);
}
}
}
prune(minRow: number, maxRow: number) {
for (const baseRow of this.lines.keys()) {
if (baseRow < minRow || baseRow > maxRow) {
this.lines.delete(baseRow);
}
}
}
clear() {
this.lines.clear();
}
}
function translateLogicalLine(term: Terminal, baseRow: number, maxRow: number): [string, number[], number] {
const buffer = term.buffer.active;
const strings: string[] = [];
const lineOffsets = [0];
let lineIndex = baseRow;
let line = buffer.getLine(lineIndex);
let span = 0;
while (line) {
span++;
const nextLine = lineIndex < maxRow ? buffer.getLine(lineIndex + 1) : undefined;
const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
let str = line.translateToString(!lineWrapsToNext);
if (lineWrapsToNext && nextLine) {
const lastCell = line.getCell(line.length - 1);
const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;
if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {
str = str.slice(0, -1);
}
}
strings.push(str);
if (lineWrapsToNext) {
lineOffsets.push(lineOffsets[lineOffsets.length - 1]! + str.length);
} else {
break;
}
lineIndex++;
line = nextLine;
}
return [strings.join(""), lineOffsets, Math.max(1, span)];
}
/**
* Maps a UTF-16 string offset within `line.translateToString()` to a 0-based
* buffer column, accounting for width-2 CJK glyphs, width-0 trailing spacer
* cells, and astral surrogate pairs.
*/
export function stringOffsetToCol(line: IBufferLine | undefined, targetOffset: number): number {
if (!line || targetOffset <= 0) return 0;
let strPos = 0;
let col = 0;
const len = line.length;
while (col < len && strPos < targetOffset) {
const cell = line.getCell(col);
if (!cell) break;
const width = cell.getWidth();
if (width === 0) {
col++;
continue;
}
const chars = cell.getChars();
strPos += chars ? chars.length : 1;
col += width;
}
return col;
}
function scanLogicalLineMatches(term: Terminal, baseRow: number, maxRow: number, needle: string): CachedLogicalLine {
const cols = term.cols;
const buffer = term.buffer.active;
const [stringLine, offsets, span] = translateLogicalLine(term, baseRow, maxRow);
const matches: TerminalBufferMatch[] = [];
if (stringLine.length < needle.length) {
return { span, matches };
}
let fromIndex = 0;
while (fromIndex <= stringLine.length - needle.length) {
const idx = stringLine.indexOf(needle, fromIndex);
if (idx < 0) break;
let startRowOffset = 0;
while (startRowOffset < offsets.length - 1 && idx >= offsets[startRowOffset + 1]!) {
startRowOffset++;
}
let endRowOffset = startRowOffset;
while (endRowOffset < offsets.length - 1 && idx + needle.length > offsets[endRowOffset + 1]!) {
endRowOffset++;
}
const startColOffset = idx - offsets[startRowOffset]!;
const endColOffset = idx + needle.length - offsets[endRowOffset]!;
const startColIndex = stringOffsetToCol(buffer.getLine(baseRow + startRowOffset), startColOffset);
const endColIndex = stringOffsetToCol(buffer.getLine(baseRow + endRowOffset), endColOffset);
const matchRow = baseRow + startRowOffset;
const size = endColIndex - startColIndex + cols * (endRowOffset - startRowOffset);
if (size > 0) {
matches.push({ row: matchRow, col: startColIndex, size });
}
fromIndex = idx + needle.length;
}
return { span, matches };
}
/**
* Finds case-sensitive occurrences of `term.getSelection()` in the visible
* viewport rows (`viewportY - marginRows .. viewportY + rows - 1 + marginRows`),
* excluding the active selection itself and skipping the alternate buffer.
*/
export function findVisibleSelectionMatches(
term: Terminal,
cache?: SelectionLineCache,
marginRows = VIEWPORT_MARGIN_ROWS,
): TerminalBufferMatch[] {
const buffer = term.buffer.active;
if (buffer.type === "alternate") return [];
const text = term.getSelection();
if (
text.length < MIN_SELECTION_LENGTH ||
text.length > MAX_SELECTION_LENGTH ||
text.includes("\n") ||
text.includes("\r") ||
!text.trim()
) {
return [];
}
const cols = term.cols;
if (cols <= 0 || term.rows <= 0) return [];
const selPos = term.getSelectionPosition();
const selStart = selPos ? selPos.start.y * cols + selPos.start.x : -1;
const selEnd = selPos ? selPos.end.y * cols + selPos.end.x : -1;
const totalRows = Math.max(buffer.length, buffer.baseY + term.rows);
if (totalRows <= 0) return [];
const viewStart = Math.max(0, buffer.viewportY - marginRows);
const viewEnd = Math.min(totalRows - 1, buffer.viewportY + term.rows - 1 + marginRows);
const maxWrapEnd = Math.min(totalRows - 1, viewEnd + MAX_WRAP_ROWS);
cache?.syncKey(text, cols);
cache?.prune(Math.max(0, viewStart - MAX_WRAP_ROWS), maxWrapEnd);
const visited = new Set<number>();
const out: TerminalBufferMatch[] = [];
for (let row = viewStart; row <= viewEnd; row++) {
let baseRow = row;
let steps = 0;
while (baseRow > 0 && steps < MAX_WRAP_ROWS && buffer.getLine(baseRow)?.isWrapped) {
baseRow--;
steps++;
}
if (visited.has(baseRow)) continue;
visited.add(baseRow);
let entry = cache?.get(baseRow);
if (!entry) {
if (cache) cache.scans++;
entry = scanLogicalLineMatches(term, baseRow, maxWrapEnd, text);
cache?.set(baseRow, entry);
}
for (const match of entry.matches) {
const matchStart = match.row * cols + match.col;
const matchEnd = matchStart + match.size;
const overlapsSelection = selPos !== undefined && matchStart < selEnd && selStart < matchEnd;
if (!overlapsSelection) {
out.push(match);
if (out.length >= MAX_VISIBLE_MATCHES) return out;
}
}
}
return out;
}
export interface SelectionHighlightOptions {
isEnabled?: () => boolean;
isSearchSelection?: () => boolean;
}
export interface SelectionHighlightController extends IDisposable {
readonly cache: SelectionLineCache;
refresh(): void;
clear(): void;
}
export function installSelectionHighlights(
term: Terminal,
opts: SelectionHighlightOptions = {},
): SelectionHighlightController {
const isEnabled = opts.isEnabled ?? (() => workflowSettings().terminal.highlightSelection);
const isSearchSelection = opts.isSearchSelection ?? (() => false);
const cache = new SelectionLineCache();
let overlay: HTMLDivElement | null = null;
const pool: HTMLDivElement[] = [];
let activeBoxes = 0;
let rafId = 0;
let lastWriteMs = 0;
let heavyOutput = false;
let settleTimer: ReturnType<typeof setTimeout> | undefined;
const ensureOverlay = (): HTMLDivElement | null => {
if (overlay && overlay.isConnected) return overlay;
const screen = term.element?.querySelector<HTMLElement>(".xterm-screen");
if (!screen) return null;
if (!overlay) {
overlay = document.createElement("div");
overlay.className = OVERLAY_CONTAINER_CLASS;
overlay.setAttribute("aria-hidden", "true");
}
screen.prepend(overlay);
return overlay;
};
const hideBoxesFrom = (startIndex: number) => {
for (let i = startIndex; i < activeBoxes; i++) {
const box = pool[i];
if (box) box.style.display = "none";
}
activeBoxes = startIndex;
};
const clearOverlay = () => {
hideBoxesFrom(0);
};
const cellDimensions = (screen: HTMLElement): { cellWidth: number; cellHeight: number } => {
const w = parseFloat(screen.style.width) || screen.clientWidth;
const h = parseFloat(screen.style.height) || screen.clientHeight;
const cellWidth = w > 0 && term.cols > 0 ? w / term.cols : 8;
const cellHeight = h > 0 && term.rows > 0 ? h / term.rows : 16;
return { cellWidth, cellHeight };
};
const recompute = () => {
rafId = 0;
if (
!isEnabled() ||
isSearchSelection() ||
heavyOutput ||
!term.hasSelection() ||
term.buffer.active.type === "alternate"
) {
clearOverlay();
return;
}
const container = ensureOverlay();
if (!container || !container.parentElement) {
clearOverlay();
return;
}
const matches = findVisibleSelectionMatches(term, cache);
if (!matches.length) {
clearOverlay();
return;
}
const { cellWidth, cellHeight } = cellDimensions(container.parentElement);
const cols = term.cols;
const rows = term.rows;
const viewportY = term.buffer.active.viewportY;
let boxIdx = 0;
for (const match of matches) {
let currentCol = match.col;
let remaining = match.size;
let currentRow = match.row;
while (remaining > 0) {
const width = Math.min(cols - currentCol, remaining);
const viewportRow = currentRow - viewportY;
if (viewportRow >= 0 && viewportRow < rows) {
let box = pool[boxIdx];
if (!box) {
box = document.createElement("div");
box.className = OVERLAY_MATCH_CLASS;
pool.push(box);
container.appendChild(box);
}
box.style.display = "block";
box.style.top = `${viewportRow * cellHeight}px`;
box.style.left = `${currentCol * cellWidth}px`;
box.style.width = `${width * cellWidth}px`;
box.style.height = `${cellHeight}px`;
boxIdx++;
}
currentCol = 0;
remaining -= width;
currentRow++;
}
}
hideBoxesFrom(boxIdx);
};
const scheduleFrame = () => {
if (rafId !== 0 || heavyOutput) return;
rafId = window.requestAnimationFrame(recompute);
};
const cancelFrame = () => {
if (rafId !== 0) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
};
const onWriteParsed = () => {
if (!term.hasSelection() || isSearchSelection() || !isEnabled()) return;
const now = performance.now();
const isBurst = now - lastWriteMs <= HEAVY_WRITE_WINDOW_MS;
lastWriteMs = now;
if (isBurst || heavyOutput) {
heavyOutput = true;
cancelFrame();
clearOverlay();
if (settleTimer !== undefined) clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
settleTimer = undefined;
heavyOutput = false;
cache.clear();
scheduleFrame();
}, HEAVY_SETTLE_MS);
}
};
const disposables: IDisposable[] = [
term.onSelectionChange(() => {
if (!term.hasSelection() || isSearchSelection()) {
cancelFrame();
clearOverlay();
return;
}
scheduleFrame();
}),
term.onScroll(() => {
if (term.hasSelection() && !isSearchSelection() && !heavyOutput) {
scheduleFrame();
}
}),
term.onRender(({ start, end }) => {
const viewportY = term.buffer.active.viewportY;
cache.invalidateRange(viewportY + start, viewportY + end);
if (term.hasSelection() && !isSearchSelection() && !heavyOutput) {
scheduleFrame();
}
}),
term.onResize(() => {
cache.clear();
if (term.hasSelection() && !isSearchSelection() && !heavyOutput) {
scheduleFrame();
}
}),
term.onWriteParsed(onWriteParsed),
term.buffer.onBufferChange(() => {
cache.clear();
if (term.buffer.active.type === "alternate") {
cancelFrame();
clearOverlay();
} else if (term.hasSelection() && !isSearchSelection()) {
scheduleFrame();
}
}),
];
return {
cache,
refresh() {
cache.clear();
cancelFrame();
recompute();
},
clear() {
cancelFrame();
clearOverlay();
},
dispose() {
cancelFrame();
if (settleTimer !== undefined) {
clearTimeout(settleTimer);
settleTimer = undefined;
}
for (const d of disposables) d.dispose();
overlay?.remove();
overlay = null;
pool.length = 0;
activeBoxes = 0;
cache.clear();
},
};
}
+18 -1
View File
@@ -16,6 +16,7 @@ import { fitScheduler, settledResize } from "./fit";
import { watchWindowsPty, windowsPty } from "./windowsPty";
import { installTerminalDrop } from "./drop";
import { installDomRowCache } from "./domRowCache";
import { installSelectionHighlights, type SelectionHighlightController } from "./highlight";
import { installTerminalPaste, pasteTerminalClipboard } from "./paste";
import { ImeCommitGate } from "./imeCommit";
import { ownTerminalInput } from "./imeInput";
@@ -206,6 +207,9 @@ export class TerminalSession {
private readonly searchAddon = new SearchAddon({ highlightLimit: 2000 });
private searchDecorations: ISearchOptions["decorations"] | null = null;
private readonly searchListeners = new Set<(r: TerminalSearchResult) => void>();
private selectingSearchMatch = false;
private searchSelection = false;
private readonly selectionHighlights: SelectionHighlightController;
private readonly opts: SessionOptions;
private snapshot: SessionSnapshot;
private ptyId: TerminalId | null = null;
@@ -309,7 +313,9 @@ export class TerminalSession {
if (workflowSettings().terminal.audibleBell) beep();
}),
this.term.onSelectionChange(() => {
if (workflowSettings().terminal.copyOnSelection && this.term.hasSelection()) this.copySelection();
this.searchSelection = this.selectingSearchMatch && this.term.hasSelection();
if (!this.searchSelection && workflowSettings().terminal.copyOnSelection && this.term.hasSelection())
this.copySelection();
}),
// Mouse reporting off: ignore the application's requests to enable mouse tracking.
this.term.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
@@ -318,6 +324,10 @@ export class TerminalSession {
return modes.length > 0 && modes.every((m) => MOUSE_MODES.has(m));
}),
);
this.selectionHighlights = installSelectionHighlights(this.term, {
isSearchSelection: () => this.searchSelection,
});
this.disposables.push(this.selectionHighlights);
this.disposables.push(
{ dispose: installTerminalPaste(this.host, (text) => this.paste(text), this.clipboardTarget) },
{ dispose: installTerminalDrop(this.host, (text) => this.paste(text)) },
@@ -385,6 +395,7 @@ export class TerminalSession {
o.cursorStyle = CURSOR[t.cursorShape];
o.cursorBlink = t.cursorBlink;
o.macOptionIsMeta = t.optionAsMeta;
this.selectionHighlights.refresh();
this.scheduleFit();
}
@@ -756,6 +767,7 @@ export class TerminalSession {
this.clearSearch();
return false;
}
this.selectionHighlights.clear();
this.searchDecorations ??= readSearchDecorations();
const options: ISearchOptions = {
...opts,
@@ -763,6 +775,7 @@ export class TerminalSession {
decorations: this.searchDecorations,
};
try {
this.selectingSearchMatch = true;
return direction === "next"
? this.searchAddon.findNext(query, options)
: this.searchAddon.findPrevious(query, options);
@@ -770,12 +783,16 @@ export class TerminalSession {
// Invalid regular expression.
this.clearSearch();
return false;
} finally {
this.selectingSearchMatch = false;
}
}
clearSearch() {
this.searchAddon.clearDecorations();
this.term.clearSelection();
this.searchSelection = false;
this.selectionHighlights.clear();
for (const l of this.searchListeners) l({ index: -1, count: 0 });
}
+2 -1
View File
@@ -985,5 +985,6 @@
"Imported 1 MCP server from the project.": "Imported 1 MCP server from the project.",
"Imported {count} MCP servers from the project.": "Imported {count} MCP servers from the project.",
"Show branch and incoming/outgoing commits in the Project view": "Show branch and incoming/outgoing commits in the Project view",
"Up to date with {upstream}": "Up to date with {upstream}"
"Up to date with {upstream}": "Up to date with {upstream}",
"Highlight occurrences of selected text": "Highlight occurrences of selected text"
}
-1
View File
@@ -2016,7 +2016,6 @@
"Show Commit in Git Log": "Mostrar confirmación en el registro de Git",
"Show Container Log": "Mostrar registro del contenedor",
"Show Context Actions": "Mostrar acciones de contexto",
"Show changes": "Mostrar cambios",
"Show Coverage": "Mostrar cobertura",
"Show Debug Tool Window": "Mostrar ventana de herramientas de depuración",
"Show Diff": "Mostrar diferencias",
-1
View File
@@ -1928,7 +1928,6 @@
"Show Commit in Git Log": "Afficher le commit dans le journal Git",
"Show Container Log": "Afficher le journal du conteneur",
"Show Context Actions": "Afficher les actions contextuelles",
"Show changes": "Afficher les modifications",
"Show Coverage": "Afficher la couverture",
"Show Debug Tool Window": "Afficher la fenêtre d'outil Débogage",
"Show Diff": "Afficher le diff",
-1
View File
@@ -2072,7 +2072,6 @@
"Show Commit in Git Log": "Git 로그에서 커밋 표시",
"Show Container Log": "컨테이너 로그 표시",
"Show Context Actions": "컨텍스트 액션 표시",
"Show changes": "변경 사항 표시",
"Show Coverage": "커버리지 표시",
"Show Debug Tool Window": "디버그 도구 창 표시",
"Show Diff": "Diff 표시",
-1
View File
@@ -2003,7 +2003,6 @@
"Show Commit in Git Log": "Pokaż zatwierdzenie w dzienniku Git",
"Show Container Log": "Pokaż dziennik kontenera",
"Show Context Actions": "Pokaż akcje kontekstowe",
"Show changes": "Pokaż zmiany",
"Show Coverage": "Pokaż pokrycie kodu",
"Show Debug Tool Window": "Pokaż okno narzędziowe debugowania",
"Show Diff": "Pokaż różnice (Diff)",
-1
View File
@@ -2004,7 +2004,6 @@
"Show Commit in Git Log": "Mostrar commit no log do Git",
"Show Container Log": "Mostrar log do contêiner",
"Show Context Actions": "Mostrar ações de contexto",
"Show changes": "Mostrar alterações",
"Show Coverage": "Mostrar cobertura",
"Show Debug Tool Window": "Mostrar janela de ferramentas de depuração",
"Show Diff": "Mostrar diff",
+5 -2
View File
@@ -343,6 +343,8 @@
"Editor and Preview": "编辑器和预览",
"Preview Only": "仅预览",
"Add Maven Projects": "添加 Maven 项目",
"Some Maven projects were not added": "部分 Maven 项目未能添加",
"These are outside the open project, and Maven only links POMs inside it: {join}{more}": "这些项目位于已打开项目之外,而 Maven 仅链接项目内的 POM:{join}{more}",
"Unlink Maven Projects": "取消链接 Maven 项目",
"Unlink Maven projects?": "取消链接 Maven 项目?",
"Remove this project and its modules from Maven? Files will remain in the Project view.": "从 Maven 中移除此项目及其模块?文件将保留在项目视图中。",
@@ -2333,6 +2335,7 @@
"Services": "服务",
"Services actions": "服务操作",
"Session finished": "会话已结束",
"Sessions from other apps": "来自其他应用的智能体会话",
"Set Active Changelist": "设置活动更改列表",
"Set DEFAULT": "设为默认值",
"Set NULL": "设为 NULL",
@@ -2364,7 +2367,6 @@
"Show Commit in Git Log": "在 Git 日志中显示提交",
"Show Container Log": "显示容器日志",
"Show Context Actions": "显示上下文操作",
"Show changes": "显示更改",
"Show Coverage": "显示覆盖率",
"Show Debug Tool Window": "显示调试工具窗口",
"Show Diff": "显示差异",
@@ -7317,5 +7319,6 @@
"Imported 1 MCP server from the project.": "已从项目导入 1 个 MCP 服务器。",
"Imported {count} MCP servers from the project.": "已从项目导入 {count} 个 MCP 服务器。",
"Show branch and incoming/outgoing commits in the Project view": "在项目视图中显示分支及传入/传出提交",
"Up to date with {upstream}": "已与 {upstream} 同步"
"Up to date with {upstream}": "已与 {upstream} 同步",
"Highlight occurrences of selected text": "高亮所选文本的匹配项"
}
+3
View File
@@ -118,6 +118,7 @@ export interface TerminalSettingsValid {
overrideIdeShortcuts: boolean;
shellIntegration: boolean;
highlightHyperlinks: boolean;
highlightSelection: boolean;
optionAsMeta: boolean;
escapeToEditor: boolean;
cursorShape: CursorShape;
@@ -276,6 +277,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = {
overrideIdeShortcuts: true,
shellIntegration: true,
highlightHyperlinks: true,
highlightSelection: true,
optionAsMeta: false,
escapeToEditor: false,
cursorShape: "block",
@@ -444,6 +446,7 @@ export function validateTerminal(raw: unknown): TerminalSettingsValid {
overrideIdeShortcuts: bool(v.overrideIdeShortcuts, d.overrideIdeShortcuts),
shellIntegration: bool(v.shellIntegration, d.shellIntegration),
highlightHyperlinks: bool(v.highlightHyperlinks, d.highlightHyperlinks),
highlightSelection: bool(v.highlightSelection, d.highlightSelection),
optionAsMeta: bool(v.optionAsMeta, d.optionAsMeta),
escapeToEditor: bool(v.escapeToEditor, d.escapeToEditor),
cursorShape: oneOf(v.cursorShape, ["block", "underline", "bar"] as const, d.cursorShape),
+6
View File
@@ -50,6 +50,12 @@ html[data-selection-lock] *:not([data-selection-scope], [data-selection-scope] *
-webkit-user-select: none !important;
user-select: none !important;
}
/* WebKit keeps editable text selectable whatever its user-select, so a drag passing over a
text field would extend into it; take fields outside the scope out of hit testing instead. */
html[data-selection-lock]
:is(input, textarea, [contenteditable]):not([data-selection-scope], [data-selection-scope] *) {
pointer-events: none !important;
}
input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"]),
textarea,