@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.
- package/LICENSE +1 -0
- package/README.md +227 -0
- package/bin/droid-cua +6 -0
- package/build/index.js +58 -0
- package/build/src/cli/app.js +115 -0
- package/build/src/cli/command-parser.js +57 -0
- package/build/src/cli/components/AgentStatus.js +21 -0
- package/build/src/cli/components/CommandSuggestions.js +33 -0
- package/build/src/cli/components/InputPanel.js +21 -0
- package/build/src/cli/components/OutputPanel.js +58 -0
- package/build/src/cli/components/StatusBar.js +22 -0
- package/build/src/cli/ink-shell.js +56 -0
- package/build/src/commands/create.js +42 -0
- package/build/src/commands/edit.js +61 -0
- package/build/src/commands/exit.js +20 -0
- package/build/src/commands/help.js +34 -0
- package/build/src/commands/index.js +49 -0
- package/build/src/commands/list.js +55 -0
- package/build/src/commands/run.js +112 -0
- package/build/src/commands/stop.js +32 -0
- package/build/src/commands/view.js +43 -0
- package/build/src/core/execution-engine.js +114 -0
- package/build/src/core/prompts.js +158 -0
- package/build/src/core/session.js +57 -0
- package/build/src/device/actions.js +81 -0
- package/build/src/device/assertions.js +75 -0
- package/build/src/device/connection.js +123 -0
- package/build/src/device/openai.js +124 -0
- package/build/src/modes/design-mode-ink.js +396 -0
- package/build/src/modes/design-mode.js +366 -0
- package/build/src/modes/execution-mode.js +165 -0
- package/build/src/test-store/test-manager.js +92 -0
- package/build/src/utils/logger.js +86 -0
- 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
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
|
+
}
|