neoagent 2.4.0 → 2.4.1-beta.6

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 (30) hide show
  1. package/LICENSE +619 -21
  2. package/README.md +1 -1
  3. package/extensions/chrome-browser/icons/icon128.png +0 -0
  4. package/extensions/chrome-browser/icons/icon16.png +0 -0
  5. package/extensions/chrome-browser/icons/icon48.png +0 -0
  6. package/extensions/chrome-browser/icons/logo.svg +12 -0
  7. package/extensions/chrome-browser/manifest.json +11 -1
  8. package/extensions/chrome-browser/popup.css +5 -0
  9. package/extensions/chrome-browser/popup.html +2 -4
  10. package/extensions/chrome-browser/popup.js +1 -5
  11. package/flutter_app/lib/main_controller.dart +16 -0
  12. package/flutter_app/lib/main_devices.dart +70 -1
  13. package/flutter_app/lib/main_settings.dart +215 -31
  14. package/flutter_app/lib/src/backend_client.dart +12 -0
  15. package/flutter_app/lib/src/desktop_companion_actions.dart +72 -0
  16. package/flutter_app/lib/src/desktop_companion_io.dart +9 -4
  17. package/flutter_app/macos/Runner/AppDelegate.swift +12 -1
  18. package/package.json +2 -2
  19. package/server/guest_agent.js +12 -1
  20. package/server/http/routes.js +190 -0
  21. package/server/public/.last_build_id +1 -1
  22. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  23. package/server/public/flutter_bootstrap.js +1 -1
  24. package/server/public/main.dart.js +51613 -51262
  25. package/server/services/ai/tools.js +39 -28
  26. package/server/services/desktop/protocol.js +1 -0
  27. package/server/services/desktop/provider.js +11 -0
  28. package/server/services/desktop/registry.js +51 -10
  29. package/server/services/runtime/docker-vm-manager.js +26 -3
  30. package/server/services/runtime/manager.js +25 -2
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" stop-color="#8f6d3e"/>
5
+ <stop offset="100%" stop-color="#2f7d6e"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="5.76" y="5.76" width="20.48" height="20.48" rx="6.96" fill="url(#bg)" stroke="#ffffff" stroke-opacity="0.16" stroke-width="1"/>
9
+ <polygon points="16,9.76 9.35,13.12 16,16.48 22.65,13.12" fill="white"/>
10
+ <polyline points="9.35,16.48 16,19.68 22.65,16.48" fill="none" stroke="white" stroke-width="1.44" stroke-linecap="round" stroke-linejoin="round"/>
11
+ <polyline points="9.35,19.68 16,22.72 22.65,19.68" fill="none" stroke="white" stroke-width="1.44" stroke-linecap="round" stroke-linejoin="round"/>
12
+ </svg>
@@ -6,9 +6,19 @@
6
6
  "minimum_chrome_version": "118",
7
7
  "permissions": ["debugger", "storage", "tabs"],
8
8
  "host_permissions": ["http://*/*", "https://*/*"],
9
+ "icons": {
10
+ "16": "icons/icon16.png",
11
+ "48": "icons/icon48.png",
12
+ "128": "icons/icon128.png"
13
+ },
9
14
  "action": {
10
15
  "default_title": "NeoAgent Browser",
11
- "default_popup": "popup.html"
16
+ "default_popup": "popup.html",
17
+ "default_icon": {
18
+ "16": "icons/icon16.png",
19
+ "48": "icons/icon48.png",
20
+ "128": "icons/icon128.png"
21
+ }
12
22
  },
13
23
  "background": {
14
24
  "service_worker": "background.mjs",
@@ -46,6 +46,11 @@ header {
46
46
  gap: 6px;
47
47
  }
48
48
 
49
+ .logo {
50
+ width: 40px;
51
+ height: 40px;
52
+ }
53
+
49
54
  h1,
50
55
  h2,
51
56
  p {
@@ -8,6 +8,7 @@
8
8
  <body>
9
9
  <main>
10
10
  <header>
11
+ <img src="icons/logo.svg" alt="NeoAgent" class="logo">
11
12
  <p class="eyebrow">NeoAgent Browser</p>
12
13
  <h1>Connect this Chrome</h1>
13
14
  <p class="intro">Pair once, then let NeoAgent control this browser when you ask it to.</p>
@@ -31,8 +32,6 @@
31
32
  </div>
32
33
  </section>
33
34
 
34
- <button id="openApp" type="button" class="link-button">Open NeoAgent web page</button>
35
-
36
35
  <details id="settings" class="settings">
37
36
  <summary>Settings &amp; updates</summary>
38
37
  <label>
@@ -47,8 +46,7 @@
47
46
  <p class="hint">The server URL is usually filled in by the ZIP downloaded from NeoAgent.</p>
48
47
  </details>
49
48
 
50
- <p class="hint">Pairing opens NeoAgent in a tab. This extension never stores your NeoAgent password.</p>
51
- <p id="message" class="message"></p>
49
+ <p id="message" class="message"></p>
52
50
  </main>
53
51
  <script src="popup.js" type="module"></script>
54
52
  </body>
@@ -9,7 +9,6 @@ const flowTitleEl = document.querySelector('#flowTitle');
9
9
  const flowDescriptionEl = document.querySelector('#flowDescription');
10
10
  const primaryActionEl = document.querySelector('#primaryAction');
11
11
  const secondaryActionEl = document.querySelector('#secondaryAction');
12
- const openAppEl = document.querySelector('#openApp');
13
12
  const disconnectEl = document.querySelector('#disconnect');
14
13
  const checkUpdateEl = document.querySelector('#checkUpdate');
15
14
  const downloadEl = document.querySelector('#download');
@@ -59,7 +58,7 @@ function setBusy(isBusy, label = 'Working...') {
59
58
  }
60
59
  const busy = pendingActions > 0;
61
60
 
62
- [primaryActionEl, secondaryActionEl, openAppEl, disconnectEl, checkUpdateEl, downloadEl].forEach((button) => {
61
+ [primaryActionEl, secondaryActionEl, disconnectEl, checkUpdateEl, downloadEl].forEach((button) => {
63
62
  if (!button || button.hidden) return;
64
63
  if (busy) {
65
64
  if (!Object.prototype.hasOwnProperty.call(button.dataset, 'wasDisabled')) {
@@ -99,8 +98,6 @@ function updateFlow() {
99
98
  const hasToken = Boolean(currentState.token || currentState.tokenId);
100
99
  const approvalUrl = currentState.approvalUrl || '';
101
100
 
102
- openAppEl.disabled = !hasServerUrl;
103
-
104
101
  if (!hasServerUrl) {
105
102
  stepLabelEl.textContent = 'Step 1 of 3';
106
103
  flowTitleEl.textContent = 'Add your NeoAgent server';
@@ -243,7 +240,6 @@ serverUrlEl.addEventListener('input', updateFlow);
243
240
 
244
241
  bindAsyncClick(primaryActionEl, () => runAction(primaryActionEl.dataset.action));
245
242
  bindAsyncClick(secondaryActionEl, () => runAction(secondaryActionEl.dataset.action));
246
- bindAsyncClick(openAppEl, () => runAction('openApp'));
247
243
  bindAsyncClick(disconnectEl, async () => {
248
244
  await send('disconnect');
249
245
  await refresh();
@@ -1040,6 +1040,15 @@ class NeoAgentController extends ChangeNotifier {
1040
1040
  }
1041
1041
  }
1042
1042
 
1043
+ Future<Map<String, dynamic>> testCliRuntime() =>
1044
+ _backendClient.testCli(backendUrl);
1045
+
1046
+ Future<Map<String, dynamic>> testBrowserExtension() =>
1047
+ _backendClient.testExtension(backendUrl);
1048
+
1049
+ Future<Map<String, dynamic>> testDesktopCompanion() =>
1050
+ _backendClient.testDesktop(backendUrl);
1051
+
1043
1052
  Future<void> openAppUpdate() async {
1044
1053
  final release = availableAppUpdate;
1045
1054
  if (release == null || isOpeningAppUpdate) {
@@ -4565,6 +4574,7 @@ class NeoAgentController extends ChangeNotifier {
4565
4574
  Future<void> saveSettings({
4566
4575
  required String browserBackend,
4567
4576
  required String cliBackend,
4577
+ String? cliDesktopDeviceId,
4568
4578
  required bool smarterSelector,
4569
4579
  required List<String> enabledModels,
4570
4580
  required String defaultChatModel,
@@ -4594,6 +4604,7 @@ class NeoAgentController extends ChangeNotifier {
4594
4604
  'headless_browser': true,
4595
4605
  'browser_backend': browserBackend,
4596
4606
  'cli_backend': cliBackend,
4607
+ if (cliDesktopDeviceId != null) 'cli_desktop_device_id': cliDesktopDeviceId,
4597
4608
  'smarter_model_selector': smarterSelector,
4598
4609
  'enabled_models': enabledModels,
4599
4610
  'default_chat_model': defaultChatModel,
@@ -6131,6 +6142,11 @@ class NeoAgentController extends ChangeNotifier {
6131
6142
  String get cliBackend =>
6132
6143
  settings['cli_backend']?.toString().trim().toLowerCase() ?? 'vm';
6133
6144
 
6145
+ String? get cliDesktopDeviceId {
6146
+ final v = settings['cli_desktop_device_id']?.toString().trim();
6147
+ return (v == null || v.isEmpty) ? null : v;
6148
+ }
6149
+
6134
6150
  String get cloudBrowserBackend {
6135
6151
  final browser = browserBackend;
6136
6152
  final profile = settings['runtime_profile']
@@ -103,8 +103,13 @@ class _DevicesPanelState extends State<DevicesPanel> {
103
103
  _onlineDesktopDevices.length > 1 &&
104
104
  (widget.controller.selectedDesktopDeviceId ?? '').isEmpty;
105
105
 
106
+ bool get _extensionPreferredButOffline =>
107
+ widget.controller.browserBackend == 'extension' &&
108
+ !widget.controller.browserExtensionConnected;
109
+
106
110
  String? get _activeScreenshotPath {
107
111
  if (_isBrowser) {
112
+ if (_extensionPreferredButOffline) return null;
108
113
  return widget.controller.browserScreenshotPath;
109
114
  }
110
115
  if (_isDesktop) {
@@ -121,6 +126,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
121
126
  Future<void> _ensurePreview() async {
122
127
  final controller = widget.controller;
123
128
  if (_isBrowser) {
129
+ if (_extensionPreferredButOffline) return;
124
130
  if (controller.browserRuntime['launched'] != true) {
125
131
  return;
126
132
  }
@@ -161,6 +167,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
161
167
  return;
162
168
  }
163
169
  if (_isBrowser) {
170
+ if (_extensionPreferredButOffline) return;
164
171
  await widget.controller.refreshBrowserFrameRuntime();
165
172
  return;
166
173
  }
@@ -451,6 +458,14 @@ class _DevicesPanelState extends State<DevicesPanel> {
451
458
  browserExtensionActive: usingExtension,
452
459
  browserFallbackLabel: browserFallbackLabel,
453
460
  ),
461
+ if (_isBrowser && prefersExtension) ...<Widget>[
462
+ const SizedBox(height: 14),
463
+ _ExtensionStatusBar(
464
+ connected: extensionConnected,
465
+ onDownload: controller.downloadBrowserExtension,
466
+ onRefresh: controller.refreshBrowserExtensionStatus,
467
+ ),
468
+ ],
454
469
  if (_isDesktop) ...<Widget>[
455
470
  const SizedBox(height: 14),
456
471
  DropdownButtonFormField<String>(
@@ -558,7 +573,9 @@ class _DevicesPanelState extends State<DevicesPanel> {
558
573
  busy: _isCurrentSurfaceBusy,
559
574
  wakingUp: !_isBrowser && !_isDesktop && _androidStarting,
560
575
  enabled: _isBrowser || _isDesktop || _androidOnline,
561
- connectRequired: _desktopRequiresSelection,
576
+ connectRequired: _isBrowser
577
+ ? _extensionPreferredButOffline
578
+ : _desktopRequiresSelection,
562
579
  onTapPoint: _handleTap,
563
580
  onSwipe: _handleSwipe,
564
581
  onWakeRequested: _openPrimary,
@@ -1701,6 +1718,58 @@ class _RuntimePreview extends StatelessWidget {
1701
1718
  }
1702
1719
  }
1703
1720
 
1721
+ class _ExtensionStatusBar extends StatelessWidget {
1722
+ const _ExtensionStatusBar({
1723
+ required this.connected,
1724
+ required this.onDownload,
1725
+ required this.onRefresh,
1726
+ });
1727
+
1728
+ final bool connected;
1729
+ final Future<void> Function() onDownload;
1730
+ final Future<void> Function() onRefresh;
1731
+
1732
+ @override
1733
+ Widget build(BuildContext context) {
1734
+ return Container(
1735
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
1736
+ decoration: BoxDecoration(
1737
+ color: _bgSecondary,
1738
+ borderRadius: BorderRadius.circular(16),
1739
+ border: Border.all(color: _borderLight),
1740
+ ),
1741
+ child: Row(
1742
+ children: <Widget>[
1743
+ _DotStatus(
1744
+ label: connected ? 'Extension connected' : 'Extension not connected',
1745
+ color: connected ? _success : _warning,
1746
+ ),
1747
+ const Spacer(),
1748
+ OutlinedButton.icon(
1749
+ onPressed: onDownload,
1750
+ icon: const Icon(Icons.download_outlined, size: 18),
1751
+ label: const Text('Download'),
1752
+ style: OutlinedButton.styleFrom(
1753
+ visualDensity: VisualDensity.compact,
1754
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
1755
+ ),
1756
+ ),
1757
+ const SizedBox(width: 8),
1758
+ OutlinedButton.icon(
1759
+ onPressed: onRefresh,
1760
+ icon: const Icon(Icons.sync, size: 18),
1761
+ label: const Text('Refresh'),
1762
+ style: OutlinedButton.styleFrom(
1763
+ visualDensity: VisualDensity.compact,
1764
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
1765
+ ),
1766
+ ),
1767
+ ],
1768
+ ),
1769
+ );
1770
+ }
1771
+ }
1772
+
1704
1773
  class _ResultBlock extends StatelessWidget {
1705
1774
  const _ResultBlock({required this.label, required this.value});
1706
1775
 
@@ -171,6 +171,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
171
171
  late final TextEditingController _searchController;
172
172
  late String _browserBackend;
173
173
  late String _cliBackend;
174
+ String? _cliDesktopDeviceId;
174
175
  late bool _smarterSelector;
175
176
  late Set<String> _enabledModels;
176
177
  late String _defaultChatModel;
@@ -187,6 +188,14 @@ class _SettingsPanelState extends State<SettingsPanel> {
187
188
  <String, TextEditingController>{};
188
189
  final Set<String> _expandedProviderIds = <String>{};
189
190
 
191
+ // Inline runtime test state — ephemeral, not stored in controller.
192
+ bool _cliTestRunning = false;
193
+ Map<String, dynamic>? _cliTestResult;
194
+ bool _extensionTestRunning = false;
195
+ Map<String, dynamic>? _extensionTestResult;
196
+ bool _desktopTestRunning = false;
197
+ Map<String, dynamic>? _desktopTestResult;
198
+
190
199
  @override
191
200
  void initState() {
192
201
  super.initState();
@@ -225,6 +234,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
225
234
  .toSet();
226
235
  _browserBackend = _normalizeBrowserBackend(controller.browserBackend);
227
236
  _cliBackend = _normalizeCliBackend(controller.cliBackend);
237
+ _cliDesktopDeviceId = controller.cliDesktopDeviceId;
228
238
  _smarterSelector = controller.smarterSelector;
229
239
  _enabledModels = controller.enabledModelIds
230
240
  .where((id) => knownModels.contains(id))
@@ -467,6 +477,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
467
477
  ? 'extension'
468
478
  : 'vm',
469
479
  cliBackend: _cliBackend == 'desktop' ? 'desktop' : 'vm',
480
+ cliDesktopDeviceId: _cliDesktopDeviceId,
470
481
  smarterSelector: _smarterSelector,
471
482
  enabledModels: _enabledModels.toList(),
472
483
  defaultChatModel: _defaultChatModel,
@@ -636,31 +647,46 @@ class _SettingsPanelState extends State<SettingsPanel> {
636
647
  },
637
648
  ),
638
649
  const SizedBox(height: 10),
639
- Text(
640
- _browserBackend == 'extension'
641
- ? (controller.browserExtensionConnected
642
- ? 'Chrome extension connected.'
643
- : 'Chrome extension selected. Download it here, load it unpacked in Chrome on the remote machine, then pair after login.')
644
- : 'Cloud browser runtime is active.',
645
- style: TextStyle(color: _textSecondary, height: 1.4),
646
- ),
647
- const SizedBox(height: 10),
648
- Wrap(
649
- spacing: 10,
650
- runSpacing: 10,
651
- children: <Widget>[
652
- OutlinedButton.icon(
653
- onPressed: controller.downloadBrowserExtension,
654
- icon: Icon(Icons.download_outlined),
655
- label: Text('Download extension'),
656
- ),
657
- OutlinedButton.icon(
658
- onPressed: controller.refreshBrowserExtensionStatus,
659
- icon: Icon(Icons.sync),
660
- label: Text('Refresh status'),
661
- ),
662
- ],
663
- ),
650
+ if (_browserBackend == 'extension') ...<Widget>[
651
+ _buildInlineTestRow(
652
+ label: 'Chrome extension',
653
+ running: _extensionTestRunning,
654
+ result: _extensionTestResult,
655
+ note: controller.browserExtensionConnected
656
+ ? 'Connected tap Test to verify the live link.'
657
+ : 'Not connected — download the extension, load it in Chrome, then pair after login.',
658
+ onTest: () async {
659
+ setState(() { _extensionTestRunning = true; _extensionTestResult = null; });
660
+ try {
661
+ final r = await controller.testBrowserExtension();
662
+ setState(() => _extensionTestResult = r);
663
+ } catch (e) {
664
+ setState(() => _extensionTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
665
+ } finally {
666
+ setState(() => _extensionTestRunning = false);
667
+ }
668
+ },
669
+ ),
670
+ const SizedBox(height: 10),
671
+ Wrap(
672
+ spacing: 10,
673
+ runSpacing: 10,
674
+ children: <Widget>[
675
+ OutlinedButton.icon(
676
+ onPressed: controller.downloadBrowserExtension,
677
+ icon: Icon(Icons.download_outlined),
678
+ label: Text('Download extension'),
679
+ ),
680
+ OutlinedButton.icon(
681
+ onPressed: controller.refreshBrowserExtensionStatus,
682
+ icon: Icon(Icons.sync),
683
+ label: Text('Refresh'),
684
+ ),
685
+ ],
686
+ ),
687
+ ] else ...<Widget>[
688
+ Text('Cloud browser runtime is active.', style: TextStyle(color: _textSecondary, height: 1.4)),
689
+ ],
664
690
  const Divider(height: 32),
665
691
  Text(
666
692
  'CLI Runtime',
@@ -691,14 +717,67 @@ class _SettingsPanelState extends State<SettingsPanel> {
691
717
  }
692
718
  },
693
719
  ),
720
+ if (_cliBackend == 'desktop' && controller.desktopDevices.length > 1) ...<Widget>[
721
+ const SizedBox(height: 12),
722
+ DropdownButtonFormField<String>(
723
+ initialValue: controller.desktopDevices.any(
724
+ (d) => d['deviceId']?.toString() == _cliDesktopDeviceId,
725
+ )
726
+ ? _cliDesktopDeviceId
727
+ : null,
728
+ decoration: const InputDecoration(
729
+ labelText: 'Desktop device',
730
+ helperText: 'Choose which desktop companion runs CLI commands.',
731
+ ),
732
+ items: controller.desktopDevices.map((device) {
733
+ final deviceId = device['deviceId']?.toString() ?? '';
734
+ final label = device['hostname']?.toString().isNotEmpty == true
735
+ ? device['hostname']!.toString()
736
+ : deviceId;
737
+ final online = device['online'] == true;
738
+ return DropdownMenuItem<String>(
739
+ value: deviceId,
740
+ child: Row(
741
+ children: <Widget>[
742
+ Icon(
743
+ online ? Icons.circle : Icons.circle_outlined,
744
+ size: 10,
745
+ color: online ? Colors.green : Colors.grey,
746
+ ),
747
+ const SizedBox(width: 8),
748
+ Text(label),
749
+ ],
750
+ ),
751
+ );
752
+ }).toList(),
753
+ onChanged: (value) {
754
+ if (value != null) {
755
+ setState(() => _cliDesktopDeviceId = value);
756
+ }
757
+ },
758
+ ),
759
+ ],
694
760
  const SizedBox(height: 10),
695
- Text(
696
- _cliBackend == 'desktop'
761
+ _buildInlineTestRow(
762
+ label: 'CLI',
763
+ running: _cliTestRunning,
764
+ result: _cliTestResult,
765
+ note: _cliBackend == 'desktop'
697
766
  ? (controller.desktopCompanionConnected
698
- ? 'Desktop app connected.'
699
- : 'Desktop app selected. Make sure the desktop companion is running and connected on your machine.')
700
- : 'Cloud CLI runtime is active.',
701
- style: TextStyle(color: _textSecondary, height: 1.4),
767
+ ? 'Desktop app connected — commands currently route through the cloud VM (desktop routing coming soon).'
768
+ : 'Desktop app selected but not connected. Commands fall back to cloud VM until the companion is online.')
769
+ : 'Cloud VM commands run in an isolated container.',
770
+ onTest: () async {
771
+ setState(() { _cliTestRunning = true; _cliTestResult = null; });
772
+ try {
773
+ final r = await controller.testCliRuntime();
774
+ setState(() => _cliTestResult = r);
775
+ } catch (e) {
776
+ setState(() => _cliTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
777
+ } finally {
778
+ setState(() => _cliTestRunning = false);
779
+ }
780
+ },
702
781
  ),
703
782
  const Divider(height: 32),
704
783
  Text(
@@ -1344,6 +1423,47 @@ class _SettingsPanelState extends State<SettingsPanel> {
1344
1423
  const SizedBox(height: 12),
1345
1424
  _InlineError(message: message),
1346
1425
  ],
1426
+ const SizedBox(height: 12),
1427
+ _buildInlineTestRow(
1428
+ label: 'Desktop companion',
1429
+ running: _desktopTestRunning,
1430
+ result: _desktopTestResult != null
1431
+ ? <String, dynamic>{
1432
+ 'passed': _desktopTestResult!['passed'] == true,
1433
+ 'detail': _desktopTestResult!['detail']?.toString() ?? '',
1434
+ }
1435
+ : null,
1436
+ note: controller.desktopCompanionConnected
1437
+ ? 'Connected — tap Test to fetch live device status from the server.'
1438
+ : 'Not connected. Make sure the desktop app is running on the target machine.',
1439
+ onTest: () async {
1440
+ setState(() { _desktopTestRunning = true; _desktopTestResult = null; });
1441
+ try {
1442
+ final r = await controller.testDesktopCompanion();
1443
+ final active = r['activeDevice'];
1444
+ final multi = r['multipleOnline'] == true;
1445
+ String detail = r['detail']?.toString() ?? '';
1446
+ if (r['passed'] == true && active != null) {
1447
+ final label = active['label']?.toString() ?? 'Device';
1448
+ final plat = active['platform']?.toString() ?? '';
1449
+ final sc = active['permissions']?['screenCapture'] == true;
1450
+ final ic = active['permissions']?['inputControl'] == true;
1451
+ detail = '$label${plat.isNotEmpty ? " ($plat)" : ""}'
1452
+ ' — screen: ${sc ? "✓" : "✗"}, input: ${ic ? "✓" : "✗"}';
1453
+ } else if (multi) {
1454
+ detail = '${r['onlineCount']} devices online — select one in Desktop › Devices';
1455
+ }
1456
+ setState(() => _desktopTestResult = <String, dynamic>{
1457
+ ...r,
1458
+ 'detail': detail,
1459
+ });
1460
+ } catch (e) {
1461
+ setState(() => _desktopTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
1462
+ } finally {
1463
+ setState(() => _desktopTestRunning = false);
1464
+ }
1465
+ },
1466
+ ),
1347
1467
  const SizedBox(height: 14),
1348
1468
  Builder(
1349
1469
  builder: (context) {
@@ -1999,6 +2119,70 @@ class _SettingsPanelState extends State<SettingsPanel> {
1999
2119
  )
2000
2120
  .toList();
2001
2121
  }
2122
+
2123
+ // Shared helper: small "Test" button + inline result row.
2124
+ Widget _buildInlineTestRow({
2125
+ required String label,
2126
+ required bool running,
2127
+ required Map<String, dynamic>? result,
2128
+ required VoidCallback onTest,
2129
+ String? note,
2130
+ }) {
2131
+ final passed = result?['passed'] == true;
2132
+ final detail = result?['detail']?.toString() ?? '';
2133
+ return Row(
2134
+ crossAxisAlignment: CrossAxisAlignment.start,
2135
+ children: <Widget>[
2136
+ Expanded(
2137
+ child: Column(
2138
+ crossAxisAlignment: CrossAxisAlignment.start,
2139
+ children: <Widget>[
2140
+ if (result != null)
2141
+ Row(
2142
+ children: <Widget>[
2143
+ Icon(
2144
+ passed ? Icons.check_circle_rounded : Icons.cancel_rounded,
2145
+ size: 15,
2146
+ color: passed ? const Color(0xFF22C55E) : const Color(0xFFEF4444),
2147
+ ),
2148
+ const SizedBox(width: 6),
2149
+ Expanded(
2150
+ child: Text(
2151
+ passed ? (detail.isNotEmpty ? detail : '$label: OK') : detail,
2152
+ style: TextStyle(
2153
+ fontSize: 13,
2154
+ color: passed ? null : const Color(0xFFEF4444),
2155
+ ),
2156
+ ),
2157
+ ),
2158
+ ],
2159
+ )
2160
+ else if (note != null)
2161
+ Text(note, style: TextStyle(fontSize: 13, color: _textSecondary, height: 1.4)),
2162
+ ],
2163
+ ),
2164
+ ),
2165
+ const SizedBox(width: 12),
2166
+ SizedBox(
2167
+ width: 80,
2168
+ child: OutlinedButton(
2169
+ onPressed: running ? null : onTest,
2170
+ style: OutlinedButton.styleFrom(
2171
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
2172
+ textStyle: const TextStyle(fontSize: 12),
2173
+ ),
2174
+ child: running
2175
+ ? const SizedBox(
2176
+ width: 13,
2177
+ height: 13,
2178
+ child: CircularProgressIndicator(strokeWidth: 2),
2179
+ )
2180
+ : const Text('Test'),
2181
+ ),
2182
+ ),
2183
+ ],
2184
+ );
2185
+ }
2002
2186
  }
2003
2187
 
2004
2188
  class _AiProviderCard extends StatelessWidget {
@@ -476,6 +476,18 @@ class BackendClient {
476
476
  return getMap(baseUrl, '/api/runtime/config', allowUnauthorized: true);
477
477
  }
478
478
 
479
+ Future<Map<String, dynamic>> testCli(String baseUrl) async {
480
+ return getMap(baseUrl, '/api/system/test/cli');
481
+ }
482
+
483
+ Future<Map<String, dynamic>> testExtension(String baseUrl) async {
484
+ return getMap(baseUrl, '/api/system/test/extension');
485
+ }
486
+
487
+ Future<Map<String, dynamic>> testDesktop(String baseUrl) async {
488
+ return getMap(baseUrl, '/api/system/test/desktop');
489
+ }
490
+
479
491
  Future<Map<String, dynamic>> fetchBrowserStatus(String baseUrl) async {
480
492
  return getMap(baseUrl, '/api/browser/status');
481
493
  }
@@ -1,3 +1,4 @@
1
+ import 'dart:async';
1
2
  import 'dart:convert';
2
3
  import 'dart:io';
3
4
 
@@ -396,6 +397,77 @@ class DesktopCompanionActions {
396
397
  };
397
398
  }
398
399
 
400
+ Future<Map<String, Object?>> executeShellCommand({
401
+ required String command,
402
+ String? cwd,
403
+ int? timeoutMs,
404
+ String? stdinInput,
405
+ }) async {
406
+ final shell = Platform.isWindows ? 'cmd.exe' : (Platform.environment['SHELL'] ?? '/bin/sh');
407
+ final args = Platform.isWindows ? <String>['/c', command] : <String>['-lc', command];
408
+ final workingDir = cwd?.trim().isNotEmpty == true ? cwd : Platform.environment['HOME'];
409
+ final startedAt = DateTime.now();
410
+
411
+ final process = await Process.start(
412
+ shell,
413
+ args,
414
+ workingDirectory: workingDir,
415
+ runInShell: false,
416
+ );
417
+
418
+ if (stdinInput != null && stdinInput.isNotEmpty) {
419
+ process.stdin.write(stdinInput);
420
+ await process.stdin.close();
421
+ } else {
422
+ unawaited(process.stdin.close());
423
+ }
424
+
425
+ const maxChars = 50000;
426
+ final stdoutBuf = StringBuffer();
427
+ final stderrBuf = StringBuffer();
428
+
429
+ final stdoutSub = process.stdout.transform(utf8.decoder).listen((data) {
430
+ stdoutBuf.write(data);
431
+ });
432
+ final stderrSub = process.stderr.transform(utf8.decoder).listen((data) {
433
+ stderrBuf.write(data);
434
+ });
435
+
436
+ final effectiveTimeout = Duration(
437
+ milliseconds: (timeoutMs != null && timeoutMs > 0) ? timeoutMs : 15 * 60 * 1000,
438
+ );
439
+
440
+ bool timedOut = false;
441
+ int? exitCode;
442
+ try {
443
+ exitCode = await process.exitCode.timeout(effectiveTimeout);
444
+ } on TimeoutException {
445
+ timedOut = true;
446
+ process.kill(ProcessSignal.sigterm);
447
+ exitCode = null;
448
+ }
449
+
450
+ await stdoutSub.cancel();
451
+ await stderrSub.cancel();
452
+
453
+ String _trim(StringBuffer buf) {
454
+ final s = buf.toString().trim();
455
+ return s.length > maxChars ? '${s.substring(0, maxChars)}\n...[truncated, ${s.length} total chars]' : s;
456
+ }
457
+
458
+ return <String, Object?>{
459
+ 'exitCode': exitCode,
460
+ 'stdout': _trim(stdoutBuf),
461
+ 'stderr': _trim(stderrBuf),
462
+ 'timedOut': timedOut,
463
+ 'killed': timedOut,
464
+ 'durationMs': DateTime.now().difference(startedAt).inMilliseconds,
465
+ 'command': command,
466
+ 'cwd': workingDir,
467
+ 'backend': 'desktop-companion',
468
+ };
469
+ }
470
+
399
471
  Future<Map<String, Object?>> _capabilities({
400
472
  Map<String, Object?>? platformStatus,
401
473
  }) async {