Skip to content

Commit e250832

Browse files
committed
feat: support apply_patch fileChange diffs in tool translation
1 parent 8100228 commit e250832

File tree

10 files changed

+475
-64
lines changed

10 files changed

+475
-64
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,4 @@ OpenCodeMonitor is a fork of [CodexMonitor](https://github.com/Dimillian/CodexMo
4444
## License
4545

4646
MIT — see [LICENSE](LICENSE)
47+

src-tauri/src/backend/app_server.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,11 @@ struct PidFileData {
7474
/// Returns the path to the PID file (~/.opencode-monitor/server.pid).
7575
fn pid_file_path() -> Option<PathBuf> {
7676
let home = env::var("HOME").ok()?;
77-
Some(PathBuf::from(home).join(".opencode-monitor").join("server.pid"))
77+
Some(
78+
PathBuf::from(home)
79+
.join(".opencode-monitor")
80+
.join("server.pid"),
81+
)
7882
}
7983

8084
/// Write PID file after starting the server.
@@ -434,7 +438,10 @@ pub(crate) async fn global_rest_get(
434438
let mut url = format!("{base_url}{path}");
435439
if let Some(directory) = directory.filter(|value| !value.trim().is_empty()) {
436440
let separator = if path.contains('?') { "&" } else { "?" };
437-
url = format!("{url}{separator}directory={}", urlencoding::encode(directory));
441+
url = format!(
442+
"{url}{separator}directory={}",
443+
urlencoding::encode(directory)
444+
);
438445
}
439446
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
440447
if !resp.status().is_success() {

src-tauri/src/backend/event_translator.rs

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use serde_json::{json, Value};
1010
use std::collections::HashMap;
1111
use std::sync::atomic::{AtomicU64, Ordering};
1212

13-
use crate::shared::diff_utils::generate_edit_diff;
13+
use crate::shared::diff_utils::{generate_apply_patch_changes, generate_edit_diff};
1414

1515
/// Per-session turn state — tracks active turn and item IDs for a single session.
1616
#[derive(Default)]
@@ -1052,7 +1052,7 @@ fn translate_tool_part(
10521052
}));
10531053

10541054
if let Some(ref input) = raw_input {
1055-
let delta_text = serde_json::to_string_pretty(input).unwrap_or_default();
1055+
let delta_text = tool_input_delta_text(tool_name, input);
10561056
if !delta_text.is_empty() {
10571057
let method = if item_type == "fileChange" {
10581058
"item/fileChange/outputDelta"
@@ -1122,6 +1122,55 @@ fn translate_tool_part(
11221122
events
11231123
}
11241124

1125+
fn tool_input_delta_text(tool_name: &str, raw_input: &Value) -> String {
1126+
if tool_name == "apply_patch" {
1127+
return build_apply_patch_delta_summary(raw_input)
1128+
.unwrap_or_else(|| "Applying patch...".to_string());
1129+
}
1130+
1131+
serde_json::to_string_pretty(raw_input).unwrap_or_default()
1132+
}
1133+
1134+
fn build_apply_patch_delta_summary(raw_input: &Value) -> Option<String> {
1135+
let patch_text = raw_input.get("patchText").and_then(|v| v.as_str())?;
1136+
if patch_text.trim().is_empty() {
1137+
return Some("Applying patch...".to_string());
1138+
}
1139+
1140+
let changes = generate_apply_patch_changes(raw_input)?;
1141+
if changes.is_empty() {
1142+
return Some("Applying patch...".to_string());
1143+
}
1144+
1145+
let mut labels = Vec::new();
1146+
for change in changes.iter().take(3) {
1147+
let kind = change
1148+
.get("kind")
1149+
.and_then(|v| v.as_str())
1150+
.unwrap_or("modify");
1151+
let path = change
1152+
.get("path")
1153+
.and_then(|v| v.as_str())
1154+
.unwrap_or("file");
1155+
labels.push(format!("{kind} {path}"));
1156+
}
1157+
1158+
let total = changes.len();
1159+
let mut summary = format!(
1160+
"Applying patch to {total} file{}",
1161+
if total == 1 { "" } else { "s" }
1162+
);
1163+
if !labels.is_empty() {
1164+
summary.push_str(": ");
1165+
summary.push_str(&labels.join(", "));
1166+
}
1167+
if total > labels.len() {
1168+
summary.push_str(&format!(", +{} more", total - labels.len()));
1169+
}
1170+
1171+
Some(summary)
1172+
}
1173+
11251174
fn build_explore_entry(tool_name: &str, title: &str, raw_input: Option<&Value>) -> Value {
11261175
let kind = tool_to_explore_kind(tool_name);
11271176

@@ -1731,7 +1780,7 @@ pub(crate) fn build_agent_message_completed(
17311780

17321781
fn tool_kind_to_item_type(kind: &str) -> &str {
17331782
match kind {
1734-
"edit" | "write" | "create" => "fileChange",
1783+
"edit" | "write" | "create" | "apply_patch" => "fileChange",
17351784
"bash" | "command" | "terminal" => "commandExecution",
17361785
"read" | "grep" | "glob" | "list" | "ls" => "explore",
17371786
"todowrite" => "todowrite",
@@ -1935,7 +1984,9 @@ fn build_tool_item(
19351984
let mut changes = changes_from_content.unwrap_or_default();
19361985
if changes.is_empty() {
19371986
if let Some(input) = raw_input {
1938-
if let Some(path) = file_path_from_raw_input(input) {
1987+
if let Some(parsed_changes) = generate_apply_patch_changes(input) {
1988+
changes = parsed_changes;
1989+
} else if let Some(path) = file_path_from_raw_input(input) {
19391990
let mut change = json!({ "path": path, "kind": "modify" });
19401991
if let Some(diff) = generate_edit_diff(input, &path) {
19411992
change["diff"] = json!(diff);
@@ -2123,6 +2174,90 @@ mod tests {
21232174
assert!(diff.contains("+ println!(\"Hello, world!\");"));
21242175
}
21252176

2177+
#[test]
2178+
fn apply_patch_tool_generates_file_change_entries() {
2179+
let mut state = make_state();
2180+
let completed = json!({
2181+
"type": "message.part.updated",
2182+
"properties": {
2183+
"part": {
2184+
"type": "tool",
2185+
"id": "tc_apply_patch",
2186+
"tool": "apply_patch",
2187+
"state": {
2188+
"status": "completed",
2189+
"input": {
2190+
"patchText": "*** Begin Patch\n*** Update File: src/main.rs\n@@ -1,1 +1,1 @@\n-old\n+new\n*** Add File: notes.txt\n+hello\n*** Delete File: old.txt\n*** Update File: src/from.rs\n*** Move to: src/to.rs\n@@ -1,1 +1,1 @@\n-before\n+after\n*** End Patch"
2191+
},
2192+
"output": "Patch applied successfully."
2193+
}
2194+
}
2195+
}
2196+
});
2197+
2198+
let events = translate_sse_event(&completed, &mut state);
2199+
assert_eq!(events.len(), 1);
2200+
let item = &events[0]["params"]["item"];
2201+
assert_eq!(item["type"], "fileChange");
2202+
2203+
let changes = item["changes"].as_array().expect("changes should be array");
2204+
assert_eq!(changes.len(), 4);
2205+
assert_eq!(changes[0]["path"], "src/main.rs");
2206+
assert_eq!(changes[0]["kind"], "modify");
2207+
assert!(changes[0]["diff"]
2208+
.as_str()
2209+
.expect("modify diff")
2210+
.contains("--- a/src/main.rs"));
2211+
2212+
assert_eq!(changes[1]["path"], "notes.txt");
2213+
assert_eq!(changes[1]["kind"], "add");
2214+
assert!(changes[1]["diff"]
2215+
.as_str()
2216+
.expect("add diff")
2217+
.contains("--- /dev/null"));
2218+
2219+
assert_eq!(changes[2]["path"], "old.txt");
2220+
assert_eq!(changes[2]["kind"], "delete");
2221+
assert!(changes[2].get("diff").is_none());
2222+
2223+
assert_eq!(changes[3]["path"], "src/to.rs");
2224+
let rename_diff = changes[3]["diff"].as_str().expect("rename diff");
2225+
assert!(rename_diff.contains("--- a/src/from.rs"));
2226+
assert!(rename_diff.contains("+++ b/src/to.rs"));
2227+
}
2228+
2229+
#[test]
2230+
fn apply_patch_running_delta_uses_summary_not_patch_text() {
2231+
let mut state = make_state();
2232+
let running = json!({
2233+
"type": "message.part.updated",
2234+
"properties": {
2235+
"part": {
2236+
"type": "tool",
2237+
"id": "tc_apply_patch_running",
2238+
"tool": "apply_patch",
2239+
"state": {
2240+
"status": "running",
2241+
"input": {
2242+
"patchText": "*** Begin Patch\n*** Update File: src/main.rs\n@@ -1,1 +1,1 @@\n-old\n+new\n*** Add File: notes.txt\n+hello\n*** End Patch"
2243+
}
2244+
}
2245+
}
2246+
}
2247+
});
2248+
2249+
let events = translate_sse_event(&running, &mut state);
2250+
assert_eq!(events.len(), 2);
2251+
assert_eq!(events[0]["method"], "item/started");
2252+
assert_eq!(events[1]["method"], "item/fileChange/outputDelta");
2253+
let delta = events[1]["params"]["delta"].as_str().expect("delta string");
2254+
assert!(delta.contains("Applying patch to 2 files"));
2255+
assert!(delta.contains("modify src/main.rs"));
2256+
assert!(delta.contains("add notes.txt"));
2257+
assert!(!delta.contains("*** Begin Patch"));
2258+
assert!(!delta.contains("patchText"));
2259+
}
2260+
21262261
#[test]
21272262
fn text_after_tool_completion_uses_new_agent_message_item() {
21282263
let mut state = make_state();

src-tauri/src/bin/codex_monitor_daemon.rs

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -806,31 +806,29 @@ impl DaemonState {
806806
};
807807
let directory = if let Some(workspace_id) = workspace_id.clone() {
808808
let workspaces = self.workspaces.lock().await;
809-
workspaces.get(&workspace_id).map(|entry| entry.path.clone())
809+
workspaces
810+
.get(&workspace_id)
811+
.map(|entry| entry.path.clone())
810812
} else {
811813
None
812814
};
813-
let providers =
814-
backend::app_server::global_rest_get(
815-
codex_bin,
816-
codex_args.as_deref(),
817-
"/config/providers",
818-
directory.as_deref(),
819-
)
820-
.await?;
815+
let providers = backend::app_server::global_rest_get(
816+
codex_bin,
817+
codex_args.as_deref(),
818+
"/config/providers",
819+
directory.as_deref(),
820+
)
821+
.await?;
821822
let mut response = codex_core::model_list_response_from_providers(&providers);
822823
if let Some(obj) = response.as_object_mut() {
823-
obj.insert(
824-
"debug".to_string(),
825-
{
826-
let mut debug = codex_core::model_list_debug_from_providers(&providers);
827-
if let Some(debug_obj) = debug.as_object_mut() {
828-
debug_obj.insert("requestWorkspaceId".to_string(), json!(workspace_id));
829-
debug_obj.insert("requestDirectory".to_string(), json!(directory));
830-
}
831-
debug
832-
},
833-
);
824+
obj.insert("debug".to_string(), {
825+
let mut debug = codex_core::model_list_debug_from_providers(&providers);
826+
if let Some(debug_obj) = debug.as_object_mut() {
827+
debug_obj.insert("requestWorkspaceId".to_string(), json!(workspace_id));
828+
debug_obj.insert("requestDirectory".to_string(), json!(directory));
829+
}
830+
debug
831+
});
834832
}
835833
Ok(response)
836834
}

src-tauri/src/codex/mod.rs

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,13 @@ pub(crate) mod args;
88
pub(crate) mod config;
99
pub(crate) mod home;
1010

11+
pub(crate) use crate::backend::app_server::WorkspaceSession;
1112
use crate::backend::app_server::{
1213
global_rest_get, opencode_restart_required_status as app_server_restart_required_status,
13-
opencode_server_status as app_server_status,
14-
restart_opencode_server as app_server_restart,
14+
opencode_server_status as app_server_status, restart_opencode_server as app_server_restart,
1515
spawn_workspace_session as spawn_workspace_session_inner,
1616
takeover_external_server as app_server_takeover,
1717
};
18-
pub(crate) use crate::backend::app_server::WorkspaceSession;
1918
use crate::backend::events::AppServerEvent;
2019
use crate::event_sink::TauriEventSink;
2120
use crate::remote_backend;
@@ -531,7 +530,9 @@ pub(crate) async fn settings_model_list(
531530
};
532531
let directory = if let Some(workspace_id) = workspace_id.clone() {
533532
let workspaces = state.workspaces.lock().await;
534-
workspaces.get(&workspace_id).map(|entry| entry.path.clone())
533+
workspaces
534+
.get(&workspace_id)
535+
.map(|entry| entry.path.clone())
535536
} else {
536537
None
537538
};
@@ -544,17 +545,14 @@ pub(crate) async fn settings_model_list(
544545
.await?;
545546
let mut response = codex_core::model_list_response_from_providers(&providers);
546547
if let Some(obj) = response.as_object_mut() {
547-
obj.insert(
548-
"debug".to_string(),
549-
{
550-
let mut debug = codex_core::model_list_debug_from_providers(&providers);
551-
if let Some(debug_obj) = debug.as_object_mut() {
552-
debug_obj.insert("requestWorkspaceId".to_string(), json!(workspace_id));
553-
debug_obj.insert("requestDirectory".to_string(), json!(directory));
554-
}
555-
debug
556-
},
557-
);
548+
obj.insert("debug".to_string(), {
549+
let mut debug = codex_core::model_list_debug_from_providers(&providers);
550+
if let Some(debug_obj) = debug.as_object_mut() {
551+
debug_obj.insert("requestWorkspaceId".to_string(), json!(workspace_id));
552+
debug_obj.insert("requestDirectory".to_string(), json!(directory));
553+
}
554+
debug
555+
});
558556
}
559557
Ok(response)
560558
}
@@ -565,8 +563,13 @@ pub(crate) async fn opencode_restart_required_status(
565563
app: AppHandle,
566564
) -> Result<Value, String> {
567565
if remote_backend::is_remote_mode(&*state).await {
568-
return remote_backend::call_remote(&*state, app, "opencode_restart_required_status", json!({}))
569-
.await;
566+
return remote_backend::call_remote(
567+
&*state,
568+
app,
569+
"opencode_restart_required_status",
570+
json!({}),
571+
)
572+
.await;
570573
}
571574
Ok(app_server_restart_required_status().await)
572575
}
@@ -577,7 +580,8 @@ pub(crate) async fn opencode_server_status(
577580
app: AppHandle,
578581
) -> Result<Value, String> {
579582
if remote_backend::is_remote_mode(&*state).await {
580-
return remote_backend::call_remote(&*state, app, "opencode_server_status", json!({})).await;
583+
return remote_backend::call_remote(&*state, app, "opencode_server_status", json!({}))
584+
.await;
581585
}
582586
Ok(app_server_status().await)
583587
}
@@ -588,7 +592,8 @@ pub(crate) async fn opencode_server_restart(
588592
app: AppHandle,
589593
) -> Result<Value, String> {
590594
if remote_backend::is_remote_mode(&*state).await {
591-
return remote_backend::call_remote(&*state, app, "opencode_server_restart", json!({})).await;
595+
return remote_backend::call_remote(&*state, app, "opencode_server_restart", json!({}))
596+
.await;
592597
}
593598
let (codex_bin, codex_args) = {
594599
let settings = state.app_settings.lock().await;

src-tauri/src/dictation/real.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,10 @@ pub(crate) async fn dictation_download_model(
432432
let model_id_clone = model_id.clone();
433433
eprintln!("[dictation] spawning download task for model: {}", model_id);
434434
let task = tokio::spawn(async move {
435-
eprintln!("[dictation] download task started for model: {}", model_id_clone);
435+
eprintln!(
436+
"[dictation] download task started for model: {}",
437+
model_id_clone
438+
);
436439
let state = app_handle.state::<AppState>();
437440
let model_dir = model_dir(&app_handle);
438441
eprintln!("[dictation] model_dir: {:?}", model_dir);
@@ -520,7 +523,10 @@ pub(crate) async fn dictation_download_model(
520523
eprintln!("[dictation] starting HTTP GET request...");
521524
let response = match client.get(url).send().await {
522525
Ok(response) => {
523-
eprintln!("[dictation] HTTP response received, status: {}", response.status());
526+
eprintln!(
527+
"[dictation] HTTP response received, status: {}",
528+
response.status()
529+
);
524530
response
525531
}
526532
Err(error) => {
@@ -688,7 +694,10 @@ pub(crate) async fn dictation_download_model(
688694
return;
689695
}
690696

691-
eprintln!("[dictation] download completed successfully for model: {}", model_id_clone);
697+
eprintln!(
698+
"[dictation] download completed successfully for model: {}",
699+
model_id_clone
700+
);
692701
let status = ready_status(&model_id_clone, &model_path);
693702
update_status(&app_handle, &state, status).await;
694703
clear_download_state(&state).await;

0 commit comments

Comments
 (0)