neoagent 2.4.4-beta.7 → 2.4.4-beta.9

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/.env.example CHANGED
@@ -42,6 +42,11 @@ NEOAGENT_PROFILE=prod
42
42
  # can always register regardless of this setting).
43
43
  # NEOAGENT_ALLOW_SIGNUP=false
44
44
 
45
+ # Rolling per-user AI token limits. Users can have custom overrides in the
46
+ # admin dashboard.
47
+ NEOAGENT_RATE_LIMIT_4H=2500000
48
+ NEOAGENT_RATE_LIMIT_WEEKLY=10000000
49
+
45
50
  # VM runtime settings used by `prod`.
46
51
  # Set these before switching NEOAGENT_PROFILE=prod.
47
52
  # The app can cache a shared base image automatically from a URL, then create
@@ -80,6 +85,18 @@ NEOAGENT_EMAIL_SMTP_HOST=
80
85
  NEOAGENT_EMAIL_SMTP_PORT=587
81
86
  NEOAGENT_EMAIL_SMTP_USER=
82
87
  NEOAGENT_EMAIL_SMTP_PASS=
88
+ NEOAGENT_EMAIL_SMTP_SECURE=false
89
+ NEOAGENT_EMAIL_SMTP_REQUIRE_TLS=true
90
+ NEOAGENT_EMAIL_SMTP_REJECT_UNAUTHORIZED=true
91
+ NEOAGENT_EMAIL_REPLY_TO=
92
+ NEOAGENT_EMAIL_REQUIRE_SIGNUP_CONFIRMATION=true
93
+ NEOAGENT_EMAIL_REQUIRE_EMAIL_CHANGE_CONFIRMATION=true
94
+ NEOAGENT_EMAIL_NOTIFY_UNUSUAL_LOGIN=true
95
+ NEOAGENT_EMAIL_NOTIFY_ACCOUNT_CHANGES=true
96
+ NEOAGENT_EMAIL_BRAND_NAME=NeoAgent
97
+ NEOAGENT_EMAIL_SUPPORT_URL=
98
+ NEOAGENT_EMAIL_PUBLIC_URL=
99
+ NEOAGENT_EMAIL_TOKEN_TTL_HOURS=24
83
100
 
84
101
  ########################################
85
102
  # Hosted AI providers
@@ -117,6 +117,7 @@ Optional. When configured, NeoAgent uses SMTP for account flows: signup confirma
117
117
  | `NEOAGENT_EMAIL_NOTIFY_ACCOUNT_CHANGES` | `true` | Notices for password and email changes |
118
118
  | `NEOAGENT_EMAIL_BRAND_NAME` | `NeoAgent` | Display name in email templates |
119
119
  | `NEOAGENT_EMAIL_SUPPORT_URL` | optional | Support link for email templates |
120
+ | `NEOAGENT_EMAIL_PUBLIC_URL` | `PUBLIC_URL` | Public base URL used in confirmation and reset links |
120
121
  | `NEOAGENT_EMAIL_TOKEN_TTL_HOURS` | `24` | Confirmation link expiry |
121
122
 
122
123
  ## Runtime Isolation
@@ -279,7 +279,7 @@ Supermemory can still be used as a benchmark comparison provider, not as NeoAgen
279
279
  5. Temporal fact columns.
280
280
  6. Integration ingestion jobs and freshness policies.
281
281
  7. Conversation summaries and working state.
282
- 8. Manual core memory with confirmation.
282
+ 8. Manual core memory management.
283
283
  9. UI and API support for memory inspection.
284
284
 
285
285
  ### Critical Gaps Before This Review
@@ -403,7 +403,8 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
403
403
  controller: _displayNameController,
404
404
  decoration: const InputDecoration(
405
405
  labelText: 'Display name',
406
- helperText: 'Shown in the sidebar. Leave blank to use your username.',
406
+ helperText:
407
+ 'Shown in the sidebar. Leave blank to use your username.',
407
408
  ),
408
409
  ),
409
410
  if (_displayNameInlineError != null) ...<Widget>[
@@ -607,6 +608,16 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
607
608
  return amount.toString();
608
609
  }
609
610
 
611
+ String? _nextUsageDropLabel(DateTime? value) {
612
+ if (value == null) return null;
613
+ final remaining = value.difference(DateTime.now());
614
+ if (remaining.isNegative) return 'Usage updates shortly';
615
+ if (remaining.inHours > 0) {
616
+ return 'Next usage drop in ${remaining.inHours}h ${remaining.inMinutes.remainder(60)}m';
617
+ }
618
+ return 'Next usage drop in ${remaining.inMinutes + 1}m';
619
+ }
620
+
610
621
  Widget _buildUsagePanel() {
611
622
  if (widget.controller.isLoadingAccountSettings) {
612
623
  return const Center(
@@ -616,7 +627,7 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
616
627
  ),
617
628
  );
618
629
  }
619
-
630
+
620
631
  final usage = widget.controller.usageAndLimits;
621
632
  if (usage == null) {
622
633
  return Center(
@@ -639,12 +650,21 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
639
650
  );
640
651
  }
641
652
 
642
- Widget buildStatBox(String label, int current, int? limit, {bool isCustom = false}) {
653
+ Widget buildStatBox(
654
+ String label,
655
+ int current,
656
+ int? limit, {
657
+ required int remaining,
658
+ required bool reached,
659
+ required DateTime? nextDecreaseAt,
660
+ bool isCustom = false,
661
+ }) {
643
662
  final double progress = limit != null
644
663
  ? (limit <= 0 ? 1.0 : (current / limit).clamp(0.0, 1.0))
645
664
  : 0.0;
646
665
  final bool nearLimit = progress > 0.8;
647
- final bool atLimit = progress >= 1.0;
666
+ final bool atLimit = reached || progress >= 1.0;
667
+ final nextDrop = _nextUsageDropLabel(nextDecreaseAt);
648
668
 
649
669
  return Container(
650
670
  width: double.infinity,
@@ -656,8 +676,8 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
656
676
  color: atLimit
657
677
  ? _danger.withValues(alpha: 0.6)
658
678
  : nearLimit
659
- ? _warning.withValues(alpha: 0.5)
660
- : _borderLight,
679
+ ? _warning.withValues(alpha: 0.5)
680
+ : _borderLight,
661
681
  ),
662
682
  ),
663
683
  child: Column(
@@ -665,11 +685,20 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
665
685
  children: <Widget>[
666
686
  Row(
667
687
  children: <Widget>[
668
- Text(label, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
688
+ Text(
689
+ label,
690
+ style: const TextStyle(
691
+ fontSize: 16,
692
+ fontWeight: FontWeight.w600,
693
+ ),
694
+ ),
669
695
  const Spacer(),
670
696
  if (limit != null)
671
697
  Container(
672
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
698
+ padding: const EdgeInsets.symmetric(
699
+ horizontal: 8,
700
+ vertical: 3,
701
+ ),
673
702
  decoration: BoxDecoration(
674
703
  color: isCustom
675
704
  ? _warning.withValues(alpha: 0.12)
@@ -696,7 +725,11 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
696
725
  style: TextStyle(
697
726
  fontSize: 28,
698
727
  fontWeight: FontWeight.w800,
699
- color: atLimit ? _danger : nearLimit ? _warning : null,
728
+ color: atLimit
729
+ ? _danger
730
+ : nearLimit
731
+ ? _warning
732
+ : null,
700
733
  ),
701
734
  ),
702
735
  Padding(
@@ -728,16 +761,27 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
728
761
  value: progress,
729
762
  backgroundColor: _border,
730
763
  valueColor: AlwaysStoppedAnimation<Color>(
731
- atLimit ? _danger : nearLimit ? _warning : _accent,
764
+ atLimit
765
+ ? _danger
766
+ : nearLimit
767
+ ? _warning
768
+ : _accent,
732
769
  ),
733
770
  ),
734
771
  ),
735
772
  const SizedBox(height: 6),
736
773
  Text(
737
- '${(progress * 100).toStringAsFixed(0)}% used',
774
+ atLimit
775
+ ? 'Limit reached${nextDrop == null ? '' : ' · $nextDrop'}'
776
+ : '${(progress * 100).toStringAsFixed(0)}% used · ${_formatTokens(remaining)} remaining'
777
+ '${nextDrop == null ? '' : ' · $nextDrop'}',
738
778
  style: TextStyle(
739
779
  fontSize: 11,
740
- color: atLimit ? _danger : nearLimit ? _warning : _textMuted,
780
+ color: atLimit
781
+ ? _danger
782
+ : nearLimit
783
+ ? _warning
784
+ : _textMuted,
741
785
  ),
742
786
  ),
743
787
  ],
@@ -756,9 +800,25 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
756
800
  style: TextStyle(color: _textSecondary, height: 1.4),
757
801
  ),
758
802
  const SizedBox(height: 24),
759
- buildStatBox('Recent Usage (4 Hours)', usage.fourHourUsage, usage.fourHourLimit, isCustom: usage.fourHourIsCustom),
803
+ buildStatBox(
804
+ 'Recent Usage (4 Hours)',
805
+ usage.fourHourUsage,
806
+ usage.fourHourLimit,
807
+ remaining: usage.fourHourRemaining,
808
+ reached: usage.fourHourReached,
809
+ nextDecreaseAt: usage.fourHourNextDecreaseAt,
810
+ isCustom: usage.fourHourIsCustom,
811
+ ),
760
812
  const SizedBox(height: 16),
761
- buildStatBox('Weekly Usage', usage.weeklyUsage, usage.weeklyLimit, isCustom: usage.weeklyIsCustom),
813
+ buildStatBox(
814
+ 'Weekly Usage',
815
+ usage.weeklyUsage,
816
+ usage.weeklyLimit,
817
+ remaining: usage.weeklyRemaining,
818
+ reached: usage.weeklyReached,
819
+ nextDecreaseAt: usage.weeklyNextDecreaseAt,
820
+ isCustom: usage.weeklyIsCustom,
821
+ ),
762
822
  ],
763
823
  );
764
824
  }
@@ -1128,7 +1188,11 @@ class _AccountSettingsTabs extends StatelessWidget {
1128
1188
  Widget build(BuildContext context) {
1129
1189
  final buttons = <Widget>[
1130
1190
  _tabButton(AccountSettingsTab.account, Icons.person_outline, 'Account'),
1131
- _tabButton(AccountSettingsTab.usage, Icons.data_usage_outlined, 'Usage & Limits'),
1191
+ _tabButton(
1192
+ AccountSettingsTab.usage,
1193
+ Icons.data_usage_outlined,
1194
+ 'Usage & Limits',
1195
+ ),
1132
1196
  _tabButton(
1133
1197
  AccountSettingsTab.security,
1134
1198
  Icons.security_outlined,
@@ -295,12 +295,13 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
295
295
  transitionBuilder: (ctx, animation, secondary, child) => FadeTransition(
296
296
  opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
297
297
  child: SlideTransition(
298
- position: Tween<Offset>(
299
- begin: const Offset(0, 0.04),
300
- end: Offset.zero,
301
- ).animate(
302
- CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
303
- ),
298
+ position:
299
+ Tween<Offset>(
300
+ begin: const Offset(0, 0.04),
301
+ end: Offset.zero,
302
+ ).animate(
303
+ CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
304
+ ),
304
305
  child: child,
305
306
  ),
306
307
  ),
@@ -398,6 +399,11 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
398
399
  _maybeFollowChatContent(messages, controller);
399
400
 
400
401
  final threadChildren = <Widget>[
402
+ if (controller.usageAndLimits case final usage?
403
+ when usage.hasLimits) ...<Widget>[
404
+ _RateLimitStatusCard(usage: usage),
405
+ const SizedBox(height: 16),
406
+ ],
401
407
  if (controller.errorMessage != null) ...<Widget>[
402
408
  _InlineError(message: controller.errorMessage!),
403
409
  const SizedBox(height: 16),
@@ -471,8 +477,12 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
471
477
  SelectionArea(
472
478
  child: ListView(
473
479
  controller: _scrollController,
474
- padding:
475
- EdgeInsets.fromLTRB(sidePadding, 30, sidePadding, 18),
480
+ padding: EdgeInsets.fromLTRB(
481
+ sidePadding,
482
+ 30,
483
+ sidePadding,
484
+ 18,
485
+ ),
476
486
  children: <Widget>[
477
487
  Center(
478
488
  child: ConstrainedBox(
@@ -715,6 +725,143 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
715
725
  }
716
726
  }
717
727
 
728
+ class _RateLimitStatusCard extends StatelessWidget {
729
+ const _RateLimitStatusCard({required this.usage});
730
+
731
+ final AccountUsageAndLimits usage;
732
+
733
+ String _formatTokens(int amount) {
734
+ if (amount >= 1000000) {
735
+ final value = amount / 1000000;
736
+ return '${value.toStringAsFixed(value == value.roundToDouble() ? 0 : 1)}M';
737
+ }
738
+ if (amount >= 1000) {
739
+ final value = amount / 1000;
740
+ return '${value.toStringAsFixed(value == value.roundToDouble() ? 0 : 1)}k';
741
+ }
742
+ return amount.toString();
743
+ }
744
+
745
+ String? _nextDropLabel(DateTime? value) {
746
+ if (value == null) return null;
747
+ final remaining = value.difference(DateTime.now());
748
+ if (remaining.isNegative) return 'Usage updates shortly';
749
+ if (remaining.inHours > 0) {
750
+ return 'Next usage drop in ${remaining.inHours}h ${remaining.inMinutes.remainder(60)}m';
751
+ }
752
+ return 'Next usage drop in ${remaining.inMinutes + 1}m';
753
+ }
754
+
755
+ Widget _buildWindow({
756
+ required String label,
757
+ required int usageAmount,
758
+ required int? limit,
759
+ required int remaining,
760
+ required bool reached,
761
+ required DateTime? nextDecreaseAt,
762
+ }) {
763
+ if (limit == null || limit <= 0) return const SizedBox.shrink();
764
+ final progress = (usageAmount / limit).clamp(0.0, 1.0);
765
+ final color = reached
766
+ ? _danger
767
+ : progress >= 0.8
768
+ ? _warning
769
+ : _accent;
770
+ final nextDrop = _nextDropLabel(nextDecreaseAt);
771
+ return Column(
772
+ crossAxisAlignment: CrossAxisAlignment.start,
773
+ children: <Widget>[
774
+ Row(
775
+ children: <Widget>[
776
+ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
777
+ const Spacer(),
778
+ Text(
779
+ reached ? 'Limit reached' : '${_formatTokens(remaining)} left',
780
+ style: TextStyle(
781
+ color: color,
782
+ fontSize: 12,
783
+ fontWeight: FontWeight.w600,
784
+ ),
785
+ ),
786
+ ],
787
+ ),
788
+ const SizedBox(height: 8),
789
+ ClipRRect(
790
+ borderRadius: BorderRadius.circular(999),
791
+ child: LinearProgressIndicator(
792
+ minHeight: 7,
793
+ value: progress,
794
+ backgroundColor: _border,
795
+ valueColor: AlwaysStoppedAnimation<Color>(color),
796
+ ),
797
+ ),
798
+ const SizedBox(height: 6),
799
+ Text(
800
+ '${_formatTokens(usageAmount)} / ${_formatTokens(limit)} tokens'
801
+ '${nextDrop == null ? '' : ' · $nextDrop'}',
802
+ style: TextStyle(color: _textMuted, fontSize: 11),
803
+ ),
804
+ ],
805
+ );
806
+ }
807
+
808
+ @override
809
+ Widget build(BuildContext context) {
810
+ final windows = <Widget>[
811
+ _buildWindow(
812
+ label: '4-hour usage',
813
+ usageAmount: usage.fourHourUsage,
814
+ limit: usage.fourHourLimit,
815
+ remaining: usage.fourHourRemaining,
816
+ reached: usage.fourHourReached,
817
+ nextDecreaseAt: usage.fourHourNextDecreaseAt,
818
+ ),
819
+ _buildWindow(
820
+ label: '7-day usage',
821
+ usageAmount: usage.weeklyUsage,
822
+ limit: usage.weeklyLimit,
823
+ remaining: usage.weeklyRemaining,
824
+ reached: usage.weeklyReached,
825
+ nextDecreaseAt: usage.weeklyNextDecreaseAt,
826
+ ),
827
+ ];
828
+ return Container(
829
+ width: double.infinity,
830
+ padding: const EdgeInsets.all(14),
831
+ decoration: BoxDecoration(
832
+ color: usage.isReached ? _danger.withValues(alpha: 0.08) : _bgSecondary,
833
+ borderRadius: BorderRadius.circular(16),
834
+ border: Border.all(
835
+ color: usage.isReached
836
+ ? _danger.withValues(alpha: 0.55)
837
+ : _borderLight,
838
+ ),
839
+ ),
840
+ child: LayoutBuilder(
841
+ builder: (context, constraints) {
842
+ if (constraints.maxWidth < 620) {
843
+ return Column(
844
+ children: <Widget>[
845
+ windows.first,
846
+ const SizedBox(height: 14),
847
+ windows.last,
848
+ ],
849
+ );
850
+ }
851
+ return Row(
852
+ crossAxisAlignment: CrossAxisAlignment.start,
853
+ children: <Widget>[
854
+ Expanded(child: windows.first),
855
+ const SizedBox(width: 24),
856
+ Expanded(child: windows.last),
857
+ ],
858
+ );
859
+ },
860
+ ),
861
+ );
862
+ }
863
+ }
864
+
718
865
  double _chatSidePadding(BuildContext context) {
719
866
  final width = MediaQuery.sizeOf(context).width;
720
867
  if (width >= 1280) return 40;
@@ -2033,6 +2033,11 @@ class NeoAgentController extends ChangeNotifier {
2033
2033
  _backendClient.fetchTokenUsageSummary(backendUrl, agentId: agentId),
2034
2034
  const <String, dynamic>{},
2035
2035
  );
2036
+ final rateLimitFuture = _softRefreshLoad<Map<String, dynamic>>(
2037
+ 'rate_limits',
2038
+ _backendClient.fetchAccountUsage(backendUrl),
2039
+ const <String, dynamic>{},
2040
+ );
2036
2041
  final updateFuture = _backendClient
2037
2042
  .fetchUpdateStatus(backendUrl)
2038
2043
  .catchError((_) => const <String, dynamic>{});
@@ -2142,6 +2147,7 @@ class NeoAgentController extends ChangeNotifier {
2142
2147
  final runsResponse = await runsFuture;
2143
2148
  final versionResponse = await versionFuture;
2144
2149
  final tokenResponse = await tokenFuture;
2150
+ final rateLimitResponse = await rateLimitFuture;
2145
2151
  final updateResponse = await updateFuture;
2146
2152
  final messagingResponse = await messagingFuture;
2147
2153
  final messagingMessagesResponse = await messagingMessagesFuture;
@@ -2193,6 +2199,7 @@ class NeoAgentController extends ChangeNotifier {
2193
2199
  versionInfo = versionResponse;
2194
2200
  backendHealthStatus = healthResponse;
2195
2201
  tokenUsage = TokenUsageSnapshot.fromJson(tokenResponse);
2202
+ usageAndLimits = AccountUsageAndLimits.fromJson(rateLimitResponse);
2196
2203
  updateStatus = UpdateStatusSnapshot.fromJson(updateResponse);
2197
2204
  messagingStatuses = messagingResponse.map(
2198
2205
  (key, value) => MapEntry(
@@ -4640,20 +4647,24 @@ class NeoAgentController extends ChangeNotifier {
4640
4647
  }
4641
4648
  activeRun = null;
4642
4649
  await refreshRunsOnly();
4650
+ await refreshRateLimitUsage();
4643
4651
  } catch (error) {
4652
+ final friendlyError = _friendlyErrorMessage(error);
4644
4653
  chatMessages = <ChatEntry>[
4645
4654
  ...chatMessages,
4646
4655
  ChatEntry(
4647
4656
  id: '',
4648
4657
  role: 'assistant',
4649
- content:
4650
- 'I could not complete that request right now. Please try again in a moment.',
4658
+ content: friendlyError,
4651
4659
  platform: 'flutter',
4652
4660
  createdAt: DateTime.now(),
4653
4661
  ),
4654
4662
  ];
4655
4663
  activeRun = null;
4656
- errorMessage = _friendlyErrorMessage(error);
4664
+ errorMessage = friendlyError;
4665
+ if (error is BackendException && error.statusCode == 429) {
4666
+ await refreshRateLimitUsage();
4667
+ }
4657
4668
  } finally {
4658
4669
  if (_socket == null || !socketConnected) {
4659
4670
  isSendingMessage = false;
@@ -4786,6 +4797,16 @@ class NeoAgentController extends ChangeNotifier {
4786
4797
  }
4787
4798
  }
4788
4799
 
4800
+ Future<void> refreshRateLimitUsage() async {
4801
+ if (!isAuthenticated) return;
4802
+ try {
4803
+ usageAndLimits = AccountUsageAndLimits.fromJson(
4804
+ await _backendClient.fetchAccountUsage(backendUrl),
4805
+ );
4806
+ notifyListeners();
4807
+ } catch (_) {}
4808
+ }
4809
+
4789
4810
  Future<bool> updateAccountEmail({
4790
4811
  required String email,
4791
4812
  required String currentPassword,
@@ -7292,11 +7313,13 @@ class NeoAgentController extends ChangeNotifier {
7292
7313
  final payload = _jsonMap(data);
7293
7314
  final runId = payload['runId']?.toString() ?? '';
7294
7315
  if (_voiceRunIds.remove(runId)) {
7316
+ unawaited(refreshRateLimitUsage());
7295
7317
  return;
7296
7318
  }
7297
7319
  if (_backgroundRunIds.remove(runId)) {
7298
7320
  unawaited(refreshRunsOnly());
7299
7321
  unawaited(refreshMemory());
7322
+ unawaited(refreshRateLimitUsage());
7300
7323
  notifyListeners();
7301
7324
  return;
7302
7325
  }
@@ -7320,6 +7343,7 @@ class NeoAgentController extends ChangeNotifier {
7320
7343
  );
7321
7344
  }
7322
7345
  unawaited(refreshRunsOnly());
7346
+ unawaited(refreshRateLimitUsage());
7323
7347
  notifyListeners();
7324
7348
  });
7325
7349
  socket.on('run:stopped', (dynamic data) {
@@ -7370,8 +7394,18 @@ class NeoAgentController extends ChangeNotifier {
7370
7394
  streamingAssistant = '';
7371
7395
  activeRun = null;
7372
7396
  isSendingMessage = false;
7373
- errorMessage =
7397
+ final message =
7398
+ payload['error']?.toString().trim() ??
7374
7399
  'I could not complete that request right now. Please try again in a moment.';
7400
+ errorMessage = _friendlyErrorMessage(
7401
+ BackendException(
7402
+ message,
7403
+ statusCode: payload['code'] == 'RATE_LIMIT_EXCEEDED' ? 429 : null,
7404
+ ),
7405
+ );
7406
+ if (payload['code'] == 'RATE_LIMIT_EXCEEDED') {
7407
+ unawaited(refreshRateLimitUsage());
7408
+ }
7375
7409
  notifyListeners();
7376
7410
  });
7377
7411
  socket.on('tasks:task_complete', (dynamic _) {
@@ -1734,6 +1734,14 @@ class _InteractiveSurfacePreviewState
1734
1734
  Widget build(BuildContext context) {
1735
1735
  final path = widget.screenshotPath;
1736
1736
  final hasImage = path != null && path.isNotEmpty;
1737
+ final viewportHeight = MediaQuery.sizeOf(context).height;
1738
+ final surfaceHeightCap = widget.surface == _DeviceSurface.android
1739
+ ? 640.0
1740
+ : 560.0;
1741
+ final previewMaxHeight = math.min(
1742
+ surfaceHeightCap,
1743
+ viewportHeight * 0.48,
1744
+ );
1737
1745
  final aspectRatio = switch (widget.surface) {
1738
1746
  _DeviceSurface.browser => 16 / 10,
1739
1747
  _DeviceSurface.android => 10 / 16,
@@ -1755,9 +1763,7 @@ class _InteractiveSurfacePreviewState
1755
1763
  child: Column(
1756
1764
  children: <Widget>[
1757
1765
  ConstrainedBox(
1758
- constraints: BoxConstraints(
1759
- maxHeight: widget.surface == _DeviceSurface.android ? 640 : 560,
1760
- ),
1766
+ constraints: BoxConstraints(maxHeight: previewMaxHeight),
1761
1767
  child: AspectRatio(
1762
1768
  aspectRatio: aspectRatio,
1763
1769
  child: ClipRRect(
@@ -4111,6 +4111,12 @@ class AccountUsageAndLimits {
4111
4111
  this.weeklyLimit,
4112
4112
  this.fourHourUsage = 0,
4113
4113
  this.weeklyUsage = 0,
4114
+ this.fourHourRemaining = 0,
4115
+ this.weeklyRemaining = 0,
4116
+ this.fourHourReached = false,
4117
+ this.weeklyReached = false,
4118
+ this.fourHourNextDecreaseAt,
4119
+ this.weeklyNextDecreaseAt,
4114
4120
  this.fourHourIsCustom = false,
4115
4121
  this.weeklyIsCustom = false,
4116
4122
  });
@@ -4118,11 +4124,28 @@ class AccountUsageAndLimits {
4118
4124
  factory AccountUsageAndLimits.fromJson(Map<String, dynamic> json) {
4119
4125
  final limits = json['limits'] is Map ? json['limits'] as Map : const {};
4120
4126
  final usage = json['usage'] is Map ? json['usage'] as Map : const {};
4127
+ final remaining = json['remaining'] is Map
4128
+ ? json['remaining'] as Map
4129
+ : const {};
4130
+ final reached = json['reached'] is Map ? json['reached'] as Map : const {};
4131
+ final nextDecreaseAt = json['nextDecreaseAt'] is Map
4132
+ ? json['nextDecreaseAt'] as Map
4133
+ : const {};
4121
4134
  return AccountUsageAndLimits(
4122
4135
  fourHourLimit: int.tryParse(limits['fourHour']?.toString() ?? ''),
4123
4136
  weeklyLimit: int.tryParse(limits['weekly']?.toString() ?? ''),
4124
4137
  fourHourUsage: _asInt(usage['fourHour']),
4125
4138
  weeklyUsage: _asInt(usage['weekly']),
4139
+ fourHourRemaining: _asInt(remaining['fourHour']),
4140
+ weeklyRemaining: _asInt(remaining['weekly']),
4141
+ fourHourReached: reached['fourHour'] == true,
4142
+ weeklyReached: reached['weekly'] == true,
4143
+ fourHourNextDecreaseAt: DateTime.tryParse(
4144
+ nextDecreaseAt['fourHour']?.toString() ?? '',
4145
+ ),
4146
+ weeklyNextDecreaseAt: DateTime.tryParse(
4147
+ nextDecreaseAt['weekly']?.toString() ?? '',
4148
+ ),
4126
4149
  fourHourIsCustom: limits['fourHourIsCustom'] == true,
4127
4150
  weeklyIsCustom: limits['weeklyIsCustom'] == true,
4128
4151
  );
@@ -4132,6 +4155,15 @@ class AccountUsageAndLimits {
4132
4155
  final int? weeklyLimit;
4133
4156
  final int fourHourUsage;
4134
4157
  final int weeklyUsage;
4158
+ final int fourHourRemaining;
4159
+ final int weeklyRemaining;
4160
+ final bool fourHourReached;
4161
+ final bool weeklyReached;
4162
+ final DateTime? fourHourNextDecreaseAt;
4163
+ final DateTime? weeklyNextDecreaseAt;
4135
4164
  final bool fourHourIsCustom;
4136
4165
  final bool weeklyIsCustom;
4166
+
4167
+ bool get hasLimits => fourHourLimit != null || weeklyLimit != null;
4168
+ bool get isReached => fourHourReached || weeklyReached;
4137
4169
  }
@@ -1513,42 +1513,37 @@ class _SettingsPanelState extends State<SettingsPanel> {
1513
1513
 
1514
1514
  Widget _buildSecuritySection(BuildContext context, NeoAgentController controller) {
1515
1515
  return Card(
1516
- elevation: 0,
1517
- shape: RoundedRectangleBorder(
1518
- borderRadius: BorderRadius.circular(12),
1519
- side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
1520
- ),
1521
- child: Column(
1522
- crossAxisAlignment: CrossAxisAlignment.start,
1523
- children: <Widget>[
1524
- ListTile(
1525
- leading: const Icon(Icons.shield_outlined),
1526
- title: const Text('Security', style: TextStyle(fontWeight: FontWeight.bold)),
1527
- subtitle: const Text('Tool allowlists, approval gates, and process isolation.'),
1528
- trailing: const SizedBox.shrink(),
1529
- ),
1530
- const Divider(height: 1),
1531
- ListTile(
1532
- leading: const Icon(Icons.checklist_outlined),
1533
- title: const Text('Tool Permissions'),
1534
- subtitle: const Text('Set block / ask / allow per tool category, or pick a global mode.'),
1535
- trailing: Row(
1536
- mainAxisSize: MainAxisSize.min,
1537
- children: const <Widget>[
1538
- Icon(Icons.shield_rounded, size: 16, color: Colors.green),
1539
- SizedBox(width: 4),
1540
- Icon(Icons.chevron_right),
1541
- ],
1516
+ child: Padding(
1517
+ padding: const EdgeInsets.all(20),
1518
+ child: Column(
1519
+ crossAxisAlignment: CrossAxisAlignment.start,
1520
+ children: <Widget>[
1521
+ const _SectionTitle('Security'),
1522
+ const SizedBox(height: 10),
1523
+ Text(
1524
+ 'Per-tool permission policies, approval gates, and process isolation for shell execution.',
1525
+ style: TextStyle(color: _textSecondary, height: 1.45),
1542
1526
  ),
1543
- onTap: () {
1544
- Navigator.of(context).push(
1545
- MaterialPageRoute<void>(
1546
- builder: (_) => MainSecurity(controller: controller),
1547
- ),
1548
- );
1549
- },
1550
- ),
1551
- ],
1527
+ const SizedBox(height: 8),
1528
+ ListTile(
1529
+ contentPadding: EdgeInsets.zero,
1530
+ leading: Icon(Icons.checklist_outlined, color: _accentAlt),
1531
+ title: const Text('Tool Permissions'),
1532
+ subtitle: Text(
1533
+ 'Set block / ask / allow per tool category, or pick a global mode.',
1534
+ style: TextStyle(color: _textSecondary),
1535
+ ),
1536
+ trailing: const Icon(Icons.chevron_right),
1537
+ onTap: () {
1538
+ Navigator.of(context).push(
1539
+ MaterialPageRoute<void>(
1540
+ builder: (_) => MainSecurity(controller: controller),
1541
+ ),
1542
+ );
1543
+ },
1544
+ ),
1545
+ ],
1546
+ ),
1552
1547
  ),
1553
1548
  );
1554
1549
  }