@qubiqlabs/mobiflow 0.9.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +9 -11
  2. package/bin/mobiflow.js +94 -61
  3. package/package.json +8 -3
  4. package/pyproject.toml +59 -0
  5. package/src/mobiflow/__init__.py +9 -0
  6. package/src/mobiflow/__main__.py +6 -0
  7. package/src/mobiflow/baseline.py +228 -0
  8. package/src/mobiflow/casedata.py +159 -0
  9. package/src/mobiflow/cases/__init__.py +715 -0
  10. package/src/mobiflow/cli.py +1423 -0
  11. package/src/mobiflow/cloud/__init__.py +28 -0
  12. package/src/mobiflow/cloud/base.py +272 -0
  13. package/src/mobiflow/cloud/browserstack.py +330 -0
  14. package/src/mobiflow/cloud/maestro_cloud.py +141 -0
  15. package/src/mobiflow/cloud/media.py +269 -0
  16. package/src/mobiflow/cloud/runner.py +156 -0
  17. package/src/mobiflow/cloud/testmu.py +378 -0
  18. package/src/mobiflow/config/__init__.py +538 -0
  19. package/src/mobiflow/deps.py +377 -0
  20. package/src/mobiflow/devices.py +717 -0
  21. package/src/mobiflow/explore.py +623 -0
  22. package/src/mobiflow/incremental.py +198 -0
  23. package/src/mobiflow/init/__init__.py +794 -0
  24. package/src/mobiflow/llm.py +462 -0
  25. package/src/mobiflow/llm_catalog.py +232 -0
  26. package/src/mobiflow/maestro/__init__.py +1506 -0
  27. package/src/mobiflow/maestro/lifecycle.py +279 -0
  28. package/src/mobiflow/pipeline.py +600 -0
  29. package/src/mobiflow/report/__init__.py +617 -0
  30. package/src/mobiflow/report/static/favicon.jpg +0 -0
  31. package/src/mobiflow/report/static/favicon.svg +1 -0
  32. package/src/mobiflow/report/static/icons.svg +24 -0
  33. package/src/mobiflow/report/static/index.html +99 -0
  34. package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
  35. package/src/mobiflow/reporting.py +682 -0
  36. package/src/mobiflow/sample_apps.py +259 -0
  37. package/src/mobiflow/secrets.py +90 -0
  38. package/src/mobiflow/selectors.py +128 -0
  39. package/src/mobiflow/suite.py +263 -0
@@ -0,0 +1,623 @@
1
+ """Explore-then-generate: discovery LLM walks the app before codegen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import re
9
+ from collections.abc import Callable
10
+ from dataclasses import asdict, dataclass, field
11
+ from typing import Any
12
+
13
+ from mobiflow.llm import ChatUsage, invoke_chat_text, merge_usage_list, profile_to_llm_config
14
+ from mobiflow.llm_catalog import ModelEntry
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ ProgressFn = Callable[[str], None] | None
19
+
20
+ _JSON_FENCE_RE = re.compile(
21
+ r"```(?:json)?\s*\n(.*?)```",
22
+ re.DOTALL | re.IGNORECASE,
23
+ )
24
+
25
+ _EXPLORE_SYSTEM = """You are a mobile QA explorer for Maestro automation.
26
+ You observe the current screen hierarchy and decide the next exploration step
27
+ toward the user's test goal. You do NOT write the final Maestro test yet.
28
+
29
+ Respond with ONLY a JSON object (optionally in a ```json fence) with keys:
30
+ {
31
+ "status": "continue" | "done",
32
+ "observation": "what you see on this screen that matters for the goal",
33
+ "screen": "short screen name/label",
34
+ "plan_so_far": ["ordered steps discovered so far toward the goal"],
35
+ "selectors": [{"label": "human name", "text": "visible text or id hint"}],
36
+ "next_action": null | {
37
+ "command": "tapOn|scroll|swipe|inputText|pressKey|scrollUntilVisible|waitForAnimationToEnd",
38
+ "text": "selector or value when needed",
39
+ "optional": false
40
+ },
41
+ "notes": "risks, onboarding to dismiss, assertions to make later"
42
+ }
43
+
44
+ Rules:
45
+ 1) Prefer stable visible text selectors from the hierarchy.
46
+ 2) If onboarding/dialogs block the goal, dismiss them first (Skip/Next/Continue/Allow/Not now).
47
+ 3) status=done when you have enough grounded steps/selectors to author a full test.
48
+ 4) next_action must be ONE atomic UI action when status=continue.
49
+ 5) Never invent UI that is not supported by the hierarchy (unless launching/navigating obviously required).
50
+ 6) Keep plan_so_far cumulative and specific.
51
+ """
52
+
53
+
54
+ @dataclass
55
+ class ExploreAction:
56
+ command: str
57
+ text: str = ""
58
+ optional: bool = False
59
+
60
+ def to_maestro_lines(self) -> list[str]:
61
+ cmd = (self.command or "").strip()
62
+ if not cmd:
63
+ return []
64
+ # Normalize common aliases
65
+ aliases = {
66
+ "tap": "tapOn",
67
+ "click": "tapOn",
68
+ "type": "inputText",
69
+ "entertext": "inputText",
70
+ "press": "pressKey",
71
+ "scroll_until": "scrollUntilVisible",
72
+ "wait": "waitForAnimationToEnd",
73
+ }
74
+ cmd = aliases.get(cmd.lower().replace(" ", ""), cmd)
75
+ if cmd == "waitForAnimationToEnd":
76
+ return ["- waitForAnimationToEnd"]
77
+ if cmd == "scroll":
78
+ return ["- scroll"]
79
+ if cmd == "swipe":
80
+ return ["- swipe:", " direction: UP"]
81
+ if cmd == "pressKey":
82
+ key = self.text or "Enter"
83
+ return [f"- pressKey: {key}"]
84
+ if cmd == "inputText":
85
+ return [f'- inputText: "{_escape(self.text)}"']
86
+ if cmd == "scrollUntilVisible":
87
+ return [
88
+ "- scrollUntilVisible:",
89
+ f' text: "{_escape(self.text)}"',
90
+ " direction: DOWN",
91
+ ]
92
+ # default tapOn
93
+ line = f'- tapOn: "{_escape(self.text)}"'
94
+ if self.optional:
95
+ return ["- tapOn:", f' text: "{_escape(self.text)}"', " optional: true"]
96
+ return [line]
97
+
98
+
99
+ @dataclass
100
+ class ExploreStep:
101
+ index: int
102
+ screen: str = ""
103
+ observation: str = ""
104
+ action: ExploreAction | None = None
105
+ action_ok: bool | None = None
106
+ hierarchy_excerpt: str = ""
107
+ notes: str = ""
108
+
109
+
110
+ @dataclass
111
+ class ExplorationResult:
112
+ goal: str
113
+ app_id: str
114
+ platform: str
115
+ steps: list[ExploreStep] = field(default_factory=list)
116
+ plan: list[str] = field(default_factory=list)
117
+ selectors: list[dict[str, str]] = field(default_factory=list)
118
+ notes: list[str] = field(default_factory=list)
119
+ final_hierarchy: str = ""
120
+ completed: bool = False
121
+ mode: str = "device" # device | plan_only | skipped
122
+ usage: ChatUsage = field(default_factory=ChatUsage)
123
+
124
+ def to_prompt_block(self) -> str:
125
+ """Serialize for codegen LLM context."""
126
+ if self.mode == "skipped" or (not self.steps and not self.plan):
127
+ return ""
128
+ lines = [
129
+ "Exploration results (observe the app BEFORE writing YAML):",
130
+ f"mode: {self.mode}",
131
+ f"appId: {self.app_id}",
132
+ f"platform: {self.platform}",
133
+ ]
134
+ if self.plan:
135
+ lines.append("Grounded plan:")
136
+ for i, step in enumerate(self.plan, 1):
137
+ lines.append(f" {i}. {step}")
138
+ if self.selectors:
139
+ lines.append("Observed selectors:")
140
+ for sel in self.selectors[:40]:
141
+ label = sel.get("label") or sel.get("text") or ""
142
+ text = sel.get("text") or ""
143
+ lines.append(f" - {label}: {text}".rstrip(": "))
144
+ if self.notes:
145
+ lines.append("Explorer notes:")
146
+ for n in self.notes[-12:]:
147
+ lines.append(f" - {n}")
148
+ if self.steps:
149
+ lines.append("Screen observations:")
150
+ for st in self.steps:
151
+ act = ""
152
+ if st.action:
153
+ act = f" → {st.action.command} {st.action.text}".rstrip()
154
+ if st.action_ok is not None:
155
+ act += " (ok)" if st.action_ok else " (failed)"
156
+ lines.append(
157
+ f" [{st.index}] {st.screen or 'screen'}: {st.observation}{act}"
158
+ )
159
+ if self.final_hierarchy.strip():
160
+ lines.append(
161
+ "Final hierarchy (truncated):\n" + self.final_hierarchy.strip()[:8000]
162
+ )
163
+ return "\n".join(lines)
164
+
165
+ def to_dict(self) -> dict[str, Any]:
166
+ return asdict(self)
167
+
168
+
169
+ def _escape(text: str) -> str:
170
+ return (text or "").replace("\\", "\\\\").replace('"', '\\"')
171
+
172
+
173
+ def extract_json_object(text: str) -> dict[str, Any]:
174
+ """Best-effort parse of a JSON object from model output."""
175
+ raw = (text or "").strip()
176
+ if not raw:
177
+ return {}
178
+ m = _JSON_FENCE_RE.search(raw)
179
+ if m:
180
+ raw = m.group(1).strip()
181
+ # Find outermost {...}
182
+ start = raw.find("{")
183
+ end = raw.rfind("}")
184
+ if start >= 0 and end > start:
185
+ raw = raw[start : end + 1]
186
+ try:
187
+ data = json.loads(raw)
188
+ return data if isinstance(data, dict) else {}
189
+ except json.JSONDecodeError:
190
+ # Trailing commas / light repair
191
+ repaired = re.sub(r",\s*([}\]])", r"\1", raw)
192
+ try:
193
+ data = json.loads(repaired)
194
+ return data if isinstance(data, dict) else {}
195
+ except json.JSONDecodeError:
196
+ logger.warning("Explore decision JSON parse failed")
197
+ return {}
198
+
199
+
200
+ def parse_explore_decision(text: str) -> dict[str, Any]:
201
+ data = extract_json_object(text)
202
+ status = str(data.get("status") or "done").strip().lower()
203
+ if status not in {"continue", "done"}:
204
+ status = "done"
205
+ action_raw = data.get("next_action")
206
+ action: ExploreAction | None = None
207
+ if isinstance(action_raw, dict) and status == "continue":
208
+ cmd = str(action_raw.get("command") or action_raw.get("type") or "").strip()
209
+ if cmd:
210
+ action = ExploreAction(
211
+ command=cmd,
212
+ text=str(action_raw.get("text") or action_raw.get("value") or ""),
213
+ optional=bool(action_raw.get("optional") or False),
214
+ )
215
+ plan = data.get("plan_so_far") or data.get("plan") or []
216
+ if not isinstance(plan, list):
217
+ plan = [str(plan)]
218
+ selectors = data.get("selectors") or []
219
+ if not isinstance(selectors, list):
220
+ selectors = []
221
+ norm_sels: list[dict[str, str]] = []
222
+ for sel in selectors:
223
+ if isinstance(sel, dict):
224
+ norm_sels.append(
225
+ {
226
+ "label": str(sel.get("label") or sel.get("name") or ""),
227
+ "text": str(sel.get("text") or sel.get("id") or ""),
228
+ }
229
+ )
230
+ elif isinstance(sel, str):
231
+ norm_sels.append({"label": sel, "text": sel})
232
+ return {
233
+ "status": status,
234
+ "observation": str(data.get("observation") or ""),
235
+ "screen": str(data.get("screen") or ""),
236
+ "plan": [str(p) for p in plan if str(p).strip()],
237
+ "selectors": norm_sels,
238
+ "action": action,
239
+ "notes": str(data.get("notes") or ""),
240
+ }
241
+
242
+
243
+ def build_action_flow_yaml(app_id: str, action: ExploreAction, *, launch: bool = False) -> str:
244
+ lines = [f"appId: {app_id}", "name: MobiFlow explore step", "---"]
245
+ if launch:
246
+ lines.append("- launchApp")
247
+ lines.extend(action.to_maestro_lines())
248
+ return "\n".join(lines) + "\n"
249
+
250
+
251
+ def build_launch_flow_yaml(app_id: str) -> str:
252
+ return (
253
+ f"appId: {app_id}\n"
254
+ f"name: MobiFlow explore launch\n"
255
+ f"---\n"
256
+ f"- launchApp\n"
257
+ )
258
+
259
+
260
+ def summarize_hierarchy(hierarchy: str, *, limit: int = 3500) -> str:
261
+ text = (hierarchy or "").strip()
262
+ if len(text) <= limit:
263
+ return text
264
+ return text[:limit] + "\n…(truncated)"
265
+
266
+
267
+ async def decide_explore_step(
268
+ *,
269
+ goal: str,
270
+ app_id: str,
271
+ platform: str,
272
+ hierarchy: str,
273
+ plan: list[str],
274
+ history: list[str],
275
+ profile: ModelEntry,
276
+ step_index: int,
277
+ max_steps: int,
278
+ usage_out: list[ChatUsage] | None = None,
279
+ ) -> dict[str, Any]:
280
+ llm_config = profile_to_llm_config(profile)
281
+ user = "\n\n".join(
282
+ [
283
+ f"Platform: {platform}",
284
+ f"App ID: {app_id}",
285
+ f"Explore step: {step_index}/{max_steps}",
286
+ f"Goal:\n{goal}",
287
+ "Plan so far:\n"
288
+ + ("\n".join(f"- {p}" for p in plan) if plan else "- (empty)"),
289
+ "Recent history:\n"
290
+ + ("\n".join(f"- {h}" for h in history[-8:]) if history else "- (none)"),
291
+ "Current hierarchy (truncated):\n" + summarize_hierarchy(hierarchy),
292
+ "Return the JSON decision now.",
293
+ ]
294
+ )
295
+ text = await asyncio.to_thread(
296
+ invoke_chat_text,
297
+ _EXPLORE_SYSTEM,
298
+ user,
299
+ llm_config,
300
+ max_tokens=2048,
301
+ temperature=0.2,
302
+ log_prefix="MobiFlowExplore",
303
+ usage_out=usage_out,
304
+ )
305
+ return parse_explore_decision(text or "")
306
+
307
+
308
+ async def plan_only_explore(
309
+ *,
310
+ goal: str,
311
+ app_id: str,
312
+ platform: str,
313
+ profile: ModelEntry,
314
+ progress: ProgressFn = None,
315
+ ) -> ExplorationResult:
316
+ """No device: ask discovery LLM for a grounded plan from the goal alone."""
317
+ if progress:
318
+ progress("Explore (plan-only — no live device hierarchy)…")
319
+ llm_config = profile_to_llm_config(profile)
320
+ system = (
321
+ _EXPLORE_SYSTEM
322
+ + "\nNo hierarchy is available. Set status=done and produce the best "
323
+ "plan_so_far + likely selectors from the goal. next_action must be null."
324
+ )
325
+ user = (
326
+ f"Platform: {platform}\nApp ID: {app_id}\nGoal:\n{goal}\n"
327
+ "Return JSON with status=done, plan_so_far, selectors, notes."
328
+ )
329
+ usage_bucket: list[ChatUsage] = []
330
+ text = await asyncio.to_thread(
331
+ invoke_chat_text,
332
+ system,
333
+ user,
334
+ llm_config,
335
+ max_tokens=2048,
336
+ temperature=0.2,
337
+ log_prefix="MobiFlowExplore",
338
+ usage_out=usage_bucket,
339
+ )
340
+ decision = parse_explore_decision(text or "")
341
+ result = ExplorationResult(
342
+ goal=goal,
343
+ app_id=app_id,
344
+ platform=platform,
345
+ plan=decision.get("plan") or [],
346
+ selectors=decision.get("selectors") or [],
347
+ notes=[decision["notes"]] if decision.get("notes") else [],
348
+ completed=True,
349
+ mode="plan_only",
350
+ usage=merge_usage_list(usage_bucket),
351
+ )
352
+ if decision.get("observation"):
353
+ result.steps.append(
354
+ ExploreStep(
355
+ index=1,
356
+ screen=decision.get("screen") or "planned",
357
+ observation=decision.get("observation") or "",
358
+ notes=decision.get("notes") or "",
359
+ )
360
+ )
361
+ return result
362
+
363
+
364
+ # Interactive decision: accept | skip | edit | done | quit
365
+ AskFn = Callable[[dict[str, Any]], dict[str, Any]]
366
+
367
+
368
+ async def explore_app(
369
+ goal: str,
370
+ *,
371
+ app_id: str,
372
+ platform: str,
373
+ device_id: str,
374
+ profile: ModelEntry,
375
+ max_steps: int = 5,
376
+ step_timeout_s: int = 90,
377
+ progress: ProgressFn = None,
378
+ interactive: bool = False,
379
+ ask: AskFn | None = None,
380
+ ) -> ExplorationResult:
381
+ """Live explore loop: hierarchy → discovery decision → optional nav action.
382
+
383
+ When ``interactive=True``, each proposed action is confirmed via ``ask``
384
+ (or a default stdin prompt). Choices: accept | skip | edit | done | quit.
385
+ This is a separate operator-driven mode — not Maestro Studio.
386
+ """
387
+ from mobiflow.maestro import fetch_hierarchy, resolve_app_id, run_flow_yaml
388
+
389
+ resolved = resolve_app_id(app_id, platform, goal)
390
+ result = ExplorationResult(
391
+ goal=goal,
392
+ app_id=resolved,
393
+ platform=platform,
394
+ mode="interactive" if interactive else "device",
395
+ )
396
+ history: list[str] = []
397
+ usage_bucket: list[ChatUsage] = []
398
+ max_steps = max(1, min(int(max_steps or 5), 12))
399
+ ask_fn = ask or (default_interactive_ask if interactive else None)
400
+
401
+ if progress:
402
+ progress(f"Explore: launching {resolved}…")
403
+ launch = await run_flow_yaml(
404
+ build_launch_flow_yaml(resolved),
405
+ device_id=device_id,
406
+ timeout_s=step_timeout_s,
407
+ )
408
+ history.append(
409
+ "launchApp " + ("ok" if launch.get("ok") else f"failed:{launch.get('error')}")
410
+ )
411
+
412
+ for i in range(1, max_steps + 1):
413
+ if progress:
414
+ progress(f"Explore step {i}/{max_steps}: reading hierarchy…")
415
+ hierarchy = await fetch_hierarchy(device_id)
416
+ result.final_hierarchy = hierarchy
417
+ decision = await decide_explore_step(
418
+ goal=goal,
419
+ app_id=resolved,
420
+ platform=platform,
421
+ hierarchy=hierarchy,
422
+ plan=result.plan,
423
+ history=history,
424
+ profile=profile,
425
+ step_index=i,
426
+ max_steps=max_steps,
427
+ usage_out=usage_bucket,
428
+ )
429
+ if decision.get("plan"):
430
+ result.plan = decision["plan"]
431
+ if decision.get("selectors"):
432
+ # merge unique selectors
433
+ seen = {(s.get("label"), s.get("text")) for s in result.selectors}
434
+ for sel in decision["selectors"]:
435
+ key = (sel.get("label"), sel.get("text"))
436
+ if key not in seen and (sel.get("text") or sel.get("label")):
437
+ result.selectors.append(sel)
438
+ seen.add(key)
439
+ if decision.get("notes"):
440
+ result.notes.append(decision["notes"])
441
+
442
+ step = ExploreStep(
443
+ index=i,
444
+ screen=decision.get("screen") or "",
445
+ observation=decision.get("observation") or "",
446
+ hierarchy_excerpt=summarize_hierarchy(hierarchy, limit=1200),
447
+ notes=decision.get("notes") or "",
448
+ action=decision.get("action"),
449
+ )
450
+
451
+ # Model thinks we're done
452
+ if decision.get("status") == "done" or not decision.get("action"):
453
+ if interactive and ask_fn is not None and decision.get("status") == "done":
454
+ choice = ask_fn(
455
+ {
456
+ "kind": "done_proposal",
457
+ "step": i,
458
+ "max_steps": max_steps,
459
+ "decision": decision,
460
+ "hierarchy_excerpt": step.hierarchy_excerpt,
461
+ }
462
+ )
463
+ selected = str(choice.get("choice") or "done").lower()
464
+ if selected == "quit":
465
+ result.notes.append("interactive: quit by operator")
466
+ result.steps.append(step)
467
+ break
468
+ if selected == "skip":
469
+ # Operator wants to keep exploring despite model done
470
+ history.append("operator: continue after model done")
471
+ step.action = None
472
+ result.steps.append(step)
473
+ continue
474
+ step.action = None
475
+ result.steps.append(step)
476
+ result.completed = True
477
+ if progress:
478
+ progress("Explore complete — enough context for codegen.")
479
+ break
480
+
481
+ action: ExploreAction = decision["action"]
482
+
483
+ if interactive and ask_fn is not None:
484
+ choice = ask_fn(
485
+ {
486
+ "kind": "action_proposal",
487
+ "step": i,
488
+ "max_steps": max_steps,
489
+ "decision": decision,
490
+ "action": action,
491
+ "hierarchy_excerpt": step.hierarchy_excerpt,
492
+ }
493
+ )
494
+ selected = str(choice.get("choice") or "accept").lower()
495
+ if selected == "quit":
496
+ result.notes.append("interactive: quit by operator")
497
+ result.steps.append(step)
498
+ break
499
+ if selected == "done":
500
+ step.action = None
501
+ result.steps.append(step)
502
+ result.completed = True
503
+ if progress:
504
+ progress("Explore stopped by operator — ready for codegen.")
505
+ break
506
+ if selected == "skip":
507
+ history.append(
508
+ f"operator skipped {action.command}:{action.text}"
509
+ )
510
+ step.action = None
511
+ step.notes = (step.notes + " | skipped by operator").strip(" |")
512
+ result.steps.append(step)
513
+ continue
514
+ if selected == "edit":
515
+ edited = choice.get("action") or {}
516
+ action = ExploreAction(
517
+ command=str(edited.get("command") or action.command),
518
+ text=str(edited.get("text") if "text" in edited else action.text),
519
+ optional=bool(edited.get("optional", action.optional)),
520
+ )
521
+ step.action = action
522
+
523
+ if progress:
524
+ progress(
525
+ f"Explore step {i}/{max_steps}: {action.command} "
526
+ f"{action.text or ''}".rstrip()
527
+ )
528
+ flow = build_action_flow_yaml(resolved, action, launch=False)
529
+ run = await run_flow_yaml(
530
+ flow,
531
+ device_id=device_id,
532
+ timeout_s=step_timeout_s,
533
+ )
534
+ step.action_ok = bool(run.get("ok"))
535
+ history.append(
536
+ f"{action.command}:{action.text} -> "
537
+ + ("ok" if step.action_ok else f"fail:{run.get('error')}")
538
+ )
539
+ result.steps.append(step)
540
+ else:
541
+ result.completed = bool(result.plan or result.selectors)
542
+
543
+ if not result.plan and result.steps:
544
+ # Derive a minimal plan from observations
545
+ result.plan = [
546
+ s.observation
547
+ for s in result.steps
548
+ if s.observation
549
+ ][:8]
550
+ result.usage = merge_usage_list(usage_bucket)
551
+ return result
552
+
553
+
554
+ def default_interactive_ask(payload: dict[str, Any]) -> dict[str, Any]:
555
+ """Stdin/questionary prompt used by ``mobiflow explore --interactive``."""
556
+ import questionary
557
+
558
+ kind = payload.get("kind")
559
+ decision = payload.get("decision") or {}
560
+ step = payload.get("step")
561
+ max_steps = payload.get("max_steps")
562
+ print()
563
+ print(f"— Explore {step}/{max_steps} —")
564
+ if decision.get("screen"):
565
+ print(f"Screen: {decision.get('screen')}")
566
+ if decision.get("observation"):
567
+ print(f"See: {decision.get('observation')}")
568
+ if decision.get("notes"):
569
+ print(f"Notes: {decision.get('notes')}")
570
+ plan = decision.get("plan") or []
571
+ if plan:
572
+ print("Plan: " + " → ".join(plan[-4:]))
573
+
574
+ if kind == "done_proposal":
575
+ choice = questionary.select(
576
+ "Discovery thinks exploration is complete. What next?",
577
+ choices=[
578
+ questionary.Choice("Finish explore (use plan for codegen)", value="done"),
579
+ questionary.Choice("Keep exploring", value="skip"),
580
+ questionary.Choice("Quit", value="quit"),
581
+ ],
582
+ default="done",
583
+ ).ask()
584
+ return {"choice": choice or "done"}
585
+
586
+ action: ExploreAction | None = payload.get("action")
587
+ label = (
588
+ f"{action.command} {action.text}".strip()
589
+ if action
590
+ else "(no action)"
591
+ )
592
+ choice = questionary.select(
593
+ f"Proposed action: {label}",
594
+ choices=[
595
+ questionary.Choice("Accept & run on device", value="accept"),
596
+ questionary.Choice("Edit action text, then run", value="edit"),
597
+ questionary.Choice("Skip this action", value="skip"),
598
+ questionary.Choice("Finish explore now", value="done"),
599
+ questionary.Choice("Quit", value="quit"),
600
+ ],
601
+ default="accept",
602
+ ).ask()
603
+ if not choice:
604
+ return {"choice": "quit"}
605
+ if choice != "edit" or action is None:
606
+ return {"choice": choice}
607
+
608
+ new_cmd = questionary.text(
609
+ "Command (tapOn/scroll/inputText/pressKey/…):",
610
+ default=action.command,
611
+ ).ask()
612
+ new_text = questionary.text(
613
+ "Text / selector / value:",
614
+ default=action.text or "",
615
+ ).ask()
616
+ return {
617
+ "choice": "edit",
618
+ "action": {
619
+ "command": (new_cmd or action.command).strip(),
620
+ "text": (new_text if new_text is not None else action.text),
621
+ "optional": action.optional,
622
+ },
623
+ }