Skip to content

Commit ce474cb

Browse files
committed
Fix Ctrl key for arrows/special keys, add Configure menu, clickable URL, SSH package (#37, #38)
- Fix Ctrl+Arrow/Home/End/PgUp/PgDn sending raw sequences instead of xterm Ctrl-modified variants (e.g. \x1b[1;5A for Ctrl+Up) - Make gateway dashboard URL clickable (opens WebDashboardScreen) with primary color + underline styling and open_in_new icon button - Add "Configure" quick action card running `openclaw configure` in terminal - Add OpenSSH as optional package (apt-get install openssh-client openssh-server) Co-Authored-By: Mithun Gowda B <mithungowda.b7411@gmail.com>
1 parent 58ce979 commit ce474cb

File tree

6 files changed

+425
-9
lines changed

6 files changed

+425
-9
lines changed

flutter_app/lib/models/optional_package.dart

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,30 @@ class OptionalPackage {
8585
completionSentinel: 'BREW_INSTALL_COMPLETE',
8686
);
8787

88+
static const sshPackage = OptionalPackage(
89+
id: 'ssh',
90+
name: 'OpenSSH',
91+
description: 'SSH client and server for secure remote access',
92+
icon: Icons.vpn_key,
93+
color: Colors.teal,
94+
installCommand:
95+
'set -e; '
96+
'echo ">>> Installing OpenSSH..."; '
97+
'apt-get update -qq && apt-get install -y openssh-client openssh-server; '
98+
'ssh -V; '
99+
'echo ">>> SSH_INSTALL_COMPLETE"',
100+
uninstallCommand:
101+
'set -e; '
102+
'echo ">>> Removing OpenSSH..."; '
103+
'apt-get remove -y openssh-client openssh-server && apt-get autoremove -y; '
104+
'echo ">>> SSH_UNINSTALL_COMPLETE"',
105+
checkPath: 'usr/bin/ssh',
106+
estimatedSize: '~10 MB',
107+
completionSentinel: 'SSH_INSTALL_COMPLETE',
108+
);
109+
88110
/// All available optional packages.
89-
static const all = [goPackage, brewPackage];
111+
static const all = [goPackage, brewPackage, sshPackage];
90112

91113
/// Sentinel for uninstall completion (derived from install sentinel).
92114
String get uninstallSentinel =>
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import 'dart:convert';
2+
import 'package:flutter/material.dart';
3+
import 'package:flutter/services.dart';
4+
import 'package:xterm/xterm.dart';
5+
import 'package:flutter_pty/flutter_pty.dart';
6+
import 'package:url_launcher/url_launcher.dart';
7+
import '../services/native_bridge.dart';
8+
import '../services/terminal_service.dart';
9+
import '../widgets/terminal_toolbar.dart';
10+
11+
/// Runs `openclaw configure` in a terminal so the user can manage
12+
/// gateway settings. Accessible from the dashboard.
13+
class ConfigureScreen extends StatefulWidget {
14+
const ConfigureScreen({super.key});
15+
16+
@override
17+
State<ConfigureScreen> createState() => _ConfigureScreenState();
18+
}
19+
20+
class _ConfigureScreenState extends State<ConfigureScreen> {
21+
late final Terminal _terminal;
22+
late final TerminalController _controller;
23+
Pty? _pty;
24+
bool _loading = true;
25+
bool _finished = false;
26+
String? _error;
27+
static final _anyUrlRegex = RegExp(r'https?://[^\s<>\[\]"' "'" r'\)]+');
28+
static final _boxDrawing = RegExp(r'[│┤├┬┴┼╮╯╰╭─╌╴╶┌┐└┘◇◆]+');
29+
30+
static const _fontFallback = [
31+
'monospace',
32+
'Noto Sans Mono',
33+
'Noto Sans Mono CJK SC',
34+
'Noto Sans Mono CJK TC',
35+
'Noto Sans Mono CJK JP',
36+
'Noto Color Emoji',
37+
'Noto Sans Symbols',
38+
'Noto Sans Symbols 2',
39+
'sans-serif',
40+
];
41+
42+
@override
43+
void initState() {
44+
super.initState();
45+
_terminal = Terminal(maxLines: 10000);
46+
_controller = TerminalController();
47+
NativeBridge.startTerminalService();
48+
WidgetsBinding.instance.addPostFrameCallback((_) {
49+
_startConfigure();
50+
});
51+
}
52+
53+
Future<void> _startConfigure() async {
54+
try {
55+
final config = await TerminalService.getProotShellConfig();
56+
final args = TerminalService.buildProotArgs(
57+
config,
58+
columns: _terminal.viewWidth,
59+
rows: _terminal.viewHeight,
60+
);
61+
62+
final configureArgs = List<String>.from(args);
63+
configureArgs.removeLast(); // remove '-l'
64+
configureArgs.removeLast(); // remove '/bin/bash'
65+
configureArgs.addAll([
66+
'/bin/bash', '-lc',
67+
'echo "=== OpenClaw Configure ===" && '
68+
'echo "Manage your gateway settings." && '
69+
'echo "" && '
70+
'openclaw configure; '
71+
'echo "" && echo "Configuration complete! You can close this screen."',
72+
]);
73+
74+
_pty = Pty.start(
75+
config['executable']!,
76+
arguments: configureArgs,
77+
environment: TerminalService.buildHostEnv(config),
78+
columns: _terminal.viewWidth,
79+
rows: _terminal.viewHeight,
80+
);
81+
82+
_pty!.output.cast<List<int>>().listen((data) {
83+
_terminal.write(utf8.decode(data, allowMalformed: true));
84+
});
85+
86+
_pty!.exitCode.then((code) {
87+
_terminal.write('\r\n[Configure exited with code $code]\r\n');
88+
if (mounted) {
89+
setState(() => _finished = true);
90+
}
91+
});
92+
93+
_terminal.onOutput = (data) {
94+
_pty?.write(utf8.encode(data));
95+
};
96+
97+
_terminal.onResize = (w, h, pw, ph) {
98+
_pty?.resize(h, w);
99+
};
100+
101+
setState(() => _loading = false);
102+
} catch (e) {
103+
setState(() {
104+
_loading = false;
105+
_error = 'Failed to start configure: $e';
106+
});
107+
}
108+
}
109+
110+
@override
111+
void dispose() {
112+
_controller.dispose();
113+
_pty?.kill();
114+
NativeBridge.stopTerminalService();
115+
super.dispose();
116+
}
117+
118+
String? _getSelectedText() {
119+
final selection = _controller.selection;
120+
if (selection == null || selection.isCollapsed) return null;
121+
122+
final range = selection.normalized;
123+
final sb = StringBuffer();
124+
for (int y = range.begin.y; y <= range.end.y; y++) {
125+
if (y >= _terminal.buffer.lines.length) break;
126+
final line = _terminal.buffer.lines[y];
127+
final from = (y == range.begin.y) ? range.begin.x : 0;
128+
final to = (y == range.end.y) ? range.end.x : null;
129+
sb.write(line.getText(from, to));
130+
if (y < range.end.y) sb.writeln();
131+
}
132+
final text = sb.toString().trim();
133+
return text.isEmpty ? null : text;
134+
}
135+
136+
String? _extractUrl(String text) {
137+
final clean = text.replaceAll(_boxDrawing, '').replaceAll(RegExp(r'\s+'), '');
138+
final parts = clean.split(RegExp(r'(?=https?://)'));
139+
String? best;
140+
for (final part in parts) {
141+
final match = _anyUrlRegex.firstMatch(part);
142+
if (match != null) {
143+
final url = match.group(0)!;
144+
if (best == null || url.length > best.length) {
145+
best = url;
146+
}
147+
}
148+
}
149+
return best;
150+
}
151+
152+
void _copySelection() {
153+
final text = _getSelectedText();
154+
if (text == null) return;
155+
156+
Clipboard.setData(ClipboardData(text: text));
157+
158+
final url = _extractUrl(text);
159+
if (url != null) {
160+
ScaffoldMessenger.of(context).showSnackBar(
161+
SnackBar(
162+
content: const Text('Copied to clipboard'),
163+
duration: const Duration(seconds: 3),
164+
action: SnackBarAction(
165+
label: 'Open',
166+
onPressed: () {
167+
final uri = Uri.tryParse(url);
168+
if (uri != null) {
169+
launchUrl(uri, mode: LaunchMode.externalApplication);
170+
}
171+
},
172+
),
173+
),
174+
);
175+
} else {
176+
ScaffoldMessenger.of(context).showSnackBar(
177+
const SnackBar(
178+
content: Text('Copied to clipboard'),
179+
duration: Duration(seconds: 1),
180+
),
181+
);
182+
}
183+
}
184+
185+
void _openSelection() {
186+
final text = _getSelectedText();
187+
if (text == null) return;
188+
189+
final url = _extractUrl(text);
190+
if (url != null) {
191+
final uri = Uri.tryParse(url);
192+
if (uri != null) {
193+
launchUrl(uri, mode: LaunchMode.externalApplication);
194+
return;
195+
}
196+
}
197+
ScaffoldMessenger.of(context).showSnackBar(
198+
const SnackBar(
199+
content: Text('No URL found in selection'),
200+
duration: Duration(seconds: 1),
201+
),
202+
);
203+
}
204+
205+
Future<void> _paste() async {
206+
final data = await Clipboard.getData(Clipboard.kTextPlain);
207+
if (data?.text != null && data!.text!.isNotEmpty) {
208+
_pty?.write(utf8.encode(data.text!));
209+
}
210+
}
211+
212+
@override
213+
Widget build(BuildContext context) {
214+
return Scaffold(
215+
appBar: AppBar(
216+
title: const Text('OpenClaw Configure'),
217+
leading: IconButton(
218+
icon: const Icon(Icons.arrow_back),
219+
onPressed: () => Navigator.of(context).pop(),
220+
),
221+
automaticallyImplyLeading: false,
222+
actions: [
223+
IconButton(
224+
icon: const Icon(Icons.copy),
225+
tooltip: 'Copy',
226+
onPressed: _copySelection,
227+
),
228+
IconButton(
229+
icon: const Icon(Icons.open_in_browser),
230+
tooltip: 'Open URL',
231+
onPressed: _openSelection,
232+
),
233+
IconButton(
234+
icon: const Icon(Icons.paste),
235+
tooltip: 'Paste',
236+
onPressed: _paste,
237+
),
238+
],
239+
),
240+
body: Column(
241+
children: [
242+
if (_loading)
243+
const Expanded(
244+
child: Center(
245+
child: Column(
246+
mainAxisAlignment: MainAxisAlignment.center,
247+
children: [
248+
CircularProgressIndicator(),
249+
SizedBox(height: 16),
250+
Text('Starting configure...'),
251+
],
252+
),
253+
),
254+
)
255+
else if (_error != null)
256+
Expanded(
257+
child: Center(
258+
child: Padding(
259+
padding: const EdgeInsets.all(24),
260+
child: Column(
261+
mainAxisAlignment: MainAxisAlignment.center,
262+
children: [
263+
Icon(
264+
Icons.error_outline,
265+
size: 48,
266+
color: Theme.of(context).colorScheme.error,
267+
),
268+
const SizedBox(height: 16),
269+
Text(
270+
_error!,
271+
textAlign: TextAlign.center,
272+
style: TextStyle(color: Theme.of(context).colorScheme.error),
273+
),
274+
const SizedBox(height: 16),
275+
FilledButton.icon(
276+
onPressed: () {
277+
setState(() {
278+
_loading = true;
279+
_error = null;
280+
_finished = false;
281+
});
282+
_startConfigure();
283+
},
284+
icon: const Icon(Icons.refresh),
285+
label: const Text('Retry'),
286+
),
287+
],
288+
),
289+
),
290+
),
291+
)
292+
else ...[
293+
Expanded(
294+
child: TerminalView(
295+
_terminal,
296+
controller: _controller,
297+
textStyle: const TerminalStyle(
298+
fontSize: 11,
299+
height: 1.0,
300+
fontFamily: 'DejaVuSansMono',
301+
fontFamilyFallback: _fontFallback,
302+
),
303+
),
304+
),
305+
TerminalToolbar(pty: _pty),
306+
],
307+
if (_finished)
308+
Padding(
309+
padding: const EdgeInsets.all(16),
310+
child: SizedBox(
311+
width: double.infinity,
312+
child: FilledButton.icon(
313+
onPressed: () => Navigator.of(context).pop(),
314+
icon: const Icon(Icons.check),
315+
label: const Text('Done'),
316+
),
317+
),
318+
),
319+
],
320+
),
321+
);
322+
}
323+
}

flutter_app/lib/screens/dashboard_screen.dart

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import '../providers/node_provider.dart';
66
import '../widgets/gateway_controls.dart';
77
import '../widgets/status_card.dart';
88
import 'node_screen.dart';
9+
import 'configure_screen.dart';
910
import 'onboarding_screen.dart';
1011
import 'terminal_screen.dart';
1112
import 'web_dashboard_screen.dart';
@@ -89,9 +90,18 @@ class DashboardScreen extends StatelessWidget {
8990
MaterialPageRoute(builder: (_) => const OnboardingScreen()),
9091
),
9192
),
93+
StatusCard(
94+
title: 'Configure',
95+
subtitle: 'Manage gateway settings',
96+
icon: Icons.tune,
97+
trailing: const Icon(Icons.chevron_right),
98+
onTap: () => Navigator.of(context).push(
99+
MaterialPageRoute(builder: (_) => const ConfigureScreen()),
100+
),
101+
),
92102
StatusCard(
93103
title: 'Packages',
94-
subtitle: 'Install optional tools (Go, Homebrew)',
104+
subtitle: 'Install optional tools (Go, Homebrew, SSH)',
95105
icon: Icons.extension,
96106
trailing: const Icon(Icons.chevron_right),
97107
onTap: () => Navigator.of(context).push(

0 commit comments

Comments
 (0)