From 33269adc1ac74795154d6c190272d6fcc839a06b Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Mon, 15 Jun 2026 11:26:34 +0900 Subject: [PATCH] fix(fill/tabs): dispatch real input/change/blur (#25); close wording + chrome-use current (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #25 — fill() didn't fire the events framework inputs / site autocomplete need: it set value directly (bypassing React's value-tracker) and typed via Input.insertText, so controlled components and input/change/blur listeners (e.g. Mercari's postal-code → 都道府県 lookup) never ran though the value showed. fill now emulates a real edit: focus, set through the element's prototype value setter (React _valueTracker registers), then dispatch input → input → change → blur/ focusout. SELECT and contenteditable handled too. type remains for per-keystroke sites. Verified live: an input wired with input/change/blur fired 'IICB' from one fill. #26 (ergonomics): - 'close ' now closes just that tab and prints 'Tab [tN] closed'; bare 'close' still closes the browser. Previously 'close t12' ran a browser close and alarmingly printed 'Browser closed'. - new 'chrome-use current': prints the active tab's stable handle (tabId + CDP targetId + url/title), refreshed live — so an agent holds the targetId (which survives cross-process nav) instead of re-deriving 'which tab is live' from 'tabs' every step. The deeper tab-id churn is the #21/#23 stable-targetId story. Tests cover fill events (live), close tab-vs-browser parse, and current. --- cli/src/commands.rs | 39 ++++++++++++++++++- cli/src/native/actions.rs | 13 +++++++ cli/src/native/browser.rs | 16 ++++++++ cli/src/native/interaction.rs | 71 +++++++++++++++++++---------------- cli/src/output.rs | 15 ++++++++ 5 files changed, 121 insertions(+), 33 deletions(-) diff --git a/cli/src/commands.rs b/cli/src/commands.rs index dbac782..f1a44ac 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1026,7 +1026,22 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result Ok(json!({ "id": id, "action": "close" })), + "close" | "quit" | "exit" => { + // `close ` closes only that tab (and the output says "Tab + // closed"); bare `close` closes the browser/session. `close --all` is + // intercepted earlier in the dispatcher. Previously `close t12` still + // ran a browser close and alarmingly printed "Browser closed" (#26). + if let Some(tab_ref) = rest.iter().find(|a| !a.starts_with("--")) { + Ok(json!({ "id": id, "action": "tab_close", "tabId": tab_ref })) + } else { + Ok(json!({ "id": id, "action": "close" })) + } + } + + // The active tab's stable handle — `targetId` survives cross-process + // navigation and is reusable across sessions, so an agent can hold it + // instead of re-deriving "which tab is live" from `tabs` each step (#26). + "current" => Ok(json!({ "id": id, "action": "current" })), // === Inspect === "inspect" => Ok(json!({ "id": id, "action": "inspect" })), @@ -4014,6 +4029,28 @@ mod tests { assert_eq!(cmd["tabId"], "docs"); } + #[test] + fn test_close_tab_vs_browser() { + // `close ` closes that tab (says "Tab closed"); bare `close` closes + // the browser (#26). + let tab = parse_command(&args("close t12"), &default_flags()).unwrap(); + assert_eq!(tab["action"], "tab_close"); + assert_eq!(tab["tabId"], "t12"); + let browser = parse_command(&args("close"), &default_flags()).unwrap(); + assert_eq!(browser["action"], "close"); + // `quit`/`exit` aliases still browser-close. + assert_eq!( + parse_command(&args("quit"), &default_flags()).unwrap()["action"], + "close" + ); + } + + #[test] + fn test_current_command() { + let cmd = parse_command(&args("current"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "current"); + } + #[test] fn test_tab_sends_string_tab_id() { let cmd = parse_command(&args("tab t2"), &default_flags()).unwrap(); diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 68147ac..3424cfb 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -1395,6 +1395,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { "count" => handle_count(cmd, state).await, "styles" => handle_styles(cmd, state).await, "bringtofront" => handle_bringtofront(state).await, + "current" => handle_current(state).await, "timezone" => handle_timezone(cmd, state).await, "locale" => handle_locale(cmd, state).await, "geolocation" => handle_geolocation(cmd, state).await, @@ -5333,6 +5334,18 @@ async fn handle_bringtofront(state: &DaemonState) -> Result { Ok(json!({ "broughtToFront": true })) } +async fn handle_current(state: &mut DaemonState) -> Result { + let mgr = state.browser.as_mut().ok_or("Browser not launched")?; + // Refresh so `current` reflects the live URL/title even after a cross-process + // nav (the relay's cached target_info can lag) (#26). + mgr.resync_targets().await.ok(); + let mut info = mgr.active_page_info().ok_or("No active tab")?; + if let Some(obj) = info.as_object_mut() { + obj.insert("current".to_string(), json!(true)); + } + Ok(info) +} + async fn handle_timezone(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let timezone = cmd diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index d308b8f..db08a38 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -1263,6 +1263,22 @@ impl BrowserManager { .collect() } + /// The active tab's stable handle + current location, for `chrome-use + /// current` (#26). `targetId` survives cross-process navigation, so it's the + /// handle an agent should hold across a multi-step flow. + pub fn active_page_info(&self) -> Option { + let i = self.resolved_active_index(); + self.pages.get(i).map(|p| { + json!({ + "tabId": format_tab_id(p.tab_id), + "targetId": p.target_id, + "label": p.label, + "url": p.url, + "title": p.title, + }) + }) + } + /// Stable `tab_id` for a page identified by its CDP `targetId`, if tracked. /// Lets callers adopt a tab by the cross-session-stable target id. pub fn tab_id_for_target(&self, target_id: &str) -> Option { diff --git a/cli/src/native/interaction.rs b/cli/src/native/interaction.rs index c4fbdd0..c382609 100644 --- a/cli/src/native/interaction.rs +++ b/cli/src/native/interaction.rs @@ -306,32 +306,50 @@ pub async fn fill( ) .await?; - // Focus the element - client - .send_command_typed::<_, Value>( - "Runtime.callFunctionOn", - &CallFunctionOnParams { - function_declaration: "function() { this.focus(); }".to_string(), - object_id: Some(object_id.clone()), - arguments: None, - return_by_value: Some(true), - await_promise: Some(false), - }, - Some(&effective_session_id), - ) - .await?; + // Emulate a real edit so framework-controlled inputs (React/Vue) and + // site-side listeners actually see the change (issue #25): the old path set + // `this.value` directly and used Input.insertText, which left React's + // internal value-tracker out of sync and never fired change/blur — so + // dependent logic (e.g. Mercari's postal-code → 都道府県 autocomplete) never + // ran even though the value was visible. Set the value through the element's + // PROTOTYPE setter (which React's _valueTracker hooks), then dispatch + // input → change → blur/focusout. `type ` remains for sites that + // need per-keystroke events. + let fill_js = format!( + r#"function() {{ + const el = this; + const v = {val}; + try {{ el.focus(); }} catch (e) {{}} + const tag = el.tagName; + const fire = (type, ctor) => el.dispatchEvent(new (ctor || Event)(type, {{ bubbles: true }})); + if (tag === 'SELECT') {{ + el.value = v; fire('input'); fire('change'); return true; + }} + if (el.isContentEditable) {{ + el.textContent = v; fire('input', window.InputEvent || Event); fire('change'); + try {{ el.blur(); }} catch (e) {{}} fire('focusout'); return true; + }} + const proto = tag === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype + : window.HTMLInputElement.prototype; + const desc = Object.getOwnPropertyDescriptor(proto, 'value'); + const set = desc && desc.set ? (x) => desc.set.call(el, x) : (x) => {{ el.value = x; }}; + set(''); // reset the framework tracker + fire('input', window.InputEvent || Event); + set(v); // native setter → React/Vue registers + fire('input', window.InputEvent || Event); + fire('change'); + try {{ el.blur(); }} catch (e) {{}} + fire('focusout'); // blur-triggered lookups/validation + return true; + }}"#, + val = serde_json::to_string(value).unwrap_or_default() + ); - // Select all + delete to clear client .send_command_typed::<_, Value>( "Runtime.callFunctionOn", &CallFunctionOnParams { - function_declaration: r#"function() { - this.select && this.select(); - this.value = ''; - this.dispatchEvent(new Event('input', { bubbles: true })); - }"# - .to_string(), + function_declaration: fill_js, object_id: Some(object_id), arguments: None, return_by_value: Some(true), @@ -341,17 +359,6 @@ pub async fn fill( ) .await?; - // Insert text (keyboard input dispatched at page level, use parent session_id) - client - .send_command_typed::<_, Value>( - "Input.insertText", - &InsertTextParams { - text: value.to_string(), - }, - Some(session_id), - ) - .await?; - Ok(()) } diff --git a/cli/src/output.rs b/cli/src/output.rs index be08aaa..f942cc9 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -209,6 +209,21 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou ); } + // `current`: the active tab's stable handle (#26). + if data + .get("current") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + let tid = data.get("tabId").and_then(|v| v.as_str()).unwrap_or("?"); + let title = data.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let url = data.get("url").and_then(|v| v.as_str()).unwrap_or(""); + let target = data.get("targetId").and_then(|v| v.as_str()).unwrap_or(""); + println!("{} [{}] {} - {}", color::cyan("→"), tid, title, url); + println!(" {}", color::dim(&format!("target: {}", target))); + return; + } + // Dialog status response if action == Some("dialog") { if let Some(has_dialog) = data.get("hasDialog").and_then(|v| v.as_bool()) {