minitap-mobile-use 3.3.0__py3-none-any.whl

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 (115) hide show
  1. minitap/mobile_use/__init__.py +0 -0
  2. minitap/mobile_use/agents/contextor/contextor.md +55 -0
  3. minitap/mobile_use/agents/contextor/contextor.py +175 -0
  4. minitap/mobile_use/agents/contextor/types.py +36 -0
  5. minitap/mobile_use/agents/cortex/cortex.md +135 -0
  6. minitap/mobile_use/agents/cortex/cortex.py +152 -0
  7. minitap/mobile_use/agents/cortex/types.py +15 -0
  8. minitap/mobile_use/agents/executor/executor.md +42 -0
  9. minitap/mobile_use/agents/executor/executor.py +87 -0
  10. minitap/mobile_use/agents/executor/tool_node.py +152 -0
  11. minitap/mobile_use/agents/hopper/hopper.md +15 -0
  12. minitap/mobile_use/agents/hopper/hopper.py +44 -0
  13. minitap/mobile_use/agents/orchestrator/human.md +12 -0
  14. minitap/mobile_use/agents/orchestrator/orchestrator.md +21 -0
  15. minitap/mobile_use/agents/orchestrator/orchestrator.py +134 -0
  16. minitap/mobile_use/agents/orchestrator/types.py +11 -0
  17. minitap/mobile_use/agents/outputter/human.md +25 -0
  18. minitap/mobile_use/agents/outputter/outputter.py +85 -0
  19. minitap/mobile_use/agents/outputter/test_outputter.py +167 -0
  20. minitap/mobile_use/agents/planner/human.md +14 -0
  21. minitap/mobile_use/agents/planner/planner.md +126 -0
  22. minitap/mobile_use/agents/planner/planner.py +101 -0
  23. minitap/mobile_use/agents/planner/types.py +51 -0
  24. minitap/mobile_use/agents/planner/utils.py +70 -0
  25. minitap/mobile_use/agents/summarizer/summarizer.py +35 -0
  26. minitap/mobile_use/agents/video_analyzer/__init__.py +5 -0
  27. minitap/mobile_use/agents/video_analyzer/human.md +5 -0
  28. minitap/mobile_use/agents/video_analyzer/video_analyzer.md +37 -0
  29. minitap/mobile_use/agents/video_analyzer/video_analyzer.py +111 -0
  30. minitap/mobile_use/clients/browserstack_client.py +477 -0
  31. minitap/mobile_use/clients/idb_client.py +429 -0
  32. minitap/mobile_use/clients/ios_client.py +332 -0
  33. minitap/mobile_use/clients/ios_client_config.py +141 -0
  34. minitap/mobile_use/clients/ui_automator_client.py +330 -0
  35. minitap/mobile_use/clients/wda_client.py +526 -0
  36. minitap/mobile_use/clients/wda_lifecycle.py +367 -0
  37. minitap/mobile_use/config.py +413 -0
  38. minitap/mobile_use/constants.py +3 -0
  39. minitap/mobile_use/context.py +106 -0
  40. minitap/mobile_use/controllers/__init__.py +0 -0
  41. minitap/mobile_use/controllers/android_controller.py +524 -0
  42. minitap/mobile_use/controllers/controller_factory.py +46 -0
  43. minitap/mobile_use/controllers/device_controller.py +182 -0
  44. minitap/mobile_use/controllers/ios_controller.py +436 -0
  45. minitap/mobile_use/controllers/platform_specific_commands_controller.py +199 -0
  46. minitap/mobile_use/controllers/types.py +106 -0
  47. minitap/mobile_use/controllers/unified_controller.py +193 -0
  48. minitap/mobile_use/graph/graph.py +160 -0
  49. minitap/mobile_use/graph/state.py +115 -0
  50. minitap/mobile_use/main.py +309 -0
  51. minitap/mobile_use/sdk/__init__.py +12 -0
  52. minitap/mobile_use/sdk/agent.py +1294 -0
  53. minitap/mobile_use/sdk/builders/__init__.py +10 -0
  54. minitap/mobile_use/sdk/builders/agent_config_builder.py +307 -0
  55. minitap/mobile_use/sdk/builders/index.py +15 -0
  56. minitap/mobile_use/sdk/builders/task_request_builder.py +236 -0
  57. minitap/mobile_use/sdk/constants.py +1 -0
  58. minitap/mobile_use/sdk/examples/README.md +83 -0
  59. minitap/mobile_use/sdk/examples/__init__.py +1 -0
  60. minitap/mobile_use/sdk/examples/app_lock_messaging.py +54 -0
  61. minitap/mobile_use/sdk/examples/platform_manual_task_example.py +67 -0
  62. minitap/mobile_use/sdk/examples/platform_minimal_example.py +48 -0
  63. minitap/mobile_use/sdk/examples/simple_photo_organizer.py +76 -0
  64. minitap/mobile_use/sdk/examples/smart_notification_assistant.py +225 -0
  65. minitap/mobile_use/sdk/examples/video_transcription_example.py +117 -0
  66. minitap/mobile_use/sdk/services/cloud_mobile.py +656 -0
  67. minitap/mobile_use/sdk/services/platform.py +434 -0
  68. minitap/mobile_use/sdk/types/__init__.py +51 -0
  69. minitap/mobile_use/sdk/types/agent.py +84 -0
  70. minitap/mobile_use/sdk/types/exceptions.py +138 -0
  71. minitap/mobile_use/sdk/types/platform.py +183 -0
  72. minitap/mobile_use/sdk/types/task.py +269 -0
  73. minitap/mobile_use/sdk/utils.py +29 -0
  74. minitap/mobile_use/services/accessibility.py +100 -0
  75. minitap/mobile_use/services/llm.py +247 -0
  76. minitap/mobile_use/services/telemetry.py +421 -0
  77. minitap/mobile_use/tools/index.py +67 -0
  78. minitap/mobile_use/tools/mobile/back.py +52 -0
  79. minitap/mobile_use/tools/mobile/erase_one_char.py +56 -0
  80. minitap/mobile_use/tools/mobile/focus_and_clear_text.py +317 -0
  81. minitap/mobile_use/tools/mobile/focus_and_input_text.py +153 -0
  82. minitap/mobile_use/tools/mobile/launch_app.py +86 -0
  83. minitap/mobile_use/tools/mobile/long_press_on.py +169 -0
  84. minitap/mobile_use/tools/mobile/open_link.py +62 -0
  85. minitap/mobile_use/tools/mobile/press_key.py +83 -0
  86. minitap/mobile_use/tools/mobile/stop_app.py +62 -0
  87. minitap/mobile_use/tools/mobile/swipe.py +156 -0
  88. minitap/mobile_use/tools/mobile/tap.py +154 -0
  89. minitap/mobile_use/tools/mobile/video_recording.py +177 -0
  90. minitap/mobile_use/tools/mobile/wait_for_delay.py +81 -0
  91. minitap/mobile_use/tools/scratchpad.py +147 -0
  92. minitap/mobile_use/tools/test_utils.py +413 -0
  93. minitap/mobile_use/tools/tool_wrapper.py +16 -0
  94. minitap/mobile_use/tools/types.py +35 -0
  95. minitap/mobile_use/tools/utils.py +336 -0
  96. minitap/mobile_use/utils/app_launch_utils.py +173 -0
  97. minitap/mobile_use/utils/cli_helpers.py +37 -0
  98. minitap/mobile_use/utils/cli_selection.py +143 -0
  99. minitap/mobile_use/utils/conversations.py +31 -0
  100. minitap/mobile_use/utils/decorators.py +124 -0
  101. minitap/mobile_use/utils/errors.py +6 -0
  102. minitap/mobile_use/utils/file.py +13 -0
  103. minitap/mobile_use/utils/logger.py +183 -0
  104. minitap/mobile_use/utils/media.py +186 -0
  105. minitap/mobile_use/utils/recorder.py +52 -0
  106. minitap/mobile_use/utils/requests_utils.py +37 -0
  107. minitap/mobile_use/utils/shell_utils.py +20 -0
  108. minitap/mobile_use/utils/test_ui_hierarchy.py +178 -0
  109. minitap/mobile_use/utils/time.py +6 -0
  110. minitap/mobile_use/utils/ui_hierarchy.py +132 -0
  111. minitap/mobile_use/utils/video.py +281 -0
  112. minitap_mobile_use-3.3.0.dist-info/METADATA +329 -0
  113. minitap_mobile_use-3.3.0.dist-info/RECORD +115 -0
  114. minitap_mobile_use-3.3.0.dist-info/WHEEL +4 -0
  115. minitap_mobile_use-3.3.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,336 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ from minitap.mobile_use.context import MobileUseContext
8
+ from minitap.mobile_use.controllers.types import (
9
+ CoordinatesSelectorRequest,
10
+ PercentagesSelectorRequest,
11
+ )
12
+ from minitap.mobile_use.controllers.unified_controller import UnifiedMobileController
13
+ from minitap.mobile_use.graph.state import State
14
+ from minitap.mobile_use.tools.types import Target
15
+ from minitap.mobile_use.utils.logger import get_logger
16
+ from minitap.mobile_use.utils.ui_hierarchy import (
17
+ ElementBounds,
18
+ Point,
19
+ find_element_by_resource_id,
20
+ get_bounds_for_element,
21
+ get_element_text,
22
+ is_element_focused,
23
+ )
24
+
25
+ logger = get_logger(__name__)
26
+
27
+
28
+ def find_element_by_text(
29
+ ui_hierarchy: list[dict], text: str, index: int | None = None
30
+ ) -> dict | None:
31
+ """
32
+ Find a UI element by its text content (adapted to both flat and rich hierarchy)
33
+
34
+ This function performs a recursive, case-insensitive partial search.
35
+
36
+ Args:
37
+ ui_hierarchy: List of UI element dictionaries.
38
+ text: The text content to search for.
39
+
40
+ Returns:
41
+ The complete UI element dictionary if found, None otherwise.
42
+ """
43
+
44
+ def search_recursive(elements: list[dict]) -> dict | None:
45
+ for element in elements:
46
+ if isinstance(element, dict):
47
+ src = element.get("attributes", element)
48
+ element_text = src.get("text", "")
49
+ # Guard against non-string text values (e.g., dict)
50
+ if not isinstance(element_text, str):
51
+ element_text = ""
52
+ if text and text.lower() == element_text.lower():
53
+ idx = index or 0
54
+ if idx == 0:
55
+ return element
56
+ idx -= 1
57
+ continue
58
+ if (children := element.get("children", [])) and (
59
+ found := search_recursive(children)
60
+ ):
61
+ return found
62
+ return None
63
+
64
+ return search_recursive(ui_hierarchy)
65
+
66
+
67
+ async def tap_bottom_right_of_element(bounds: ElementBounds, ctx: MobileUseContext):
68
+ bottom_right: Point = bounds.get_relative_point(x_percent=0.99, y_percent=0.99)
69
+ await tap(
70
+ ctx=ctx,
71
+ selector_request=SelectorRequestWithCoordinates(
72
+ coordinates=CoordinatesSelectorRequest(
73
+ x=bottom_right.x,
74
+ y=bottom_right.y,
75
+ ),
76
+ ),
77
+ )
78
+
79
+
80
+ async def move_cursor_to_end_if_bounds(
81
+ ctx: MobileUseContext,
82
+ state: State,
83
+ target: Target,
84
+ elt: dict | None = None,
85
+ ) -> dict | None:
86
+ """
87
+ Best-effort move of the text cursor near the end of the input by tapping the
88
+ bottom-right area of the focused element (if bounds are available).
89
+ """
90
+ if target.resource_id:
91
+ if not elt:
92
+ elt = find_element_by_resource_id(
93
+ ui_hierarchy=state.latest_ui_hierarchy or [],
94
+ resource_id=target.resource_id,
95
+ index=target.resource_id_index,
96
+ )
97
+ if not elt:
98
+ return None
99
+
100
+ bounds = get_bounds_for_element(elt)
101
+ if not bounds:
102
+ return elt
103
+
104
+ logger.debug("Tapping near the end of the input to move the cursor")
105
+ await tap_bottom_right_of_element(bounds=bounds, ctx=ctx)
106
+ logger.debug(f"Tapped end of input {target.resource_id}")
107
+ return elt
108
+
109
+ if target.bounds:
110
+ await tap_bottom_right_of_element(target.bounds, ctx=ctx)
111
+ logger.debug("Tapped end of input by coordinates")
112
+ return elt
113
+
114
+ if target.text:
115
+ text_elt = find_element_by_text(
116
+ state.latest_ui_hierarchy or [], target.text, index=target.text_index
117
+ )
118
+ if text_elt:
119
+ bounds = get_bounds_for_element(text_elt)
120
+ if bounds:
121
+ await tap_bottom_right_of_element(bounds=bounds, ctx=ctx)
122
+ logger.debug(f"Tapped end of input that had text'{target.text}'")
123
+ return text_elt
124
+ return None
125
+
126
+ return None
127
+
128
+
129
+ async def focus_element_if_needed(
130
+ ctx: MobileUseContext, target: Target
131
+ ) -> Literal["resource_id", "coordinates", "text"] | None:
132
+ """
133
+ Ensures the element is focused, with a sanity check to prevent trusting misleading IDs.
134
+ """
135
+ controller = UnifiedMobileController(ctx)
136
+ rich_hierarchy = await controller.get_ui_elements()
137
+ elt_from_id = None
138
+ if target.resource_id:
139
+ elt_from_id = find_element_by_resource_id(
140
+ ui_hierarchy=rich_hierarchy,
141
+ resource_id=target.resource_id,
142
+ index=target.resource_id_index,
143
+ is_rich_hierarchy=False,
144
+ )
145
+
146
+ if elt_from_id and target.text:
147
+ text_from_id_elt = get_element_text(elt_from_id)
148
+ if not text_from_id_elt or target.text.lower() != text_from_id_elt.lower():
149
+ logger.warning(
150
+ f"ID '{target.resource_id}' and text '{target.text}' seem to be on different "
151
+ "elements. Ignoring the resource_id and falling back to other locators."
152
+ )
153
+ elt_from_id = None
154
+
155
+ if elt_from_id:
156
+ if not is_element_focused(elt_from_id):
157
+ tap(
158
+ ctx=ctx,
159
+ selector_request=IdSelectorRequest(id=target.resource_id), # type: ignore
160
+ index=target.resource_id_index,
161
+ )
162
+ logger.debug(f"Focused (tap) on resource_id={target.resource_id}")
163
+ rich_hierarchy = await controller.get_ui_elements()
164
+ elt_from_id = find_element_by_resource_id(
165
+ ui_hierarchy=rich_hierarchy,
166
+ resource_id=target.resource_id, # type: ignore
167
+ index=target.resource_id_index,
168
+ is_rich_hierarchy=False,
169
+ )
170
+ if elt_from_id and is_element_focused(elt_from_id):
171
+ logger.debug(f"Text input is focused: {target.resource_id}")
172
+ return "resource_id"
173
+ logger.warning(f"Failed to focus using resource_id='{target.resource_id}'. Fallback...")
174
+
175
+ if target.bounds:
176
+ relative_point = target.bounds.get_center()
177
+ await tap(
178
+ ctx=ctx,
179
+ selector_request=SelectorRequestWithCoordinates(
180
+ coordinates=CoordinatesSelectorRequest(x=relative_point.x, y=relative_point.y)
181
+ ),
182
+ )
183
+ logger.debug(f"Tapped on coordinates ({relative_point.x}, {relative_point.y}) to focus.")
184
+ return "coordinates"
185
+
186
+ if target.text:
187
+ text_elt = find_element_by_text(rich_hierarchy, target.text, index=target.text_index)
188
+ if text_elt:
189
+ bounds = get_bounds_for_element(text_elt)
190
+ if bounds:
191
+ relative_point = bounds.get_center()
192
+ await tap(
193
+ ctx=ctx,
194
+ selector_request=SelectorRequestWithCoordinates(
195
+ coordinates=CoordinatesSelectorRequest(
196
+ x=relative_point.x, y=relative_point.y
197
+ )
198
+ ),
199
+ )
200
+ logger.debug(f"Tapped on text element '{target.text}' to focus.")
201
+ return "text"
202
+
203
+ logger.error(
204
+ "Failed to focus element. No valid locator (resource_id, coordinates, or text) succeeded."
205
+ )
206
+ return None
207
+
208
+
209
+ def validate_coordinates_bounds(
210
+ target: Target, screen_width: int, screen_height: int
211
+ ) -> str | None:
212
+ """
213
+ Validate that coordinates are within screen bounds.
214
+ Returns error message if invalid, None if valid.
215
+ """
216
+ if not target.bounds:
217
+ return None
218
+
219
+ center = target.bounds.get_center()
220
+ errors = []
221
+
222
+ if center.x < 0 or center.x >= screen_width:
223
+ errors.append(f"x={center.x} is outside screen width (0-{screen_width})")
224
+ if center.y < 0 or center.y >= screen_height:
225
+ errors.append(f"y={center.y} is outside screen height (0-{screen_height})")
226
+
227
+ return "; ".join(errors) if errors else None
228
+
229
+
230
+ def has_valid_selectors(target: Target) -> bool:
231
+ """Check if target has at least one valid selector."""
232
+ has_coordinates = target.bounds is not None
233
+ has_resource_id = target.resource_id is not None and target.resource_id != ""
234
+ has_text = target.text is not None and target.text != ""
235
+ return has_coordinates or has_resource_id or has_text
236
+
237
+
238
+ class IdSelectorRequest(BaseModel):
239
+ model_config = ConfigDict(extra="forbid")
240
+ id: str
241
+
242
+ def to_dict(self) -> dict[str, str | int]:
243
+ return {"id": self.id}
244
+
245
+
246
+ class TextSelectorRequest(BaseModel):
247
+ model_config = ConfigDict(extra="forbid")
248
+ text: str
249
+
250
+ def to_dict(self) -> dict[str, str | int]:
251
+ return {"text": self.text}
252
+
253
+
254
+ class SelectorRequestWithCoordinates(BaseModel):
255
+ model_config = ConfigDict(extra="forbid")
256
+ coordinates: CoordinatesSelectorRequest
257
+
258
+ def to_dict(self) -> dict[str, str | int]:
259
+ return {"point": self.coordinates.to_str()}
260
+
261
+
262
+ class SelectorRequestWithPercentages(BaseModel):
263
+ model_config = ConfigDict(extra="forbid")
264
+ percentages: PercentagesSelectorRequest
265
+
266
+ def to_dict(self) -> dict[str, str | int]:
267
+ return {"point": self.percentages.to_str()}
268
+
269
+
270
+ class IdWithTextSelectorRequest(BaseModel):
271
+ model_config = ConfigDict(extra="forbid")
272
+ id: str
273
+ text: str
274
+
275
+ def to_dict(self) -> dict[str, str | int]:
276
+ return {"id": self.id, "text": self.text}
277
+
278
+
279
+ SelectorRequest = (
280
+ IdSelectorRequest
281
+ | SelectorRequestWithCoordinates
282
+ | SelectorRequestWithPercentages
283
+ | TextSelectorRequest
284
+ | IdWithTextSelectorRequest
285
+ )
286
+
287
+
288
+ def _extract_resource_id_and_text_from_selector(
289
+ selector: SelectorRequest,
290
+ ) -> tuple[str | None, str | None]:
291
+ """Extract resource_id and text from a selector."""
292
+ resource_id = None
293
+ text = None
294
+
295
+ if isinstance(selector, IdSelectorRequest):
296
+ resource_id = selector.id
297
+ elif isinstance(selector, TextSelectorRequest):
298
+ text = selector.text
299
+ elif isinstance(selector, IdWithTextSelectorRequest):
300
+ resource_id = selector.id
301
+ text = selector.text
302
+
303
+ return resource_id, text
304
+
305
+
306
+ async def tap(
307
+ ctx: MobileUseContext,
308
+ selector_request: SelectorRequest,
309
+ index: int | None = None,
310
+ ):
311
+ """
312
+ Tap on a selector.
313
+ Index is optional and is used when you have multiple views matching the same selector.
314
+ ui_hierarchy is optional and used for ADB taps to find elements.
315
+ """
316
+ controller = UnifiedMobileController(ctx)
317
+ if isinstance(selector_request, SelectorRequestWithCoordinates):
318
+ result = await controller.tap_at(
319
+ x=selector_request.coordinates.x, y=selector_request.coordinates.y
320
+ )
321
+ return result.error if result.error else None
322
+ if isinstance(selector_request, SelectorRequestWithPercentages):
323
+ coords = selector_request.percentages.to_coords(
324
+ width=ctx.device.device_width,
325
+ height=ctx.device.device_height,
326
+ )
327
+ return await controller.tap_at(coords.x, coords.y)
328
+
329
+ # For other selectors, we need the UI hierarchy
330
+ resource_id, text = _extract_resource_id_and_text_from_selector(selector_request)
331
+
332
+ return await controller.tap_element(
333
+ resource_id=resource_id,
334
+ text=text,
335
+ index=index if index is not None else 0,
336
+ )
@@ -0,0 +1,173 @@
1
+ """
2
+ Utilities for handling app locking and initial app launch logic.
3
+ """
4
+
5
+ import asyncio
6
+
7
+ from minitap.mobile_use.context import AppLaunchResult, MobileUseContext
8
+ from minitap.mobile_use.controllers.platform_specific_commands_controller import (
9
+ get_current_foreground_package,
10
+ )
11
+ from minitap.mobile_use.controllers.unified_controller import UnifiedMobileController
12
+ from minitap.mobile_use.utils.logger import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ async def _poll_for_app_ready(
18
+ ctx: MobileUseContext,
19
+ app_package: str,
20
+ max_poll_seconds: int = 15,
21
+ poll_interval: float = 1.0,
22
+ ) -> tuple[bool, str | None]:
23
+ """
24
+ Poll for app to be ready after launch.
25
+
26
+ Treats mCurrentFocus=null as a loading state and keeps polling.
27
+ Only fails if we get a different (non-null) package or timeout.
28
+
29
+ Args:
30
+ ctx: Mobile use context
31
+ app_package: Expected package name
32
+ max_poll_seconds: Maximum time to poll (default: 15s)
33
+ poll_interval: Time between polls (default: 1s)
34
+
35
+ Returns:
36
+ Tuple of (success: bool, error_message: str | None)
37
+ """
38
+ polls = int(max_poll_seconds / poll_interval)
39
+
40
+ for i in range(polls):
41
+ current_package = get_current_foreground_package(ctx)
42
+
43
+ if current_package == app_package:
44
+ logger.success(f"App {app_package} is ready (took ~{i * poll_interval:.1f}s)")
45
+ return True, None
46
+
47
+ if current_package is None:
48
+ logger.debug(f"Poll {i + 1}/{polls}: App loading (mCurrentFocus=null)...")
49
+ else:
50
+ error_msg = (
51
+ f"Wrong app in foreground: expected '{app_package}', got '{current_package}'"
52
+ )
53
+ logger.warning(error_msg)
54
+ return False, error_msg
55
+
56
+ if i < polls - 1:
57
+ await asyncio.sleep(poll_interval)
58
+
59
+ current_package = get_current_foreground_package(ctx)
60
+ error_msg = (
61
+ f"Timeout waiting for {app_package} to load after {max_poll_seconds}s. "
62
+ f"Current foreground: {current_package}"
63
+ )
64
+ logger.error(error_msg)
65
+ return False, error_msg
66
+
67
+
68
+ async def launch_app_with_retries(
69
+ ctx: MobileUseContext,
70
+ app_package: str,
71
+ max_retries: int = 3,
72
+ max_poll_seconds: int = 15,
73
+ ) -> tuple[bool, str | None]:
74
+ """
75
+ Launch an app with retry logic and smart polling.
76
+
77
+ Args:
78
+ ctx: Mobile use context
79
+ app_package: Package name (Android) or bundle ID (iOS) to launch
80
+ max_retries: Maximum number of launch attempts (default: 3)
81
+ max_poll_seconds: Maximum time to wait for app to load per attempt (default: 15s)
82
+
83
+ Returns:
84
+ Tuple of (success: bool, error_message: str | None)
85
+ """
86
+ for attempt in range(1, max_retries + 1):
87
+ logger.info(f"Launch attempt {attempt}/{max_retries} for app {app_package}")
88
+
89
+ controller = UnifiedMobileController(ctx)
90
+ launch_success = await controller.launch_app(app_package)
91
+ if not launch_success:
92
+ error_msg = f"Failed to execute launch command for {app_package}"
93
+ logger.error(error_msg)
94
+ if attempt == max_retries:
95
+ return False, error_msg
96
+ await asyncio.sleep(2)
97
+ continue
98
+
99
+ await asyncio.sleep(1)
100
+
101
+ success, error_msg = await _poll_for_app_ready(ctx, app_package, max_poll_seconds)
102
+
103
+ if success:
104
+ return True, None
105
+
106
+ if attempt < max_retries:
107
+ logger.warning(f"Attempt {attempt} failed: {error_msg}. Retrying...")
108
+ await asyncio.sleep(1)
109
+
110
+ error_msg = f"Failed to launch {app_package} after {max_retries} attempts"
111
+ logger.error(error_msg)
112
+ return False, error_msg
113
+
114
+
115
+ async def _handle_initial_app_launch(
116
+ ctx: MobileUseContext,
117
+ locked_app_package: str,
118
+ ) -> AppLaunchResult:
119
+ """
120
+ Handle initial app launch verification and launching if needed.
121
+
122
+ If locked_app_package is set:
123
+ 1. Check if the app is already in the foreground
124
+ 2. If not, attempt to launch it (with retries)
125
+ 3. Return status with success/error information
126
+
127
+ Args:
128
+ ctx: Mobile use context
129
+ locked_app_package: Package name (Android) or bundle ID (iOS) to lock to, or None
130
+
131
+ Returns:
132
+ AppLaunchResult with launch status and error information
133
+ """
134
+ if not locked_app_package:
135
+ error_msg = f"Invalid locked_app_package: '{locked_app_package}'"
136
+ logger.error(error_msg)
137
+ return AppLaunchResult(
138
+ locked_app_package=locked_app_package,
139
+ locked_app_initial_launch_success=False,
140
+ locked_app_initial_launch_error=error_msg,
141
+ )
142
+
143
+ logger.info(f"Starting initial app launch for package: {locked_app_package}")
144
+
145
+ try:
146
+ current_package = get_current_foreground_package(ctx)
147
+ logger.info(f"Current foreground app: {current_package}")
148
+
149
+ if current_package == locked_app_package:
150
+ logger.info(f"App {locked_app_package} is already in foreground")
151
+ return AppLaunchResult(
152
+ locked_app_package=locked_app_package,
153
+ locked_app_initial_launch_success=True,
154
+ locked_app_initial_launch_error=None,
155
+ )
156
+
157
+ logger.info(f"App {locked_app_package} not in foreground, attempting to launch")
158
+ success, error_msg = await launch_app_with_retries(ctx, locked_app_package)
159
+
160
+ return AppLaunchResult(
161
+ locked_app_package=locked_app_package,
162
+ locked_app_initial_launch_success=success,
163
+ locked_app_initial_launch_error=error_msg,
164
+ )
165
+
166
+ except Exception as e:
167
+ error_msg = f"Exception during initial app launch: {str(e)}"
168
+ logger.error(error_msg)
169
+ return AppLaunchResult(
170
+ locked_app_package=locked_app_package,
171
+ locked_app_initial_launch_success=False,
172
+ locked_app_initial_launch_error=error_msg,
173
+ )
@@ -0,0 +1,37 @@
1
+ import sys
2
+
3
+ from adbutils import AdbClient
4
+ from rich.console import Console
5
+
6
+ from minitap.mobile_use.clients.ios_client import format_device_info, get_all_ios_devices_detailed
7
+
8
+
9
+ def display_device_status(console: Console, adb_client: AdbClient | None = None):
10
+ """Checks for connected devices and displays the status."""
11
+ console.print("\n[bold]📱 Device Status[/bold]")
12
+ devices = None
13
+ if adb_client is not None:
14
+ devices = adb_client.device_list()
15
+ if devices:
16
+ console.print("✅ [bold green]Android device(s) connected:[/bold green]")
17
+ for device in devices:
18
+ console.print(f" - {device.serial}")
19
+ else:
20
+ console.print("❌ [bold red]No Android device found.[/bold red]")
21
+ command = "emulator -avd <avd_name>"
22
+ if sys.platform not in ["win32", "darwin"]:
23
+ command = f"./{command}"
24
+ console.print(
25
+ f"You can start an emulator using a command like: [bold]'{command}'[/bold]"
26
+ )
27
+
28
+ ios_devices = get_all_ios_devices_detailed()
29
+ if ios_devices:
30
+ console.print("✅ [bold green]iOS device(s) connected:[/bold green]")
31
+ for device in ios_devices:
32
+ console.print(f" - [green]{format_device_info(device)}[/green]")
33
+ else:
34
+ console.print("❌ [bold red]No iOS device found.[/bold red]")
35
+ console.print(
36
+ "[iOS] Please make sure your emulator is running or a device is connected via USB."
37
+ )
@@ -0,0 +1,143 @@
1
+ import sys
2
+
3
+ import inquirer
4
+ from rich.console import Console
5
+ from rich.prompt import Prompt
6
+
7
+
8
+ def select_provider_and_model(
9
+ console: Console,
10
+ available_providers: list[str],
11
+ available_models: dict,
12
+ default_provider: str,
13
+ default_model: str,
14
+ provider: str | None = None,
15
+ model: str | None = None,
16
+ ) -> tuple[str, str]:
17
+ """
18
+ Interactive selection of LLM provider and model with arrow-key dropdowns when available.
19
+
20
+ Args:
21
+ console: Rich console for output
22
+ available_providers: List of available provider names
23
+ available_models: Dict mapping providers to their available models
24
+ default_provider: Default provider to use
25
+ default_model: Default model to use
26
+ provider: Pre-selected provider (optional)
27
+ model: Pre-selected model (optional)
28
+
29
+ Returns:
30
+ Tuple of (selected_provider, selected_model)
31
+ """
32
+ final_provider = provider
33
+ final_model = model
34
+
35
+ # Interactive provider selection
36
+ if not final_provider:
37
+ console.print("\n🤖 [bold cyan]LLM Provider Selection[/bold cyan]")
38
+ final_provider = _select_from_list(
39
+ console=console,
40
+ item_type="provider",
41
+ choices=available_providers,
42
+ default=default_provider,
43
+ message="Select LLM provider",
44
+ )
45
+
46
+ # Interactive model selection
47
+ if not final_model:
48
+ console.print(f"\n🎯 [bold green]Model Selection for {final_provider}[/bold green]")
49
+ available_model_list = (
50
+ available_models[final_provider]
51
+ if final_provider
52
+ else available_models[default_provider]
53
+ )
54
+
55
+ default_model_for_provider = (
56
+ default_model if default_model in available_model_list else available_model_list[0]
57
+ )
58
+
59
+ final_model = _select_from_list(
60
+ console=console,
61
+ item_type="model",
62
+ choices=available_model_list,
63
+ default=default_model_for_provider,
64
+ message=f"Select model for {final_provider}",
65
+ )
66
+
67
+ return final_provider, final_model
68
+
69
+
70
+ def _select_from_list(
71
+ console: Console,
72
+ item_type: str,
73
+ choices: list[str],
74
+ default: str,
75
+ message: str,
76
+ ) -> str:
77
+ """
78
+ Select an item from a list using arrow keys when available, fallback to numbered selection.
79
+
80
+ Args:
81
+ console: Rich console for output
82
+ item_type: Type of item being selected (for error messages)
83
+ choices: List of choices to select from
84
+ default: Default choice
85
+ message: Message to display in dropdown
86
+
87
+ Returns:
88
+ Selected choice
89
+ """
90
+ # Try arrow-key dropdown if TTY is available, fallback to numbered selection
91
+ if sys.stdin.isatty():
92
+ try:
93
+ questions = [
94
+ inquirer.List(
95
+ "selection",
96
+ message=f"{message} (use arrow keys)",
97
+ choices=choices,
98
+ default=default,
99
+ ),
100
+ ]
101
+ answers = inquirer.prompt(questions)
102
+ return answers["selection"] if answers else default
103
+ except Exception:
104
+ # Fallback to numbered selection
105
+ return _numbered_selection(console, item_type, choices, default)
106
+ else:
107
+ return _numbered_selection(console, item_type, choices, default)
108
+
109
+
110
+ def _numbered_selection(console: Console, item_type: str, choices: list[str], default: str) -> str:
111
+ """Fallback numbered selection when arrow keys aren't available."""
112
+ choices_text = "\n".join([f" {i + 1}. {choice}" for i, choice in enumerate(choices)])
113
+ console.print(f"Available {item_type}s:\n{choices_text}")
114
+
115
+ default_idx = choices.index(default) + 1 if default in choices else 1
116
+
117
+ while True:
118
+ choice = Prompt.ask(
119
+ f"Select {item_type} (1-{len(choices)}) or press Enter for default",
120
+ default=str(default_idx),
121
+ )
122
+ try:
123
+ choice_idx = int(choice) - 1
124
+ if 0 <= choice_idx < len(choices):
125
+ return choices[choice_idx]
126
+ else:
127
+ console.print("[red]Invalid choice. Please try again.[/red]")
128
+ except ValueError:
129
+ console.print("[red]Please enter a number.[/red]")
130
+
131
+
132
+ def display_llm_config(console: Console, provider: str, model: str) -> None:
133
+ """Display the selected LLM configuration with colors."""
134
+ from rich.text import Text
135
+
136
+ config_text = Text()
137
+ config_text.append("🤖 LLM Configuration: ", style="bold white")
138
+ config_text.append("Provider: ", style="white")
139
+ config_text.append(f"{provider}", style="bold cyan")
140
+ config_text.append(" | Model: ", style="white")
141
+ config_text.append(f"{model}", style="bold green")
142
+
143
+ console.print(config_text)