From 8b4cbf19145b79f472dd0006765739e1ccdf43b5 Mon Sep 17 00:00:00 2001 From: rusty4444 Date: Sat, 18 Jul 2026 07:09:44 +1000 Subject: [PATCH] fix: hide raw tool output and edit saved connections --- lib/core/screens/chat_screen.dart | 74 ++++++-------- lib/core/services/connection_manager.dart | 48 +++++++++ lib/core/utils/message_content.dart | 75 ++++++++++++-- lib/main.dart | 116 +++++++++++++++++----- test/connection_manager_test.dart | 48 +++++++++ test/message_content_test.dart | 24 +++++ 6 files changed, 309 insertions(+), 76 deletions(-) diff --git a/lib/core/screens/chat_screen.dart b/lib/core/screens/chat_screen.dart index bdc3078..206c054 100644 --- a/lib/core/screens/chat_screen.dart +++ b/lib/core/screens/chat_screen.dart @@ -288,10 +288,10 @@ class _ChatScreenState extends State { void _extractToolMessages(List> messages) { _toolMessages.clear(); for (final msg in messages) { - final role = (msg['role'] as String?) ?? ''; - if (role != 'tool') continue; + if (!isToolResultMessage(msg)) continue; - final name = (msg['name'] as String?) ?? + final name = + (msg['name'] as String?) ?? (msg['tool_name'] as String?) ?? (msg['toolCallName'] as String?) ?? ''; @@ -412,23 +412,23 @@ class _ChatScreenState extends State { } } final saved = _savedPositions[widget.session.id]; - if (saved != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.jumpTo( - saved.clamp(0.0, _scrollController.position.maxScrollExtent), - ); - } - }); - } else { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.jumpTo( - _scrollController.position.maxScrollExtent, - ); + if (saved != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo( + saved.clamp(0.0, _scrollController.position.maxScrollExtent), + ); + } + }); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo( + _scrollController.position.maxScrollExtent, + ); + } + }); } - }); - } } catch (e) { setState(() { _streaming = false; @@ -491,9 +491,7 @@ class _ChatScreenState extends State { setState(() { final idx = toolCallId.isEmpty ? -1 - : _toolMessages.indexWhere( - (m) => m['toolCallId'] == toolCallId, - ); + : _toolMessages.indexWhere((m) => m['toolCallId'] == toolCallId); final payload = { 'role': 'tool_progress', 'content': content, @@ -681,21 +679,21 @@ class _ChatScreenState extends State { for (final msg in _messages) { final role = (msg['role'] as String?) ?? 'assistant'; - if (role == 'tool') { + if (isToolResultMessage(msg)) { if (toolQueue.isNotEmpty) { currentGroup.add(toolQueue.removeAt(0)); } continue; } if (role != 'user' && role != 'assistant') continue; - final content = messageContentToText(msg['content']); + final content = stripToolResultText(messageContentToText(msg['content'])); if (content.isEmpty) continue; if (currentGroup.isNotEmpty) { displayMessages.add(currentGroup.toList()); currentGroup.clear(); } - displayMessages.add(msg); + displayMessages.add({...msg, '_display_content': content}); } if (currentGroup.isNotEmpty) { displayMessages.add(currentGroup.toList()); @@ -720,7 +718,9 @@ class _ChatScreenState extends State { final msg = item as Map; final role = (msg['role'] as String?) ?? 'assistant'; - final content = messageContentToText(msg['content']); + final content = + (msg['_display_content'] as String?) ?? + stripToolResultText(messageContentToText(msg['content'])); final isUser = role == 'user'; return _MessageBubble( @@ -892,15 +892,11 @@ class _MessageBubble extends StatelessWidget { } } - class _ToolProgressCard extends StatelessWidget { final List> items; final bool verbose; - const _ToolProgressCard({ - required this.items, - this.verbose = false, - }); + const _ToolProgressCard({required this.items, this.verbose = false}); @override Widget build(BuildContext context) { @@ -916,7 +912,9 @@ class _ToolProgressCard extends StatelessWidget { final emojis = items.map((item) { final content = (item['content'] as String?) ?? ''; - return content.isNotEmpty ? content.substring(0, content.length < 2 ? content.length : 2) : '\uD83D\uDD27'; + return content.isNotEmpty + ? content.substring(0, content.length < 2 ? content.length : 2) + : '\uD83D\uDD27'; }).toList(); return Container( @@ -936,24 +934,18 @@ class _ToolProgressCard extends StatelessWidget { style: const TextStyle(fontSize: 13), ), const SizedBox(width: 6), - Text( - emojis.join(' '), - style: const TextStyle(fontSize: 13), - ), + Text(emojis.join(' '), style: const TextStyle(fontSize: 13)), if (active) Padding( padding: const EdgeInsets.only(left: 8), child: SizedBox( width: 12, height: 12, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: fg, - ), + child: CircularProgressIndicator(strokeWidth: 1.5, color: fg), ), ), ], ), ); } -} \ No newline at end of file +} diff --git a/lib/core/services/connection_manager.dart b/lib/core/services/connection_manager.dart index 1e66491..473c4bf 100644 --- a/lib/core/services/connection_manager.dart +++ b/lib/core/services/connection_manager.dart @@ -60,6 +60,54 @@ class ConnectionManager { _saveAll(current); } + /// Updates all editable fields on an existing connection while preserving its + /// id and list position. Empty optional strings clear their saved values. + void updateConnection( + String connId, + String label, + String host, + int port, + String apiKey, { + String? gatewayPrefix, + String? dashboardPrefix, + bool dashboardProxied = false, + int? dashboardPort, + String? dashboardUsername, + String? dashboardPassword, + }) { + final current = getConnections(); + final idx = current.indexWhere((c) => c.id == connId); + if (idx < 0) return; + + final normalized = SavedConnection.normalizeHostAndPort(host, port); + final gateway = gatewayPrefix?.trim(); + final dashboard = dashboardPrefix?.trim(); + final dashUser = dashboardUsername?.trim(); + final dashPass = dashboardPassword?.trim(); + + current[idx] = current[idx].copyWith( + label: label, + host: normalized.host, + port: normalized.port, + apiKey: apiKey, + useHttps: normalized.useHttps, + gatewayPrefix: gateway == null || gateway.isEmpty ? null : gateway, + clearGatewayPrefix: gateway != null && gateway.isEmpty, + dashboardPrefix: dashboard == null || dashboard.isEmpty + ? null + : dashboard, + clearDashboardPrefix: dashboard != null && dashboard.isEmpty, + dashboardProxied: dashboardProxied, + dashboardPortOverride: dashboardPort, + clearDashboardPort: dashboardPort == null, + dashboardUsername: dashUser == null || dashUser.isEmpty ? null : dashUser, + clearDashboardUsername: dashUser != null && dashUser.isEmpty, + dashboardPassword: dashPass == null || dashPass.isEmpty ? null : dashPass, + clearDashboardPassword: dashPass != null && dashPass.isEmpty, + ); + _saveAll(current); + } + /// Updates the dashboard port + basic-auth credentials on an existing /// connection. Empty strings clear the corresponding field. void updateDashboardAuth( diff --git a/lib/core/utils/message_content.dart b/lib/core/utils/message_content.dart index 5986579..6b82b61 100644 --- a/lib/core/utils/message_content.dart +++ b/lib/core/utils/message_content.dart @@ -5,25 +5,84 @@ /// Keep rendering resilient when the gateway adds new part types. String messageContentToText(dynamic content) { if (content == null) return ''; - if (content is String) return content; + if (content is String) return normaliseDisplayText(content); if (content is List) { - return content - .map(_contentPartToText) - .where((part) => part.isNotEmpty) - .join('\n\n'); + return normaliseDisplayText( + content + .map(_contentPartToText) + .where((part) => part.isNotEmpty) + .join('\n\n'), + ); } - return _contentPartToText(content); + return normaliseDisplayText(_contentPartToText(content)); +} + +/// Normalise common JSON-escaped line endings that can survive one decode layer. +/// +/// Hermes tool result payloads may contain nested JSON strings, so the outer +/// `jsonDecode` leaves literal two-character `\\n` sequences in the message +/// content. Markdown needs real newline characters to render paragraphs, lists, +/// and headings correctly. +String normaliseDisplayText(String text) { + if (!text.contains(r'\n') && !text.contains(r'\r')) return text; + return text + .replaceAll(r'\r\n', '\n') + .replaceAll(r'\n', '\n') + .replaceAll(r'\r', '\n'); +} + +/// True when a stored message is a tool result rather than user-visible chat. +bool isToolResultMessage(Map message) { + final role = (message['role']?.toString() ?? '').trim().toLowerCase(); + if (role == 'tool' || + role == 'tool_result' || + role == 'tool-result' || + role == 'function' || + role.contains('tool')) { + return true; + } + + return looksLikeToolResultText(messageContentToText(message['content'])); +} + +/// True when message text contains Hermes' raw tool-result wrapper. +bool looksLikeToolResultText(String text) { + final trimmed = text.trimLeft(); + return trimmed.startsWith(']*>[\s\S]*?', + multiLine: true, + ), + '', + ) + .trim(); + + if (looksLikeToolResultText(stripped)) return ''; + return stripped; } String _contentPartToText(dynamic part) { if (part == null) return ''; - if (part is String) return part; + if (part is String) return normaliseDisplayText(part); if (part is! Map) return part.toString(); final text = part['text']; - if (text is String && text.isNotEmpty) return text; + if (text is String && text.isNotEmpty) return normaliseDisplayText(text); final type = part['type']?.toString() ?? 'unknown'; if (type.contains('image') || part.containsKey('image_url')) { diff --git a/lib/main.dart b/lib/main.dart index 7b5c33c..aa57b2a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -199,10 +199,17 @@ class _HomeScreenState extends State { ); } - void _showAddDialog() { + void _showAddDialog() => _showConnectionDialog(); + + void _showEditConnectionDialog(SavedConnection conn) { + _showConnectionDialog(existing: conn); + } + + void _showConnectionDialog({SavedConnection? existing}) { showDialog( context: context, builder: (_) => _AddDialog( + initialConnection: existing, onSave: ( label, @@ -216,18 +223,34 @@ class _HomeScreenState extends State { dashboardUsername, dashboardPassword, }) { - widget.connManager.saveConnection( - label, - host, - port, - apiKey, - gatewayPrefix: gatewayPrefix, - dashboardPrefix: dashboardPrefix, - dashboardProxied: dashboardProxied, - dashboardPort: dashboardPort, - dashboardUsername: dashboardUsername, - dashboardPassword: dashboardPassword, - ); + if (existing == null) { + widget.connManager.saveConnection( + label, + host, + port, + apiKey, + gatewayPrefix: gatewayPrefix, + dashboardPrefix: dashboardPrefix, + dashboardProxied: dashboardProxied, + dashboardPort: dashboardPort, + dashboardUsername: dashboardUsername, + dashboardPassword: dashboardPassword, + ); + } else { + widget.connManager.updateConnection( + existing.id, + label, + host, + port, + apiKey, + gatewayPrefix: gatewayPrefix, + dashboardPrefix: dashboardPrefix, + dashboardProxied: dashboardProxied, + dashboardPort: dashboardPort, + dashboardUsername: dashboardUsername, + dashboardPassword: dashboardPassword, + ); + } _refresh(); }, ), @@ -605,6 +628,8 @@ class _HomeScreenState extends State { if (v == 'delete') { widget.connManager.deleteConnection(conn.id); _refresh(); + } else if (v == 'edit') { + _showEditConnectionDialog(conn); } else if (v == 'apikey') { _showApiKeyDialog(conn); } else if (v == 'dashboard') { @@ -612,6 +637,7 @@ class _HomeScreenState extends State { } }, itemBuilder: (_) => [ + const PopupMenuItem(value: 'edit', child: Text('Edit Connection')), const PopupMenuItem(value: 'apikey', child: Text('Update API Key')), const PopupMenuItem( value: 'dashboard', @@ -696,6 +722,7 @@ class _HomeScreenState extends State { } class _AddDialog extends StatefulWidget { + final SavedConnection? initialConnection; final void Function( String label, String host, @@ -709,27 +736,60 @@ class _AddDialog extends StatefulWidget { String? dashboardPassword, }) onSave; - const _AddDialog({required this.onSave}); + const _AddDialog({required this.onSave, this.initialConnection}); @override State<_AddDialog> createState() => _AddDialogState(); } class _AddDialogState extends State<_AddDialog> { - final _label = TextEditingController(text: 'Home'); - final _host = TextEditingController(); - final _port = TextEditingController(text: '8642'); - final _apiKey = TextEditingController(); - final _gatewayPrefix = TextEditingController(); - final _dashboardPrefix = TextEditingController(); - final _dashPort = TextEditingController(); - final _dashUser = TextEditingController(); - final _dashPass = TextEditingController(); - bool _showDashboard = false; - bool _dashboardProxied = false; + late final TextEditingController _label; + late final TextEditingController _host; + late final TextEditingController _port; + late final TextEditingController _apiKey; + late final TextEditingController _gatewayPrefix; + late final TextEditingController _dashboardPrefix; + late final TextEditingController _dashPort; + late final TextEditingController _dashUser; + late final TextEditingController _dashPass; + late bool _showDashboard; + late bool _dashboardProxied; bool _validating = false; String? _error; + bool get _isEditing => widget.initialConnection != null; + + @override + void initState() { + super.initState(); + final conn = widget.initialConnection; + _label = TextEditingController(text: conn?.label ?? 'Home'); + _host = TextEditingController( + text: conn == null + ? '' + : conn.useHttps + ? 'https://${conn.host}' + : conn.host, + ); + _port = TextEditingController(text: (conn?.port ?? 8642).toString()); + _apiKey = TextEditingController(text: conn?.apiKey ?? ''); + _gatewayPrefix = TextEditingController(text: conn?.gatewayPrefix ?? ''); + _dashboardPrefix = TextEditingController(text: conn?.dashboardPrefix ?? ''); + _dashPort = TextEditingController( + text: conn?.dashboardPortOverride?.toString() ?? '', + ); + _dashUser = TextEditingController(text: conn?.dashboardUsername ?? ''); + _dashPass = TextEditingController(text: conn?.dashboardPassword ?? ''); + _dashboardProxied = conn?.dashboardProxied ?? false; + _showDashboard = + conn?.gatewayPrefix?.isNotEmpty == true || + conn?.dashboardPrefix?.isNotEmpty == true || + conn?.dashboardPortOverride != null || + conn?.dashboardUsername?.isNotEmpty == true || + conn?.dashboardPassword?.isNotEmpty == true || + _dashboardProxied; + } + Future _validateAndSave() async { final label = _label.text.trim(); final host = _host.text.trim(); @@ -848,7 +908,9 @@ class _AddDialogState extends State<_AddDialog> { @override Widget build(BuildContext context) { return AlertDialog( - title: const Text('Add Gateway Connection'), + title: Text( + _isEditing ? 'Edit Gateway Connection' : 'Add Gateway Connection', + ), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, @@ -1019,7 +1081,7 @@ class _AddDialogState extends State<_AddDialog> { color: Colors.white, ), ) - : const Text('Connect'), + : Text(_isEditing ? 'Save Changes' : 'Connect'), ), ], ); diff --git a/test/connection_manager_test.dart b/test/connection_manager_test.dart index 800d749..31f13bc 100644 --- a/test/connection_manager_test.dart +++ b/test/connection_manager_test.dart @@ -583,6 +583,54 @@ void main() { expect(conn.dashboardUsername, 'misha'); expect(conn.dashboardPassword, 'secret'); }); + + test( + 'updateConnection edits host, port, key, and clears optional fields', + () async { + final prefs = await SharedPreferences.getInstance(); + final mgr = ConnectionManager(prefs); + mgr.saveConnection( + 'Home', + '192.168.1.50', + 8642, + 'key', + gatewayPrefix: '/old-gateway', + dashboardPrefix: '/old-dashboard', + dashboardProxied: true, + dashboardPort: 30433, + dashboardUsername: 'misha', + dashboardPassword: 'secret', + ); + final id = mgr.getConnections().single.id; + + mgr.updateConnection( + id, + 'Moved', + 'https://hermes.example.com', + 8642, + 'new-key', + gatewayPrefix: '', + dashboardPrefix: '', + dashboardProxied: false, + dashboardUsername: '', + dashboardPassword: '', + ); + + final conn = mgr.getConnections().single; + expect(conn.id, id); + expect(conn.label, 'Moved'); + expect(conn.host, 'hermes.example.com'); + expect(conn.port, 443); + expect(conn.useHttps, isTrue); + expect(conn.apiKey, 'new-key'); + expect(conn.gatewayPrefix, isNull); + expect(conn.dashboardPrefix, isNull); + expect(conn.dashboardProxied, isFalse); + expect(conn.dashboardPortOverride, isNull); + expect(conn.dashboardUsername, isNull); + expect(conn.dashboardPassword, isNull); + }, + ); }); group('Path prefix support', () { diff --git a/test/message_content_test.dart b/test/message_content_test.dart index 8acc7b4..8d9e0a4 100644 --- a/test/message_content_test.dart +++ b/test/message_content_test.dart @@ -39,5 +39,29 @@ void main() { '[Unsupported content: custom_part]', ); }); + + test('normalises JSON-escaped newlines in string content', () { + expect( + messageContentToText(r'first\nsecond'), + 'first\nsecond'.replaceAll(r'\n', '\n'), + ); + }); + + test('detects and strips raw Hermes tool result wrappers', () { + final toolMessage = { + 'role': 'assistant', + 'content': + 'raw', + }; + + expect(isToolResultMessage(toolMessage), isTrue); + expect(stripToolResultText(toolMessage['content']!), ''); + expect( + stripToolResultText( + 'Here is the answer.\nraw\nDone.', + ), + 'Here is the answer.\n\nDone.', + ); + }); }); }