Skip to content

Commit e88dbb8

Browse files
authored
Merge pull request #207 from LockInTime/feat/206-semantic-select
feat(select): add semantic native dropdown selection
2 parents 55c3194 + 97ceee6 commit e88dbb8

25 files changed

Lines changed: 707 additions & 18 deletions

File tree

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

Lines changed: 2 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+
For native dropdowns, use `select` with exactly one exact `--label` or
36+
`--value`; do not treat custom ARIA widgets as native selects.
3537
6. After navigation or a substantial rerender, wait for the expected URL, text,
3638
settled state, or Chromium network idle and inspect again before the next
3739
interaction.

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ headless --session NAME click REF
3030
headless --session NAME click --role ROLE --name NAME
3131
headless --session NAME fill REF "TEXT"
3232
headless --session NAME fill REF -- "--json stays literal"
33+
headless --session NAME select REF --label LABEL
34+
headless --session NAME select --role combobox --name NAME --value VALUE
3335
headless --session NAME upload REF --artifact FILE
3436
headless --session NAME upload --role textbox --name NAME --artifact FILE
3537
headless --session NAME press KEY
@@ -46,9 +48,10 @@ large pages, request `outline`, select a returned `@rN` region, then use
4648
bound the result; check `omitted` before assuming it describes the whole page.
4749
Use `click --role ... --name ...` for unique accessible controls. Use an `@eN`
4850
ref from the latest inspection when role/name is ambiguous. Inspect again after
49-
navigation or a large rerender. File inputs advertise `upload` for an existing
50-
private artifact-store basename. Upload never accepts or imports a filesystem
51-
path. Ask before uploading, as in [safety.md](safety.md).
51+
navigation or a large rerender. Native single-select controls advertise
52+
`select`; use exactly one exact `--label` or `--value`. File inputs advertise
53+
`upload` for an existing private artifact-store basename. Upload never accepts
54+
or imports a filesystem path. Ask before uploading, as in [safety.md](safety.md).
5255
5356
Pass fill text as one quoted shell argument so whitespace is preserved. Put
5457
`--` before a value that contains a literal global flag such as `--json` or

‎apps/headless/Host/AgentBridge.swift‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ extension BrowserWindowController {
6666
return try callAgent("return globalThis.__headlessAgent.fill(args);", arguments: ["args": args])
6767
}
6868

69+
func agentSelect(parameters: [String: JSONValue]) throws -> JSONValue {
70+
var args = try browserTargetArguments(parameters)
71+
if let label = parameters["label"]?.stringValue { args["label"] = label }
72+
if let value = parameters["value"]?.stringValue { args["value"] = value }
73+
return try callAgent(
74+
"return globalThis.__headlessAgent.select(args);", arguments: ["args": args]
75+
)
76+
}
77+
6978
func agentAuthenticationState() throws -> JSONValue {
7079
try callAgent("return globalThis.__headlessAgent.authentication();")
7180
}
@@ -550,6 +559,9 @@ extension BrowserWindowController: BrowserEngineSession {
550559
func hostFill(parameters: [String: JSONValue]) throws -> JSONValue {
551560
try agentFill(parameters: parameters)
552561
}
562+
func hostSelect(parameters: [String: JSONValue]) throws -> JSONValue {
563+
try agentSelect(parameters: parameters)
564+
}
553565
func hostPress(parameters: [String: JSONValue]) throws -> JSONValue {
554566
try agentPress(parameters: parameters)
555567
}

‎apps/headless/LinuxHost/BrowserProcess.swift‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,15 @@ final class LinuxBrowserSession: @unchecked Sendable {
805805
])
806806
}
807807

808+
func select(parameters: [String: JSONValue]) throws -> JSONValue {
809+
var args = try browserTargetArguments(parameters)
810+
if let label = parameters["label"]?.stringValue { args["label"] = label }
811+
if let value = parameters["value"]?.stringValue { args["value"] = value }
812+
return try evaluate(
813+
"return globalThis.__headlessAgent.select(args);", input: ["args": args]
814+
)
815+
}
816+
808817
func authenticationState() throws -> JSONValue {
809818
try evaluate("return globalThis.__headlessAgent.authentication();")
810819
}

‎apps/headless/LinuxHost/main.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ final class ChromiumBrowserEngineSession: BrowserEngineSession {
9191
func hostFill(parameters: [String: JSONValue]) throws -> JSONValue {
9292
try browserSession.fill(parameters: parameters)
9393
}
94+
func hostSelect(parameters: [String: JSONValue]) throws -> JSONValue {
95+
try browserSession.select(parameters: parameters)
96+
}
9497
func hostUpload(parameters: [String: JSONValue], artifactURL: URL) throws -> JSONValue {
9598
try browserSession.upload(parameters: parameters, artifactURL: artifactURL)
9699
}

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ public struct CLIParser {
158158
return remote(.fill, session: session, parameters: [
159159
"target": .string(arguments[0]), "value": .string(arguments[1]),
160160
], jsonOutput: jsonOutput)
161+
case "select":
162+
return try parseSelect(arguments, session: session, jsonOutput: jsonOutput)
161163
case "press":
162164
guard arguments.count == 1 else { throw CLIParseError.missingArgument("KEY") }
163165
return remote(.press, session: session, parameters: ["key": .string(arguments[0])], jsonOutput: jsonOutput)
@@ -401,6 +403,24 @@ public struct CLIParser {
401403
return remote(.upload, session: session, parameters: parameters, jsonOutput: jsonOutput)
402404
}
403405

406+
private func parseSelect(
407+
_ arguments: [String], session: String?, jsonOutput: Bool
408+
) throws -> CLIInvocation {
409+
var args = arguments
410+
let label = try removeOption("--label", from: &args)
411+
let value = try removeOption("--value", from: &args)
412+
guard (label == nil) != (value == nil) else {
413+
throw CLIParseError.missingArgument("exactly one of --label or --value")
414+
}
415+
let invocation = try parseTargeted(
416+
.select, arguments: args, session: session, jsonOutput: jsonOutput
417+
)
418+
var parameters = invocation.request?.parameters ?? [:]
419+
if let label { parameters["label"] = .string(label) }
420+
if let value { parameters["value"] = .string(value) }
421+
return remote(.select, session: session, parameters: parameters, jsonOutput: jsonOutput)
422+
}
423+
404424
private func parseTargeted(
405425
_ command: CommandName,
406426
arguments: [String],
@@ -856,6 +876,8 @@ Commands:
856876
[--within @rN] [--limit N] [--budget TOKENS] [--depth N] [--text]
857877
click REF | click --role ROLE [--name NAME]
858878
fill REF TEXT | fill REF -- TEXT_WITH_LITERAL_FLAGS | press KEY
879+
select REF --label LABEL | select REF --value VALUE
880+
select --role ROLE [--name NAME] (--label LABEL | --value VALUE)
859881
upload REF --artifact FILE | upload --role ROLE [--name NAME] --artifact FILE
860882
scroll [up|down|top|bottom] [--amount PX]
861883
back | reload

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public struct BrowserEngineCapabilities: Sendable {
2424
public let qaDiagnosticSynchronization: String
2525
public let screenshotClipboard: Bool
2626
public let inputDispatch: String
27+
public let selectDispatch: String
2728
public let networkIdleWait: Bool
2829
public let normalProfileStorage: String
2930
public let fileUpload: Bool
@@ -69,6 +70,7 @@ public struct BrowserEngineCapabilities: Sendable {
6970
"screenshotClipboard": .bool(screenshotClipboard),
7071
"tourTimeoutMs": .number(65_000),
7172
"inputDispatch": .string(inputDispatch),
73+
"selectDispatch": .string(selectDispatch),
7274
"networkIdleWait": .bool(networkIdleWait),
7375
"fileUpload": .bool(fileUpload),
7476
"normalProfile": .object([
@@ -115,6 +117,7 @@ public struct BrowserEngineCapabilities: Sendable {
115117
qaDiagnosticSynchronization: "best-effort-page-world-observer",
116118
screenshotClipboard: true,
117119
inputDispatch: "synthetic-dom",
120+
selectDispatch: "synthetic-dom",
118121
networkIdleWait: false,
119122
normalProfileStorage: "persistent-wkwebsite-data-store",
120123
fileUpload: false
@@ -139,6 +142,7 @@ public struct BrowserEngineCapabilities: Sendable {
139142
qaDiagnosticSynchronization: "runtime-round-trip-flush",
140143
screenshotClipboard: false,
141144
inputDispatch: "trusted-cdp",
145+
selectDispatch: "synthetic-dom",
142146
networkIdleWait: true,
143147
normalProfileStorage: "private-xdg-data-directory",
144148
fileUpload: true

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public protocol BrowserEngineSession: AnyObject {
3737
func hostInspect(parameters: [String: JSONValue]) throws -> JSONValue
3838
func hostClick(parameters: [String: JSONValue]) throws -> JSONValue
3939
func hostFill(parameters: [String: JSONValue]) throws -> JSONValue
40+
func hostSelect(parameters: [String: JSONValue]) throws -> JSONValue
4041
func hostPress(parameters: [String: JSONValue]) throws -> JSONValue
4142
func hostScroll(parameters: [String: JSONValue]) throws -> JSONValue
4243
func hostWait(parameters: [String: JSONValue]) throws -> JSONValue
@@ -554,6 +555,7 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
554555
case .inspect: return try session.hostInspect(parameters: request.parameters)
555556
case .click: return try session.hostClick(parameters: request.parameters)
556557
case .fill: return try session.hostFill(parameters: request.parameters)
558+
case .select: return try session.hostSelect(parameters: request.parameters)
557559
case .upload:
558560
guard let artifact = request.parameters["artifact"]?.stringValue else {
559561
throw HostError(code: .missingParameter, message: "Artifact name is required.")

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ public enum CommandName: String, Codable, CaseIterable, Sendable {
6464
case inspect
6565
case click
6666
case fill
67+
case select
6768
case upload
6869
case press
6970
case scroll
@@ -265,6 +266,15 @@ public struct CommandRequest: Codable, Equatable, Sendable {
265266
try target(allowValue: false)
266267
case .fill:
267268
try target(allowValue: true)
269+
case .select:
270+
try target(allowValue: false)
271+
let label = try string("label", maximumBytes: 1_000)
272+
let value = try string("value", maximumBytes: 1_000)
273+
guard (label == nil) != (value == nil) else {
274+
throw ProtocolValidationError.invalidParameter(
275+
"Choose exactly one option label or value"
276+
)
277+
}
268278
case .upload:
269279
try target(allowValue: false)
270280
if let artifact = try string("artifact", required: true, maximumBytes: 128) {

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,11 @@ public func protocolResultDefinition(for command: CommandName) -> ProtocolResult
320320
])
321321
case .fill:
322322
return result("Fill", [resultField("filled", .string), resultField("valueLength", .number)])
323+
case .select:
324+
return result("Select", [
325+
resultField("selected", .string), resultField("role", .string),
326+
resultField("name", .string), resultField("optionIndex", .number),
327+
])
323328
case .upload:
324329
return result("Upload", [
325330
resultField("uploaded", .string), resultField("role", .string),
@@ -701,6 +706,19 @@ public let protocolCommandDefinitions: [CommandName: ProtocolCommandDefinition]
701706
untrusted: true,
702707
constraints: ["exactly one target reference or semantic role/name target"]
703708
),
709+
command(
710+
.select,
711+
targetParameters + [
712+
string("label", maximumBytes: 1_000, sensitive: true),
713+
string("value", maximumBytes: 1_000, sensitive: true),
714+
],
715+
untrusted: true,
716+
constraints: [
717+
"exactly one target reference or semantic role/name target",
718+
"exactly one option label or value",
719+
"native single-selection HTML select controls only",
720+
]
721+
),
704722
command(
705723
.upload,
706724
targetParameters + [string("artifact", required: true, maximumBytes: 128)],

0 commit comments

Comments
 (0)