Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions claude-eclipse-core/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3651,6 +3651,16 @@ fn process_event_value(
}
}
cursor.1 = t.len();
// The block is whole, with its signature, which says whether it is the model's
// narration: its commentary between steps, which the view shows as a line of text
// (the extension does) rather than a collapsed "Thinking" row. Not a subagent's.
if !is_partial
&& !is_subagent
&& !t.trim().is_empty()
&& block["signature"].as_str().is_some_and(crate::session::is_narration_signature)
{
fire_string(java_vm, callbacks, "onThinkingBlock", "narration");
}
}
"tool_use" if !is_partial => {
if is_subagent {
Expand Down Expand Up @@ -3685,11 +3695,12 @@ fn process_event_value(
}
}
}
// A subagent's own per-turn usage, summed across its whole run — the
// Agents popup's duration+tokens line. Anthropic's `usage` is PER
// MESSAGE, not cumulative, so this has to add rather than overwrite;
// only counted once a message is actually final (never for a partial
// delta still in flight, which would double-count once it completes).
// A subagent's token count so far, as the CLI's own figure for it is made:
// the usage of its LATEST message, which already counts everything it has
// read (cache reads are the whole context again on every call), not a sum of
// them — a sum counted that context once per call (30M for an agent the CLI
// reports at 296k). Only a message that is final counts, never a partial
// delta still in flight. The CLI's total replaces it when the agent ends.
// Model rides along on the same message (also the detail popup's
// "Sonnet 5"-style field) rather than its own separate event.
if is_subagent && !is_partial {
Expand All @@ -3698,7 +3709,9 @@ fn process_event_value(
+ usage["output_tokens"].as_u64().unwrap_or(0)
+ usage["cache_creation_input_tokens"].as_u64().unwrap_or(0)
+ usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
cursor.2 += added;
if added > 0 {
cursor.2 = added;
}
let model = event["message"]["model"].as_str().unwrap_or("");
if added > 0 || !model.is_empty() {
let activity = serde_json::json!({
Expand All @@ -3724,6 +3737,14 @@ fn process_event_value(
if let Some(n) = tokens.on_stream_event(&event["event"]) {
fire_string(java_vm, callbacks, "onTokens", &n.to_string());
}
// A new thinking block begins, so the one before it (if any) is over: what it said
// and what this one says are rows of their own, as in the extension. The text held
// back from the block before has already gone out, ahead of this event.
if event["event"]["type"].as_str() == Some("content_block_start")
&& event["event"]["content_block"]["type"].as_str() == Some("thinking")
{
fire_string(java_vm, callbacks, "onThinkingBlock", "start");
}
}
// Text and thinking flow as they are written. The cursor records how much of
// the block went out, so the finished "assistant" event above only sends the
Expand Down Expand Up @@ -4167,11 +4188,10 @@ const USAGE_PROBE_DIR: &str = "claude-eclipse-usage";
/// Best-effort: failures are ignored, since a leftover file is harmless and the
/// next probe retries the sweep.
fn purge_probe_transcripts() {
let home = match std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) {
Some(h) => std::path::PathBuf::from(h),
None => return,
let Some(config) = crate::session::config_dir() else {
return;
};
let projects = home.join(".claude").join("projects");
let projects = config.join("projects");
let entries = match std::fs::read_dir(&projects) {
Ok(e) => e,
Err(_) => return,
Expand Down
22 changes: 22 additions & 0 deletions claude-eclipse-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1662,6 +1662,28 @@ pub extern "system" fn Java_com_anthropic_claudecode_eclipse_NativeCore_listMent
finish_export(&mut env, "listMentions", result, std::ptr::null_mut())
}

/// Which of the `@` list's files a dropped file is, as its path under `root`, or "" when it is
/// none of them (see `mentions::project_file_for`: the name, size and time must all fit).
/// **Blocking** (it may walk the folder): off the UI thread.
#[no_mangle]
pub extern "system" fn Java_com_anthropic_claudecode_eclipse_NativeCore_projectFileFor(
mut env: JNIEnv,
_class: JClass,
root: JString,
name: JString,
size: jlong,
modified_ms: jlong,
) -> jstring {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut arg = |s: JString| jstr(&mut env, &s);
let root = arg(root);
let name = arg(name);
let found = mentions::project_file_for(&root, &name, size.max(0) as u64, modified_ms).unwrap_or_default();
jout(&mut env, found, "")
}));
finish_export(&mut env, "projectFileFor", result, std::ptr::null_mut())
}

// ===========================================================================
// MCP servers window JNI entry point
// ===========================================================================
Expand Down
83 changes: 83 additions & 0 deletions claude-eclipse-core/src/mentions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,40 @@ pub fn mention_rows_json(
}
}

/// Which of the `@` list's files a dropped file is, when it is one of them. A page is told
/// a dropped file's name, size and time of last write, and not where it lives, so a file is
/// taken for the project's only when a listed file of that name has that size and was written
/// within two seconds of that time (the coarsest a file system keeps it). A copy from
/// elsewhere with the same name does not pass for it, and neither does one of two listed files
/// that both fit. The path is relative to `root` and `/`-separated, as the list gives it.
pub fn project_file_for(root: &str, name: &str, size: u64, modified_ms: i64) -> Option<String> {
// Without a time there is nothing to tell a file from a same-named one of the same size.
if root.trim().is_empty() || name.is_empty() || modified_ms <= 0 {
return None;
}
let base = std::path::Path::new(root);
let mut found: Option<String> = None;
for entry in cached_entries(root).iter().filter(|e| !e.dir && e.name == name) {
let Ok(meta) = std::fs::metadata(base.join(&entry.path)) else { continue };
if meta.len() != size {
continue;
}
let written = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64);
if !written.is_some_and(|w| (w - modified_ms).abs() <= 2000) {
continue;
}
if found.is_some() {
return None;
}
found = Some(entry.path.clone());
}
found
}

fn row(e: &Entry) -> serde_json::Value {
serde_json::json!({
"path": e.path,
Expand Down Expand Up @@ -357,4 +391,53 @@ mod tests {
assert_eq!(rows[0]["name"], "readme.md");
assert_eq!(rows[1]["name"], "Docs");
}
// ---- a dropped file that is one of the project's ----

fn written_ms(path: &std::path::Path) -> i64 {
std::fs::metadata(path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64
}

#[test]
fn a_dropped_file_is_the_projects_when_its_name_size_and_time_all_fit() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("src")).unwrap();
std::fs::create_dir_all(root.path().join("docs")).unwrap();
std::fs::write(root.path().join("src/App.java"), "class A {}").unwrap();
std::fs::write(root.path().join("docs/App.java"), "class Documented { /* longer */ }").unwrap();
let at = written_ms(&root.path().join("src/App.java"));
let r = root.path().to_string_lossy().to_string();

// Same name in two places: the size says which.
assert_eq!(project_file_for(&r, "App.java", 10, at).as_deref(), Some("src/App.java"));
assert_eq!(project_file_for(&r, "App.java", 33, written_ms(&root.path().join("docs/App.java"))).as_deref(), Some("docs/App.java"));
// A near time is the same time (a file system may keep it to two seconds).
assert_eq!(project_file_for(&r, "App.java", 10, at + 1500).as_deref(), Some("src/App.java"));
// Not the project's: another size, another time, no time at all, another name.
assert_eq!(project_file_for(&r, "App.java", 11, at), None);
assert_eq!(project_file_for(&r, "App.java", 10, at - 60_000), None, "a copy from elsewhere, written long ago");
assert_eq!(project_file_for(&r, "App.java", 10, 0), None, "without a time it would be a guess");
assert_eq!(project_file_for(&r, "Other.java", 10, at), None);
assert_eq!(project_file_for("", "App.java", 10, at), None);
assert_eq!(project_file_for(&r, "", 10, at), None);
}

#[test]
fn two_files_that_both_fit_are_not_told_apart_by_guessing() {
let root = tempfile::tempdir().unwrap();
for dir in ["a", "b"] {
std::fs::create_dir_all(root.path().join(dir)).unwrap();
std::fs::write(root.path().join(dir).join("same.txt"), "identical").unwrap();
}
let at = written_ms(&root.path().join("a/same.txt"));
assert_eq!(project_file_for(&root.path().to_string_lossy(), "same.txt", 9, at), None);
}

#[test]
fn a_file_the_list_leaves_out_is_not_the_projects() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join(".git")).unwrap();
std::fs::write(root.path().join(".git/config"), "[core]").unwrap();
let at = written_ms(&root.path().join(".git/config"));
assert_eq!(project_file_for(&root.path().to_string_lossy(), "config", 6, at), None);
}
}
Loading
Loading