@loadmill/droid-cua 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 (34) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +227 -0
  3. package/bin/droid-cua +6 -0
  4. package/build/index.js +58 -0
  5. package/build/src/cli/app.js +115 -0
  6. package/build/src/cli/command-parser.js +57 -0
  7. package/build/src/cli/components/AgentStatus.js +21 -0
  8. package/build/src/cli/components/CommandSuggestions.js +33 -0
  9. package/build/src/cli/components/InputPanel.js +21 -0
  10. package/build/src/cli/components/OutputPanel.js +58 -0
  11. package/build/src/cli/components/StatusBar.js +22 -0
  12. package/build/src/cli/ink-shell.js +56 -0
  13. package/build/src/commands/create.js +42 -0
  14. package/build/src/commands/edit.js +61 -0
  15. package/build/src/commands/exit.js +20 -0
  16. package/build/src/commands/help.js +34 -0
  17. package/build/src/commands/index.js +49 -0
  18. package/build/src/commands/list.js +55 -0
  19. package/build/src/commands/run.js +112 -0
  20. package/build/src/commands/stop.js +32 -0
  21. package/build/src/commands/view.js +43 -0
  22. package/build/src/core/execution-engine.js +114 -0
  23. package/build/src/core/prompts.js +158 -0
  24. package/build/src/core/session.js +57 -0
  25. package/build/src/device/actions.js +81 -0
  26. package/build/src/device/assertions.js +75 -0
  27. package/build/src/device/connection.js +123 -0
  28. package/build/src/device/openai.js +124 -0
  29. package/build/src/modes/design-mode-ink.js +396 -0
  30. package/build/src/modes/design-mode.js +366 -0
  31. package/build/src/modes/execution-mode.js +165 -0
  32. package/build/src/test-store/test-manager.js +92 -0
  33. package/build/src/utils/logger.js +86 -0
  34. package/package.json +68 -0
package/LICENSE ADDED
@@ -0,0 +1 @@
1
+ © 2025 Loadmill. All rights reserved.
package/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # droid-cua
2
+
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/@loadmill/droid-cua"><img src="https://img.shields.io/npm/v/@loadmill/droid-cua?color=green" alt="npm version"></a>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="#what-is-droid-cua">What is droid-cua?</a> •
9
+ <a href="#quick-start">Quick Start</a> •
10
+ <a href="#features">Features</a> •
11
+ <a href="#usage">Usage</a> •
12
+ <a href="#assertions">Assertions</a> •
13
+ <a href="#command-line-options">Command Line Options</a> •
14
+ <a href="#how-it-works">How It Works</a> •
15
+ <a href="#license">License</a>
16
+ </p>
17
+
18
+ ---
19
+
20
+ **AI-powered Android testing using OpenAI's computer-use model**
21
+
22
+ Create and run automated Android tests using natural language. The AI explores your app and generates executable test scripts.
23
+
24
+ https://github.com/user-attachments/assets/36b2ea7e-820a-432d-9294-8aa61dceb4b0
25
+
26
+ ---
27
+
28
+ <h2 id="what-is-droid-cua">💡 What is droid-cua?</h2>
29
+
30
+ `droid-cua` gives you three core components for Android testing:
31
+
32
+ * **Interactive Shell** – Design and run tests with real-time feedback and visual status indicators
33
+ * **Test Scripts** – Simple text files with natural language instructions and assertions
34
+ * **AI Agent** – Autonomous exploration powered by OpenAI's computer-use model
35
+
36
+ Together, these let you create and execute Android tests without writing traditional test code.
37
+
38
+ ---
39
+
40
+ <h2 id="quick-start">🚀 Quick Start</h2>
41
+
42
+ **1. Install**
43
+
44
+ Globally (recommended):
45
+ ```sh
46
+ npm install -g @loadmill/droid-cua
47
+ ```
48
+
49
+ Or from source:
50
+ ```sh
51
+ git clone https://github.com/loadmill/droid-cua
52
+ cd droid-cua
53
+ npm install
54
+ npm run build
55
+ ```
56
+
57
+ **2. Set your OpenAI API key**
58
+
59
+ Using environment variable:
60
+ ```sh
61
+ export OPENAI_API_KEY=your-api-key
62
+ ```
63
+
64
+ Or create a `.env` file:
65
+ ```sh
66
+ echo "OPENAI_API_KEY=your-api-key" > .env
67
+ ```
68
+
69
+ **3. Ensure ADB is available**
70
+
71
+ ```sh
72
+ adb version
73
+ ```
74
+
75
+ **4. Run**
76
+
77
+ ```sh
78
+ droid-cua
79
+ ```
80
+
81
+ The emulator will auto-launch if not already running.
82
+
83
+ ---
84
+
85
+ <h2 id="features">✨ Features</h2>
86
+
87
+ - **Design Mode** - Describe what to test, AI explores and creates test scripts
88
+ - **Execution Mode** - Run tests with real-time feedback and assertion handling
89
+ - **Headless Mode** - Run tests in CI/CD pipelines
90
+ - **Test Management** - Create, edit, view, and run test scripts
91
+ - **Smart Actions** - Automatic wait detection and coordinate mapping
92
+
93
+ ---
94
+
95
+ <h2 id="usage">📚 Usage</h2>
96
+
97
+ ### Interactive Commands
98
+
99
+ | Command | Description |
100
+ |---------|-------------|
101
+ | `/create <name>` | Create a new test |
102
+ | `/run <name>` | Execute a test |
103
+ | `/list` | List all tests |
104
+ | `/view <name>` | View test contents |
105
+ | `/edit <name>` | Edit a test |
106
+ | `/help` | Show help |
107
+ | `/exit` | Exit shell |
108
+
109
+ ### Creating Tests
110
+
111
+ ```sh
112
+ droid-cua
113
+ > /create login-test
114
+ > Test the login flow with valid credentials
115
+ ```
116
+
117
+ The AI will explore your app and generate a test script. Review and save it.
118
+
119
+ ### Running Tests
120
+
121
+ Interactive:
122
+ ```sh
123
+ droid-cua
124
+ > /run login-test
125
+ ```
126
+
127
+ Headless (CI/CD):
128
+ ```sh
129
+ droid-cua --instructions tests/login-test.dcua
130
+ ```
131
+
132
+ ### Test Script Format
133
+
134
+ One instruction per line:
135
+
136
+ ```
137
+ Open the Calculator app
138
+ assert: Calculator app is visible
139
+ Type "2"
140
+ Click the plus button
141
+ Type "3"
142
+ Click the equals button
143
+ assert: result shows 5
144
+ exit
145
+ ```
146
+
147
+ <h3 id="assertions">Assertions</h3>
148
+
149
+ Assertions validate the app state during test execution. Add them anywhere in your test script.
150
+
151
+ **Syntax** (all valid):
152
+ ```
153
+ assert: the login button is visible
154
+ Assert: error message appears
155
+ ASSERT the result shows 5
156
+ ```
157
+
158
+ **Interactive Mode** - When an assertion fails:
159
+ - `retry` - Retry the same assertion
160
+ - `skip` - Continue to next instruction
161
+ - `stop` - Stop test execution
162
+
163
+ **Headless Mode** - Assertions fail immediately and exit with code 1.
164
+
165
+ **Examples**:
166
+ ```
167
+ assert: Calculator app is open
168
+ assert: the result shows 8
169
+ assert: error message is displayed in red
170
+ assert: login button is enabled
171
+ ```
172
+
173
+ ---
174
+
175
+ <h2 id="command-line-options">💻 Command Line Options</h2>
176
+
177
+ | Option | Description |
178
+ |--------|-------------|
179
+ | `--avd=NAME` | Specify emulator |
180
+ | `--instructions=FILE` | Run test headless |
181
+ | `--record` | Save screenshots |
182
+ | `--debug` | Enable debug logs |
183
+
184
+ ---
185
+
186
+ ## Requirements
187
+
188
+ - Node.js 18.17.0+
189
+ - Android Debug Bridge (ADB)
190
+ - Android Emulator (AVD)
191
+ - OpenAI API Key (Tier 3 for computer-use-preview model)
192
+
193
+ ---
194
+
195
+ <h2 id="how-it-works">🔧 How It Works</h2>
196
+
197
+ 1. Connects to a running Android emulator
198
+ 2. Captures full-screen device screenshots
199
+ 3. Scales down the screenshots for OpenAI model compatibility
200
+ 4. Sends screenshots and user instructions to OpenAI's computer-use-preview model
201
+ 5. Receives structured actions (click, scroll, type, keypress, wait, drag)
202
+ 6. Rescales model outputs back to real device coordinates
203
+ 7. Executes the actions on the device via ADB
204
+ 8. Validates assertions and handles failures
205
+ 9. Repeats until task completion
206
+
207
+ ---
208
+
209
+ ## 🎞️ Convert Screenshots to Video
210
+
211
+ If you run with `--record`, screenshots are saved to:
212
+ ```
213
+ droid-cua-recording-<timestamp>/
214
+ ```
215
+
216
+ Convert to video with ffmpeg:
217
+ ```sh
218
+ ffmpeg -framerate 1 -pattern_type glob -i 'droid-cua-recording-*/frame_*.png' \
219
+ -vf "pad=ceil(iw/2)*2:ceil(ih/2)*2" \
220
+ -c:v libx264 -pix_fmt yuv420p session.mp4
221
+ ```
222
+
223
+ ---
224
+
225
+ <h2 id="license">📄 License</h2>
226
+
227
+ © 2025 Loadmill. All rights reserved.
package/bin/droid-cua ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import('../build/index.js').catch(err => {
4
+ console.error(err);
5
+ process.exit(1);
6
+ });
package/build/index.js ADDED
@@ -0,0 +1,58 @@
1
+ import minimist from "minimist";
2
+ import path from "path";
3
+ import { mkdir, readFile } from "fs/promises";
4
+ import { connectToDevice, getDeviceInfo } from "./src/device/connection.js";
5
+ import { Session } from "./src/core/session.js";
6
+ import { ExecutionEngine } from "./src/core/execution-engine.js";
7
+ import { buildBaseSystemPrompt } from "./src/core/prompts.js";
8
+ import { startInkShell } from "./src/cli/ink-shell.js";
9
+ import { ExecutionMode } from "./src/modes/execution-mode.js";
10
+ import { logger } from "./src/utils/logger.js";
11
+ const args = minimist(process.argv.slice(2));
12
+ const avdName = args["avd"];
13
+ const recordScreenshots = args["record"] || false;
14
+ const instructionsFile = args.instructions || args.i || null;
15
+ const debugMode = args["debug"] || false;
16
+ // Initialize debug logging
17
+ await logger.init(debugMode);
18
+ const screenshotDir = path.join("droid-cua-recording-" + Date.now());
19
+ if (recordScreenshots)
20
+ await mkdir(screenshotDir, { recursive: true });
21
+ async function main() {
22
+ // Connect to device
23
+ const deviceId = await connectToDevice(avdName);
24
+ const deviceInfo = await getDeviceInfo(deviceId);
25
+ console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
26
+ console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
27
+ // Create session to manage state
28
+ const session = new Session(deviceId, deviceInfo);
29
+ const initialSystemText = buildBaseSystemPrompt(deviceInfo);
30
+ session.setSystemPrompt(initialSystemText);
31
+ // Create execution engine
32
+ const engine = new ExecutionEngine(session, {
33
+ recordScreenshots,
34
+ screenshotDir,
35
+ });
36
+ // If --instructions provided, run in headless mode
37
+ if (instructionsFile) {
38
+ console.log(`\nRunning test from: ${instructionsFile}\n`);
39
+ // Read and parse the instructions file
40
+ const content = await readFile(instructionsFile, "utf-8");
41
+ const instructions = content
42
+ .split("\n")
43
+ .map(line => line.trim())
44
+ .filter(line => line.length > 0);
45
+ const executionMode = new ExecutionMode(session, engine, instructions, true); // true = headless mode
46
+ const result = await executionMode.execute();
47
+ if (result.success) {
48
+ process.exit(0);
49
+ }
50
+ else {
51
+ console.error(`\nTest failed: ${result.error}`);
52
+ process.exit(1);
53
+ }
54
+ }
55
+ // Otherwise, start interactive Ink shell
56
+ await startInkShell(session, engine);
57
+ }
58
+ main();
@@ -0,0 +1,115 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box } from 'ink';
3
+ import { StatusBar } from './components/StatusBar.js';
4
+ import { OutputPanel } from './components/OutputPanel.js';
5
+ import { InputPanel } from './components/InputPanel.js';
6
+ import { AgentStatus } from './components/AgentStatus.js';
7
+ import { CommandSuggestions } from './components/CommandSuggestions.js';
8
+ import { COMMANDS } from './command-parser.js';
9
+ /**
10
+ * Main Ink App component - conversational split-pane UI
11
+ */
12
+ export function App({ session, initialMode = 'command', onInput, onExit }) {
13
+ const [mode, setMode] = useState(initialMode);
14
+ const [testName, setTestName] = useState(null);
15
+ const [output, setOutput] = useState([]);
16
+ const [agentWorking, setAgentWorking] = useState(false);
17
+ const [agentMessage, setAgentMessage] = useState('');
18
+ const [inputDisabled, setInputDisabled] = useState(false);
19
+ const [inputPlaceholder, setInputPlaceholder] = useState('Type a command or message...');
20
+ const [activeDesignMode, setActiveDesignMode] = useState(null);
21
+ const [activeExecutionMode, setActiveExecutionMode] = useState(null);
22
+ const [isExecutionMode, setIsExecutionMode] = useState(false);
23
+ const [inputValue, setInputValue] = useState('');
24
+ const [inputResolver, setInputResolver] = useState(null); // For waiting on user input
25
+ // Context object passed to modes and commands
26
+ const context = {
27
+ // Output methods
28
+ addOutput: (item) => {
29
+ setOutput((prev) => [...prev, item]);
30
+ },
31
+ clearOutput: () => {
32
+ setOutput([]);
33
+ },
34
+ // Agent status
35
+ setAgentWorking: (working, message = '') => {
36
+ setAgentWorking(working);
37
+ setAgentMessage(message || (working ? 'Agent is working...' : ''));
38
+ },
39
+ // Mode management
40
+ setMode: (newMode) => setMode(newMode),
41
+ setTestName: (name) => setTestName(name),
42
+ getMode: () => mode,
43
+ // Input control
44
+ setInputDisabled: (disabled) => setInputDisabled(disabled),
45
+ setInputPlaceholder: (placeholder) => setInputPlaceholder(placeholder),
46
+ // Execution mode flag (to restrict inputs during test execution)
47
+ isExecutionMode: isExecutionMode,
48
+ setExecutionMode: (executing) => setIsExecutionMode(executing),
49
+ // Session access
50
+ session,
51
+ // Design mode reference (for routing inputs)
52
+ activeDesignMode: activeDesignMode,
53
+ setActiveDesignMode: (mode) => setActiveDesignMode(mode),
54
+ // Execution mode reference (for /stop command)
55
+ activeExecutionMode: activeExecutionMode,
56
+ setActiveExecutionMode: (mode) => setActiveExecutionMode(mode),
57
+ // Exit
58
+ exit: () => {
59
+ if (onExit) {
60
+ onExit();
61
+ }
62
+ },
63
+ // Wait for user input (for prompts during execution)
64
+ waitForUserInput: () => {
65
+ return new Promise((resolve) => {
66
+ setInputResolver(() => resolve);
67
+ });
68
+ },
69
+ };
70
+ // Make context available globally for modes
71
+ useEffect(() => {
72
+ if (typeof global !== 'undefined') {
73
+ global.inkContext = context;
74
+ }
75
+ }, [context]);
76
+ // Show welcome banner on mount
77
+ useEffect(() => {
78
+ const banner = `
79
+ _ _ _ _ _ _ _ _
80
+ | | ___ __ _ __| |_ __ ___ (_) | | __| |_ __ ___ (_) __| | ___ _ _ __ _
81
+ | | / _ \\ / _\` |/ _\` | '_ \` _ \\| | | | / _\` | '__/ _ \\| |/ _\` |_____ / __| | | |/ _\` |
82
+ | |__| (_) | (_| | (_| | | | | | | | | | | (_| | | | (_) | | (_| |_____| (__| |_| | (_| |
83
+ |_____\\___/ \\__,_|\\__,_|_| |_| |_|_|_|_| \\__,_|_| \\___/|_|\\__,_| \\___|\\__,_|\\__,_|
84
+ `;
85
+ context.addOutput({ type: 'system', text: banner });
86
+ context.addOutput({ type: 'info', text: 'Type /help for available commands.' });
87
+ context.addOutput({ type: 'info', text: '' });
88
+ }, []); // Empty deps = run once on mount
89
+ const handleInput = async (input) => {
90
+ // Check if we're waiting for user input (e.g., assertion failure prompt)
91
+ if (inputResolver) {
92
+ // Resolve the waiting promise with the user's input
93
+ inputResolver(input);
94
+ setInputResolver(null);
95
+ setInputValue('');
96
+ return;
97
+ }
98
+ // Add user input to output
99
+ context.addOutput({ type: 'user', text: input });
100
+ // Clear input value
101
+ setInputValue('');
102
+ // Call input handler
103
+ if (onInput) {
104
+ await onInput(input, context);
105
+ }
106
+ };
107
+ return (React.createElement(Box, { flexDirection: "column", height: "100%" },
108
+ React.createElement(StatusBar, { mode: mode, deviceInfo: session?.deviceInfo, testName: testName }),
109
+ React.createElement(Box, { flexGrow: 1, flexDirection: "column", paddingBottom: 1 },
110
+ React.createElement(OutputPanel, { items: output }),
111
+ React.createElement(AgentStatus, { isWorking: agentWorking, message: agentMessage })),
112
+ React.createElement(Box, { flexDirection: "column" },
113
+ React.createElement(InputPanel, { value: inputValue, onChange: setInputValue, onSubmit: handleInput, placeholder: inputPlaceholder, disabled: inputDisabled }),
114
+ React.createElement(CommandSuggestions, { input: inputValue, commands: COMMANDS }))));
115
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Parse user input to determine if it's a slash command or regular instruction
3
+ */
4
+ /**
5
+ * Parse user input
6
+ * @param {string} input - Raw user input
7
+ * @returns {{type: 'command'|'instruction', command?: string, args?: string, text?: string}}
8
+ */
9
+ export function parseInput(input) {
10
+ const trimmed = input.trim();
11
+ // Check if it starts with /
12
+ if (trimmed.startsWith('/')) {
13
+ // Extract command and args
14
+ const match = trimmed.match(/^\/(\w+)\s*(.*)/);
15
+ if (match) {
16
+ const [, command, args] = match;
17
+ return {
18
+ type: 'command',
19
+ command: command.toLowerCase(),
20
+ args: args.trim(),
21
+ };
22
+ }
23
+ // Invalid slash command format
24
+ return {
25
+ type: 'command',
26
+ command: trimmed.slice(1).toLowerCase(),
27
+ args: '',
28
+ };
29
+ }
30
+ // Not a command, treat as regular instruction
31
+ return {
32
+ type: 'instruction',
33
+ text: trimmed,
34
+ };
35
+ }
36
+ /**
37
+ * Available slash commands
38
+ */
39
+ export const COMMANDS = {
40
+ help: 'Show available commands',
41
+ exit: 'Exit the CLI',
42
+ create: 'Create a new test with autonomous design',
43
+ run: 'Execute an existing test',
44
+ list: 'List all available tests',
45
+ view: 'View test contents with line numbers',
46
+ edit: 'Edit a test in your default editor',
47
+ stop: 'Stop current test creation or execution',
48
+ };
49
+ /**
50
+ * Get command suggestions for autocomplete
51
+ * @param {string} partial - Partial command (e.g., "he" for "help")
52
+ * @returns {string[]} - Array of matching commands
53
+ */
54
+ export function getCommandSuggestions(partial) {
55
+ const lower = partial.toLowerCase();
56
+ return Object.keys(COMMANDS).filter(cmd => cmd.startsWith(lower));
57
+ }
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ /**
5
+ * Live status indicator for agent activity
6
+ */
7
+ export function AgentStatus({ isWorking, message }) {
8
+ if (!isWorking && !message) {
9
+ return null;
10
+ }
11
+ if (isWorking) {
12
+ return (React.createElement(Box, { marginTop: 1 },
13
+ React.createElement(Text, { color: "yellow" },
14
+ React.createElement(Spinner, { type: "dots" })),
15
+ React.createElement(Text, { dimColor: true },
16
+ " ",
17
+ message || 'Agent is working...')));
18
+ }
19
+ return (React.createElement(Box, { marginTop: 1 },
20
+ React.createElement(Text, { dimColor: true }, message)));
21
+ }
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ /**
4
+ * Command suggestions shown when user types "/"
5
+ */
6
+ export function CommandSuggestions({ input, commands }) {
7
+ const MAX_VISIBLE = 6;
8
+ const HEADER_LINES = 1;
9
+ const PANEL_HEIGHT = HEADER_LINES + MAX_VISIBLE;
10
+ // Only show suggestions if input starts with /
11
+ if (!input.startsWith('/')) {
12
+ return null;
13
+ }
14
+ // Extract the command part (without the /)
15
+ const commandPart = input.slice(1).toLowerCase();
16
+ // Filter commands that match
17
+ const suggestions = Object.entries(commands)
18
+ .filter(([cmd]) => cmd.toLowerCase().startsWith(commandPart))
19
+ .sort()
20
+ .slice(0, MAX_VISIBLE);
21
+ const usedLines = HEADER_LINES + suggestions.length;
22
+ const padLines = Math.max(0, PANEL_HEIGHT - usedLines);
23
+ return (React.createElement(Box, { flexDirection: "column", paddingX: 1, height: PANEL_HEIGHT },
24
+ React.createElement(Text, { dimColor: true }, "Available commands:"),
25
+ suggestions.map(([cmd, description]) => (React.createElement(Box, { key: cmd },
26
+ React.createElement(Text, { color: "cyan" },
27
+ " /",
28
+ cmd),
29
+ React.createElement(Text, { dimColor: true },
30
+ " - ",
31
+ description)))),
32
+ Array.from({ length: padLines }).map((_, i) => (React.createElement(Text, { key: `pad-${i}` }, " ")))));
33
+ }
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ /**
5
+ * Always-active input panel at the bottom
6
+ */
7
+ export function InputPanel({ value, onChange, onSubmit, placeholder, disabled }) {
8
+ const handleSubmit = (submittedValue) => {
9
+ if (submittedValue.trim().length === 0) {
10
+ return;
11
+ }
12
+ onSubmit(submittedValue.trim());
13
+ };
14
+ if (disabled) {
15
+ return (React.createElement(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1 },
16
+ React.createElement(Text, { dimColor: true }, "Input disabled...")));
17
+ }
18
+ return (React.createElement(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 1 },
19
+ React.createElement(Text, { color: "cyan", bold: true }, "> "),
20
+ React.createElement(TextInput, { value: value, onChange: onChange, onSubmit: handleSubmit, placeholder: placeholder || 'Type a command or message...', placeholderColor: "gray" })));
21
+ }
@@ -0,0 +1,58 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ /**
4
+ * Scrollable output panel for agent actions and reasoning
5
+ */
6
+ export function OutputPanel({ items }) {
7
+ if (items.length === 0) {
8
+ return (React.createElement(Box, { flexDirection: "column", paddingX: 1, paddingY: 1 },
9
+ React.createElement(Text, { dimColor: true }, "Waiting for input...")));
10
+ }
11
+ return (React.createElement(Box, { flexDirection: "column", paddingX: 1, paddingY: 1 }, items.map((item, index) => (React.createElement(OutputItem, { key: index, item: item })))));
12
+ }
13
+ function OutputItem({ item }) {
14
+ switch (item.type) {
15
+ case 'reasoning':
16
+ return (React.createElement(Box, null,
17
+ React.createElement(Text, { color: "magenta" }, "[Reasoning]"),
18
+ React.createElement(Text, null,
19
+ " ",
20
+ item.text)));
21
+ case 'action':
22
+ return (React.createElement(Box, null,
23
+ React.createElement(Text, { color: "blue" }, item.text)));
24
+ case 'assistant':
25
+ return (React.createElement(Box, { flexDirection: "column", marginTop: 1 },
26
+ React.createElement(Text, { color: "green", bold: true }, "[Assistant]"),
27
+ React.createElement(Text, null, item.text)));
28
+ case 'user':
29
+ return (React.createElement(Box, { marginTop: 1 },
30
+ React.createElement(Text, { bold: true }, "You: "),
31
+ React.createElement(Text, null, item.text)));
32
+ case 'system':
33
+ return (React.createElement(Box, { marginTop: 1 },
34
+ React.createElement(Text, { color: "yellow" }, item.text)));
35
+ case 'error':
36
+ return (React.createElement(Box, null,
37
+ React.createElement(Text, { color: "red" },
38
+ "\u26A0\uFE0F ",
39
+ item.text)));
40
+ case 'success':
41
+ return (React.createElement(Box, null,
42
+ React.createElement(Text, { color: "green" },
43
+ "\u2713 ",
44
+ item.text)));
45
+ case 'test-name':
46
+ return (React.createElement(Box, null,
47
+ React.createElement(Text, { color: "cyan" }, item.text),
48
+ React.createElement(Text, { dimColor: true },
49
+ " ",
50
+ item.metadata)));
51
+ case 'info':
52
+ return (React.createElement(Box, null,
53
+ React.createElement(Text, { dimColor: true }, item.text)));
54
+ default:
55
+ return (React.createElement(Box, null,
56
+ React.createElement(Text, null, item.text || '')));
57
+ }
58
+ }
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ /**
4
+ * Status bar showing current mode and device info
5
+ */
6
+ export function StatusBar({ mode, deviceInfo, testName }) {
7
+ const modeDisplay = mode === 'design'
8
+ ? `Design Mode${testName ? `: ${testName}` : ''}`
9
+ : mode === 'execution'
10
+ ? `Execution Mode${testName ? `: ${testName}` : ''}`
11
+ : 'Command Mode';
12
+ const deviceDisplay = deviceInfo
13
+ ? `${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`
14
+ : '';
15
+ return (React.createElement(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 1 },
16
+ React.createElement(Text, { bold: true, color: "cyan" }, "droid-cua"),
17
+ React.createElement(Text, { dimColor: true }, " - "),
18
+ React.createElement(Text, { color: "green" }, modeDisplay),
19
+ deviceDisplay && (React.createElement(React.Fragment, null,
20
+ React.createElement(Text, { dimColor: true }, " | "),
21
+ React.createElement(Text, { dimColor: true }, deviceDisplay)))));
22
+ }
@@ -0,0 +1,56 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { App } from './app.js';
4
+ import { parseInput } from './command-parser.js';
5
+ import { routeCommand } from '../commands/index.js';
6
+ /**
7
+ * Start the Ink-based conversational shell
8
+ * @param {Object} session - Session object with device info
9
+ * @param {Object} executionEngine - Execution engine instance
10
+ * @returns {Promise<void>}
11
+ */
12
+ export async function startInkShell(session, executionEngine) {
13
+ let shouldExit = false;
14
+ const handleInput = async (input, context) => {
15
+ // Check if there's an active design mode - route input to it
16
+ if (context.activeDesignMode) {
17
+ context.activeDesignMode.handleUserInput(input);
18
+ return;
19
+ }
20
+ // Parse input
21
+ const parsed = parseInput(input);
22
+ // During execution mode, only allow commands (not free text)
23
+ if (context.isExecutionMode && parsed.type !== 'command') {
24
+ context.addOutput({
25
+ type: 'error',
26
+ text: 'Cannot interrupt test execution with instructions. Use /stop or /exit to stop the test.',
27
+ });
28
+ return;
29
+ }
30
+ if (parsed.type === 'command') {
31
+ // Route to command handler
32
+ const shouldContinue = await routeCommand(parsed.command, parsed.args, session, { ...context, engine: executionEngine });
33
+ if (!shouldContinue) {
34
+ shouldExit = true;
35
+ context.exit();
36
+ }
37
+ }
38
+ else {
39
+ // In command mode, instructions aren't supported
40
+ if (context.getMode() === 'command') {
41
+ context.addOutput({
42
+ type: 'error',
43
+ text: 'Direct instructions only work in execution or design mode. Use /run or /create commands.',
44
+ });
45
+ }
46
+ }
47
+ };
48
+ const handleExit = () => {
49
+ shouldExit = true;
50
+ };
51
+ // Render the Ink app
52
+ const { unmount, waitUntilExit } = render(React.createElement(App, { session: session, initialMode: "command", onInput: handleInput, onExit: handleExit }));
53
+ // Wait for exit
54
+ await waitUntilExit();
55
+ return { unmount };
56
+ }