Skip to content

Commit 7c9a1c2

Browse files
authored
Merge pull request #210 from LockInTime/feat/35-semantic-hover
feat: add bounded semantic hover
2 parents 1ce7c96 + ca431af commit 7c9a1c2

38 files changed

Lines changed: 1026 additions & 97 deletions

‎.agents/skills/headless-computer-use/SKILL.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Chromium capabilities; macOS WebKit reports them as unsupported.
3232
4. Start with `inspect --context summary --task "..."`, then use an outline,
3333
scoped text, or scoped actions only when the task needs them.
3434
5. Prefer role/name targeting; otherwise use a ref from the latest inspection.
35+
Use `hover` when the task requires a tooltip or other hover-only state; it
36+
does not click or focus the target. Inspect again after the state changes.
3537
For native dropdowns, use `select` with exactly one exact `--label` or
3638
`--value`; do not treat custom ARIA widgets as native selects.
3739
6. After navigation or a substantial rerender, wait for the expected URL, text,
@@ -49,6 +51,7 @@ headless session create agent-qa
4951
headless --session agent-qa visit http://localhost:3000
5052
headless --session agent-qa inspect --context summary --task "click Continue"
5153
headless --session agent-qa inspect --context actions --task "click Continue" --limit 10
54+
headless --session agent-qa hover --role button --name Continue
5255
headless --session agent-qa click --role button --name Continue
5356
headless --session agent-qa wait --url /next --settled --timeout 10000
5457
headless --session agent-qa inspect --context actions --task "verify next page"

‎.agents/skills/headless-computer-use/references/commands.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ headless --session NAME inspect --context actions --task "TASK"
2828
headless --session NAME inspect --context full --text
2929
headless --session NAME click REF
3030
headless --session NAME click --role ROLE --name NAME
31+
headless --session NAME hover REF
32+
headless --session NAME hover --role ROLE --name NAME
3133
headless --session NAME fill REF "TEXT"
3234
headless --session NAME fill REF -- "--json stays literal"
3335
headless --session NAME select REF --label LABEL
@@ -48,7 +50,9 @@ large pages, request `outline`, select a returned `@rN` region, then use
4850
bound the result; check `omitted` before assuming it describes the whole page.
4951
Use `click --role ... --name ...` for unique accessible controls. Use an `@eN`
5052
ref from the latest inspection when role/name is ambiguous. Inspect again after
51-
navigation or a large rerender. Native single-select controls advertise
53+
navigation or a large rerender. Use `hover` only for hover-dependent state such
54+
as tooltips; it does not focus or click, and its output never includes internal
55+
coordinates. Native single-select controls advertise
5256
`select`; use exactly one exact `--label` or `--value`. File inputs advertise
5357
`upload` for an existing private artifact-store basename. Upload never accepts
5458
or imports a filesystem path. Ask before uploading, as in [safety.md](safety.md).

‎CHANGELOG.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ Two versions travel independently, on purpose:
1212
`HEADLESS_VERSION`, reported by the CLI, host, and MCP adapter, and used for
1313
release assets.
1414
- **Protocol version**: `headlessProtocolVersion` in `Protocol.swift`,
15-
currently `0.5`. It changes only when the wire contract changes, and always
16-
with an entry in
15+
currently `0.5`. It changes only for incompatible wire-contract changes,
16+
always with an entry in
1717
[`docs/roadmap/architecture-decisions.md`](docs/roadmap/architecture-decisions.md).
1818

1919
## [Unreleased]
@@ -24,6 +24,9 @@ Two versions travel independently, on purpose:
2424

2525
### Added
2626

27+
- Semantic `hover` targets a fresh element ref or an exact role/name without
28+
exposing coordinates. Chromium dispatches a trusted CDP mouse move, while
29+
WebKit reports its fixed synthetic hover path in capabilities.
2730
- macOS Command-, opens one Settings window built from the typed registry and
2831
writing through the same backend as `headless config`. Linux has no Settings
2932
GUI.

‎README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ headless --session qa inspect --context outline --limit 20
7979
headless --session qa inspect --context actions --task "click Continue"
8080
headless --session qa record start --fps 10
8181
headless --session qa tour --full-page
82+
headless --session qa hover --role button --name Continue
8283
headless --session qa click --role button --name Continue
8384
headless --session qa wait --url /next --settled
8485
headless --session qa record stop --output dashboard-flow.mp4
@@ -128,7 +129,10 @@ structural region references such as `@r4`, then inspect only that region with
128129
explicit broad-page escape hatch.
129130

130131
Each control's `actions` list contains only protocol verbs that can run (`click`,
131-
`fill`, or `upload`); unsupported controls never advertise nonexistent commands. Full
132+
`fill`, `select`, or `upload`); unsupported controls never advertise nonexistent
133+
commands. Hover is available for any visible semantic target but is not
134+
advertised because a page does not expose whether hovering has meaningful
135+
behavior. Full
132136
inspection retains roles, names, rendered media metadata, safety markers,
133137
bounds, and element references such as `@e1`.
134138
`capture-info` returns the browser surface, page state, action trace, and

‎apps/headless-rs/src/protocol.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pub enum CommandName {
2626
Visit,
2727
Inspect,
2828
Click,
29+
Hover,
2930
Fill,
3031
Press,
3132
Scroll,
@@ -93,6 +94,7 @@ impl CommandName {
9394
CommandName::Visit => "visit",
9495
CommandName::Inspect => "inspect",
9596
CommandName::Click => "click",
97+
CommandName::Hover => "hover",
9698
CommandName::Fill => "fill",
9799
CommandName::Press => "press",
98100
CommandName::Scroll => "scroll",
@@ -350,7 +352,7 @@ impl CommandRequest {
350352
}
351353
Ok(())
352354
}
353-
Click => self.target(false, true, true),
355+
Click | Hover => self.target(false, true, true),
354356
Fill => self.target(true, true, true),
355357
Press => {
356358
self.allow(&["key"])?;

‎apps/headless-rs/tests/protocol_tests.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,22 @@ fn command_parameter_validation() {
165165
let request = valid_request(CommandName::Click, params(&[("target", json!("@food"))]));
166166
assert!(request.validate().is_err());
167167

168+
// hover follows the same exclusive target contract as click
169+
let request = valid_request(CommandName::Hover, params(&[("target", json!("@e8"))]));
170+
request.validate().unwrap();
171+
let request = valid_request(
172+
CommandName::Hover,
173+
params(&[("role", json!("button")), ("name", json!("Account"))]),
174+
);
175+
request.validate().unwrap();
176+
let request = valid_request(CommandName::Hover, params(&[]));
177+
assert!(request.validate().is_err());
178+
let request = valid_request(
179+
CommandName::Hover,
180+
params(&[("target", json!("@e8")), ("name", json!("Account"))]),
181+
);
182+
assert!(request.validate().is_err());
183+
168184
// fill requires a value
169185
let request = valid_request(CommandName::Fill, params(&[("target", json!("@e12"))]));
170186
assert!(request.validate().is_err());

‎apps/headless/Host/AgentBridge.swift‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ extension BrowserWindowController {
5757
return try callAgent("return globalThis.__headlessAgent.click(args);", arguments: ["args": args])
5858
}
5959

60+
func agentHover(parameters: [String: JSONValue]) throws -> JSONValue {
61+
let args = try browserTargetArguments(parameters)
62+
return try callAgent("return globalThis.__headlessAgent.hover(args);", arguments: ["args": args])
63+
}
64+
6065
func agentFill(parameters: [String: JSONValue]) throws -> JSONValue {
6166
var args = try browserTargetArguments(parameters)
6267
guard let value = parameters["value"]?.stringValue else {
@@ -644,6 +649,9 @@ extension BrowserWindowController: BrowserEngineSession {
644649
func hostClick(parameters: [String: JSONValue]) throws -> JSONValue {
645650
try agentClick(parameters: parameters)
646651
}
652+
func hostHover(parameters: [String: JSONValue]) throws -> JSONValue {
653+
try agentHover(parameters: parameters)
654+
}
647655
func hostFill(parameters: [String: JSONValue]) throws -> JSONValue {
648656
try agentFill(parameters: parameters)
649657
}

‎apps/headless/LinuxHost/BrowserProcess.swift‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,21 @@ final class LinuxBrowserSession: @unchecked Sendable {
773773
])
774774
}
775775

776+
func hover(parameters: [String: JSONValue]) throws -> JSONValue {
777+
let target = try trustedInputTarget(parameters: parameters, action: "hover")
778+
_ = try command("Input.dispatchMouseEvent", parameters: [
779+
"type": "mouseMoved", "x": target.x, "y": target.y,
780+
])
781+
// A hover handler can start a cross-document navigation before the next
782+
// command. Pause capture so a recording does not composite that frame.
783+
pauseRecordingCapture()
784+
return .object([
785+
"hovered": .string(target.reference),
786+
"role": .string(target.role),
787+
"name": .string(target.name),
788+
])
789+
}
790+
776791
func upload(parameters: [String: JSONValue], artifactURL: URL) throws -> JSONValue {
777792
let args = try browserTargetArguments(parameters)
778793
let objectId = try evaluateNode(

‎apps/headless/LinuxHost/main.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ final class ChromiumBrowserEngineSession: BrowserEngineSession {
8888
func hostClick(parameters: [String: JSONValue]) throws -> JSONValue {
8989
try browserSession.click(parameters: parameters)
9090
}
91+
func hostHover(parameters: [String: JSONValue]) throws -> JSONValue {
92+
try browserSession.hover(parameters: parameters)
93+
}
9194
func hostFill(parameters: [String: JSONValue]) throws -> JSONValue {
9295
try browserSession.fill(parameters: parameters)
9396
}

‎apps/headless/Sources/HeadlessProtocol/CLI.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ public struct CLIParser {
151151
return try parseInspect(arguments, session: session, jsonOutput: jsonOutput)
152152
case "click":
153153
return try parseTargeted(.click, arguments: arguments, session: session, jsonOutput: jsonOutput)
154+
case "hover":
155+
return try parseTargeted(.hover, arguments: arguments, session: session, jsonOutput: jsonOutput)
154156
case "upload":
155157
return try parseUpload(arguments, session: session, jsonOutput: jsonOutput)
156158
case "fill":
@@ -884,6 +886,7 @@ Commands:
884886
inspect [--context summary|outline|text|actions|full] [--task TEXT]
885887
[--within @rN] [--limit N] [--budget TOKENS] [--depth N] [--text]
886888
click REF | click --role ROLE [--name NAME]
889+
hover REF | hover --role ROLE [--name NAME]
887890
fill REF TEXT | fill REF -- TEXT_WITH_LITERAL_FLAGS | press KEY
888891
select REF --label LABEL | select REF --value VALUE
889892
select --role ROLE [--name NAME] (--label LABEL | --value VALUE)

0 commit comments

Comments
 (0)