@wlfi-agent/cli 1.4.15 → 1.4.16

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 (80) hide show
  1. package/Cargo.lock +1 -0
  2. package/Cargo.toml +1 -1
  3. package/README.md +10 -2
  4. package/crates/vault-cli-admin/src/main.rs +21 -2
  5. package/crates/vault-cli-admin/src/tui.rs +634 -129
  6. package/crates/vault-cli-daemon/Cargo.toml +1 -0
  7. package/crates/vault-cli-daemon/src/bin/wlfi-agent-system-keychain.rs +122 -8
  8. package/crates/vault-cli-daemon/src/main.rs +24 -4
  9. package/crates/vault-cli-daemon/src/relay_sync.rs +155 -35
  10. package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +23 -18
  11. package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +6 -0
  12. package/crates/vault-daemon/src/daemon_parts/types_api_rpc.rs +6 -0
  13. package/crates/vault-daemon/src/tests.rs +2 -2
  14. package/crates/vault-daemon/src/tests_parts/part4.rs +110 -0
  15. package/crates/vault-transport-unix/src/lib.rs +22 -3
  16. package/crates/vault-transport-xpc/src/lib.rs +20 -2
  17. package/dist/cli.cjs +20842 -25552
  18. package/dist/cli.cjs.map +1 -1
  19. package/package.json +5 -3
  20. package/packages/cache/.turbo/turbo-build.log +20 -20
  21. package/packages/cache/coverage/base.css +224 -0
  22. package/packages/cache/coverage/block-navigation.js +87 -0
  23. package/packages/cache/coverage/clover.xml +585 -0
  24. package/packages/cache/coverage/coverage-final.json +5 -0
  25. package/packages/cache/coverage/favicon.png +0 -0
  26. package/packages/cache/coverage/index.html +161 -0
  27. package/packages/cache/coverage/prettify.css +1 -0
  28. package/packages/cache/coverage/prettify.js +2 -0
  29. package/packages/cache/coverage/sort-arrow-sprite.png +0 -0
  30. package/packages/cache/coverage/sorter.js +210 -0
  31. package/packages/cache/coverage/src/client/index.html +116 -0
  32. package/packages/cache/coverage/src/client/index.ts.html +253 -0
  33. package/packages/cache/coverage/src/errors/index.html +116 -0
  34. package/packages/cache/coverage/src/errors/index.ts.html +244 -0
  35. package/packages/cache/coverage/src/index.html +116 -0
  36. package/packages/cache/coverage/src/index.ts.html +94 -0
  37. package/packages/cache/coverage/src/service/index.html +116 -0
  38. package/packages/cache/coverage/src/service/index.ts.html +2212 -0
  39. package/packages/cache/dist/{chunk-ALQ6H7KG.cjs → chunk-QF4XKEIA.cjs} +189 -45
  40. package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +1 -0
  41. package/packages/cache/dist/{chunk-FGJEEF5N.js → chunk-QNK6GOTI.js} +182 -38
  42. package/packages/cache/dist/chunk-QNK6GOTI.js.map +1 -0
  43. package/packages/cache/dist/index.cjs +2 -2
  44. package/packages/cache/dist/index.js +1 -1
  45. package/packages/cache/dist/service/index.cjs +2 -2
  46. package/packages/cache/dist/service/index.d.cts +2 -0
  47. package/packages/cache/dist/service/index.d.ts +2 -0
  48. package/packages/cache/dist/service/index.js +1 -1
  49. package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  50. package/packages/cache/src/service/index.test.ts +575 -0
  51. package/packages/cache/src/service/index.ts +234 -51
  52. package/packages/config/.turbo/turbo-build.log +17 -18
  53. package/packages/config/node_modules/.bin/tsc +2 -2
  54. package/packages/config/node_modules/.bin/tsserver +2 -2
  55. package/packages/config/node_modules/.bin/tsup +2 -2
  56. package/packages/config/node_modules/.bin/tsup-node +2 -2
  57. package/packages/rpc/.turbo/turbo-build.log +31 -32
  58. package/packages/rpc/node_modules/.bin/tsc +2 -2
  59. package/packages/rpc/node_modules/.bin/tsserver +2 -2
  60. package/packages/rpc/node_modules/.bin/tsup +2 -2
  61. package/packages/rpc/node_modules/.bin/tsup-node +2 -2
  62. package/packages/ui/.turbo/turbo-build.log +43 -44
  63. package/scripts/install-rust-binaries.mjs +164 -58
  64. package/src/cli.ts +51 -39
  65. package/src/lib/admin-passthrough.js +1 -0
  66. package/src/lib/admin-reset.js +1 -0
  67. package/src/lib/admin-reset.ts +26 -16
  68. package/src/lib/admin-setup.js +1 -0
  69. package/src/lib/admin-setup.ts +32 -20
  70. package/src/lib/agent-auth-revoke.js +1 -0
  71. package/src/lib/agent-auth-rotate.js +1 -0
  72. package/src/lib/agent-auth.js +1 -0
  73. package/src/lib/config-mutation.js +1 -0
  74. package/src/lib/launchd-assets.js +1 -0
  75. package/src/lib/launchd-assets.ts +29 -0
  76. package/src/lib/local-admin-access.js +1 -0
  77. package/src/lib/rust.ts +1 -1
  78. package/src/lib/status-repair-cli.js +1 -0
  79. package/packages/cache/dist/chunk-ALQ6H7KG.cjs.map +0 -1
  80. package/packages/cache/dist/chunk-FGJEEF5N.js.map +0 -1
@@ -9,7 +9,7 @@ use crossterm::execute;
9
9
  use crossterm::terminal::{
10
10
  disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
11
11
  };
12
- use ratatui::backend::CrosstermBackend;
12
+ use ratatui::backend::{Backend, CrosstermBackend};
13
13
  use ratatui::layout::{Constraint, Direction, Layout};
14
14
  use ratatui::style::{Color, Modifier, Style};
15
15
  use ratatui::text::{Line, Span};
@@ -123,6 +123,42 @@ impl View {
123
123
  }
124
124
  }
125
125
 
126
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
127
+ enum PendingDiscardAction {
128
+ NextView,
129
+ PreviousView,
130
+ ReloadCurrentView,
131
+ NewTokenDraft,
132
+ NewNetworkDraft,
133
+ CycleSavedToken(i8),
134
+ CycleSavedNetwork(i8),
135
+ }
136
+
137
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
138
+ enum PendingDeleteAction {
139
+ DeleteDestinationOverride,
140
+ DeleteManualApproval,
141
+ DeleteToken,
142
+ DeleteNetwork,
143
+ }
144
+
145
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
146
+ enum MessageLevel {
147
+ Info,
148
+ Success,
149
+ Error,
150
+ }
151
+
152
+ impl MessageLevel {
153
+ fn style(self) -> Style {
154
+ match self {
155
+ Self::Info => Style::default().fg(Color::Cyan),
156
+ Self::Success => Style::default().fg(Color::Green),
157
+ Self::Error => Style::default().fg(Color::Red),
158
+ }
159
+ }
160
+ }
161
+
126
162
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
127
163
  enum Field {
128
164
  SelectedToken,
@@ -726,7 +762,12 @@ struct AppState {
726
762
  network_draft: NetworkDraft,
727
763
  show_advanced: bool,
728
764
  print_agent_auth_token: bool,
765
+ token_dirty: bool,
766
+ network_dirty: bool,
767
+ pending_discard_action: Option<PendingDiscardAction>,
768
+ pending_delete_action: Option<PendingDeleteAction>,
729
769
  message: Option<String>,
770
+ message_level: MessageLevel,
730
771
  }
731
772
 
732
773
  impl AppState {
@@ -760,7 +801,12 @@ impl AppState {
760
801
  network_draft,
761
802
  show_advanced: false,
762
803
  print_agent_auth_token,
804
+ token_dirty: false,
805
+ network_dirty: false,
806
+ pending_discard_action: None,
807
+ pending_delete_action: None,
763
808
  message: None,
809
+ message_level: MessageLevel::Info,
764
810
  }
765
811
  }
766
812
 
@@ -860,17 +906,109 @@ impl AppState {
860
906
  self.visible_fields()[self.selected]
861
907
  }
862
908
 
909
+ fn active_draft_dirty(&self) -> bool {
910
+ match self.view {
911
+ View::Tokens => self.token_dirty,
912
+ View::Networks => self.network_dirty,
913
+ View::Bootstrap => false,
914
+ }
915
+ }
916
+
917
+ fn active_draft_label(&self) -> &'static str {
918
+ match self.view {
919
+ View::Tokens => "token draft",
920
+ View::Networks => "network draft",
921
+ View::Bootstrap => "current view",
922
+ }
923
+ }
924
+
925
+ fn clear_pending_discard(&mut self) {
926
+ self.pending_discard_action = None;
927
+ }
928
+
929
+ fn clear_pending_delete(&mut self) {
930
+ self.pending_delete_action = None;
931
+ }
932
+
933
+ fn clear_message(&mut self) {
934
+ self.message = None;
935
+ self.message_level = MessageLevel::Info;
936
+ }
937
+
938
+ fn set_info_message(&mut self, message: impl Into<String>) {
939
+ self.message = Some(message.into());
940
+ self.message_level = MessageLevel::Info;
941
+ }
942
+
943
+ fn set_success_message(&mut self, message: impl Into<String>) {
944
+ self.message = Some(message.into());
945
+ self.message_level = MessageLevel::Success;
946
+ }
947
+
948
+ fn set_error_message(&mut self, message: impl Into<String>) {
949
+ self.message = Some(message.into());
950
+ self.message_level = MessageLevel::Error;
951
+ }
952
+
953
+ fn mark_token_dirty(&mut self) {
954
+ self.token_dirty = true;
955
+ self.clear_pending_discard();
956
+ self.clear_pending_delete();
957
+ }
958
+
959
+ fn mark_network_dirty(&mut self) {
960
+ self.network_dirty = true;
961
+ self.clear_pending_discard();
962
+ self.clear_pending_delete();
963
+ }
964
+
965
+ fn confirm_discard(&mut self, action: PendingDiscardAction) -> bool {
966
+ if !self.active_draft_dirty() {
967
+ self.clear_pending_discard();
968
+ return true;
969
+ }
970
+
971
+ if self.pending_discard_action == Some(action) {
972
+ self.clear_pending_discard();
973
+ return true;
974
+ }
975
+
976
+ self.clear_pending_delete();
977
+ self.pending_discard_action = Some(action);
978
+ self.set_info_message(format!(
979
+ "unsaved changes in the {}; repeat the action to discard them or save first",
980
+ self.active_draft_label()
981
+ ));
982
+ false
983
+ }
984
+
985
+ fn confirm_delete(&mut self, action: PendingDeleteAction, label: &str) -> bool {
986
+ if self.pending_delete_action == Some(action) {
987
+ self.clear_pending_delete();
988
+ return true;
989
+ }
990
+
991
+ self.clear_pending_discard();
992
+ self.pending_delete_action = Some(action);
993
+ self.set_info_message(format!("repeat the action to confirm deleting {label}"));
994
+ false
995
+ }
996
+
863
997
  fn next_view(&mut self) {
864
998
  self.view = self.view.next();
865
999
  self.selected = 0;
866
- self.message = None;
1000
+ self.clear_pending_discard();
1001
+ self.clear_pending_delete();
1002
+ self.clear_message();
867
1003
  self.normalize_selection();
868
1004
  }
869
1005
 
870
1006
  fn previous_view(&mut self) {
871
1007
  self.view = self.view.previous();
872
1008
  self.selected = 0;
873
- self.message = None;
1009
+ self.clear_pending_discard();
1010
+ self.clear_pending_delete();
1011
+ self.clear_message();
874
1012
  self.normalize_selection();
875
1013
  }
876
1014
 
@@ -906,6 +1044,42 @@ impl AppState {
906
1044
  }
907
1045
  }
908
1046
 
1047
+ fn request_next_view(&mut self) {
1048
+ if self.confirm_discard(PendingDiscardAction::NextView) {
1049
+ self.next_view();
1050
+ }
1051
+ }
1052
+
1053
+ fn request_previous_view(&mut self) {
1054
+ if self.confirm_discard(PendingDiscardAction::PreviousView) {
1055
+ self.previous_view();
1056
+ }
1057
+ }
1058
+
1059
+ fn request_reload_current_view(&mut self) -> bool {
1060
+ if !self.confirm_discard(PendingDiscardAction::ReloadCurrentView) {
1061
+ return false;
1062
+ }
1063
+ self.reload_current_view();
1064
+ true
1065
+ }
1066
+
1067
+ fn request_new_current_draft(&mut self) {
1068
+ match self.view {
1069
+ View::Tokens => {
1070
+ if self.confirm_discard(PendingDiscardAction::NewTokenDraft) {
1071
+ self.new_token_draft();
1072
+ }
1073
+ }
1074
+ View::Networks => {
1075
+ if self.confirm_discard(PendingDiscardAction::NewNetworkDraft) {
1076
+ self.new_network_draft();
1077
+ }
1078
+ }
1079
+ View::Bootstrap => {}
1080
+ }
1081
+ }
1082
+
909
1083
  fn load_token_draft(&mut self, token_key: Option<&str>) {
910
1084
  self.token_draft = token_key
911
1085
  .and_then(|token_key| {
@@ -933,6 +1107,9 @@ impl AppState {
933
1107
  })
934
1108
  })
935
1109
  .unwrap_or_else(|| TokenDraft::blank(&self.shared_config_draft));
1110
+ self.token_dirty = false;
1111
+ self.clear_pending_discard();
1112
+ self.clear_pending_delete();
936
1113
  self.normalize_selection();
937
1114
  }
938
1115
 
@@ -963,22 +1140,81 @@ impl AppState {
963
1140
  })
964
1141
  })
965
1142
  .unwrap_or_else(NetworkDraft::blank);
1143
+ self.network_dirty = false;
1144
+ self.clear_pending_discard();
1145
+ self.clear_pending_delete();
966
1146
  self.normalize_selection();
967
1147
  }
968
1148
 
969
1149
  fn new_token_draft(&mut self) {
970
1150
  self.token_draft = TokenDraft::blank(&self.shared_config_draft);
971
- self.message = Some("new token draft ready".to_string());
1151
+ self.token_dirty = false;
1152
+ self.clear_pending_discard();
1153
+ self.clear_pending_delete();
1154
+ self.set_success_message("new token draft ready");
972
1155
  }
973
1156
 
974
1157
  fn new_network_draft(&mut self) {
975
1158
  self.network_draft = NetworkDraft::blank();
976
- self.message = Some("new network draft ready".to_string());
1159
+ self.network_dirty = false;
1160
+ self.clear_pending_discard();
1161
+ self.clear_pending_delete();
1162
+ self.set_success_message("new network draft ready");
1163
+ }
1164
+
1165
+ fn request_delete_destination_override(&mut self) {
1166
+ if self.token_draft.destination_overrides.is_empty() {
1167
+ self.delete_destination_override();
1168
+ return;
1169
+ }
1170
+ if self.confirm_delete(
1171
+ PendingDeleteAction::DeleteDestinationOverride,
1172
+ "the selected destination override",
1173
+ ) {
1174
+ self.delete_destination_override();
1175
+ }
1176
+ }
1177
+
1178
+ fn request_delete_manual_approval(&mut self) {
1179
+ if self.token_draft.manual_approvals.is_empty() {
1180
+ self.delete_manual_approval();
1181
+ return;
1182
+ }
1183
+ if self.confirm_delete(
1184
+ PendingDeleteAction::DeleteManualApproval,
1185
+ "the selected manual approval policy",
1186
+ ) {
1187
+ self.delete_manual_approval();
1188
+ }
1189
+ }
1190
+
1191
+ fn request_delete_token(&mut self) -> Result<()> {
1192
+ let has_candidate = self.token_draft.source_key.is_some()
1193
+ || !self.token_draft.key.trim().is_empty();
1194
+ if !has_candidate {
1195
+ return self.delete_token_config();
1196
+ }
1197
+ if self.confirm_delete(PendingDeleteAction::DeleteToken, "the selected token") {
1198
+ return self.delete_token_config();
1199
+ }
1200
+ Ok(())
1201
+ }
1202
+
1203
+ fn request_delete_network(&mut self) -> Result<()> {
1204
+ let has_candidate = self.network_draft.source_key.is_some()
1205
+ || !self.network_draft.key.trim().is_empty();
1206
+ if !has_candidate {
1207
+ return self.delete_network_config();
1208
+ }
1209
+ if self.confirm_delete(PendingDeleteAction::DeleteNetwork, "the selected network") {
1210
+ return self.delete_network_config();
1211
+ }
1212
+ Ok(())
977
1213
  }
978
1214
 
979
1215
  fn step_selected(&mut self, direction: i8) {
980
1216
  match self.selected_field() {
981
- Field::SelectedToken => self.cycle_saved_token(direction),
1217
+ Field::SelectedToken => self.request_cycle_saved_token(direction),
982
1218
  Field::NetworkMembership => self.cycle_available_network(direction),
983
1219
  Field::EditingNetwork => self.cycle_selected_network_mapping(direction),
984
1220
  Field::NetworkIsNative => {
@@ -987,6 +1223,7 @@ impl AppState {
987
1223
  if network.is_native {
988
1224
  network.address.clear();
989
1225
  }
1226
+ self.mark_token_dirty();
990
1227
  }
991
1228
  }
992
1229
  Field::SelectedDestinationOverride => {
@@ -1003,17 +1240,24 @@ impl AppState {
1003
1240
  direction,
1004
1241
  );
1005
1242
  }
1006
- Field::SelectedNetwork => self.cycle_saved_network(direction),
1243
+ Field::SelectedNetwork => self.request_cycle_saved_network(direction),
1007
1244
  Field::ShowAdvanced => {
1008
1245
  self.show_advanced = !self.show_advanced;
1009
1246
  }
1010
1247
  Field::ChainConfigUseAsActive => {
1011
1248
  self.network_draft.use_as_active = !self.network_draft.use_as_active;
1249
+ self.mark_network_dirty();
1012
1250
  }
1013
1251
  _ => {}
1014
1252
  }
1015
1253
  }
1016
1254
 
1255
+ fn request_cycle_saved_token(&mut self, direction: i8) {
1256
+ if self.confirm_discard(PendingDiscardAction::CycleSavedToken(direction)) {
1257
+ self.cycle_saved_token(direction);
1258
+ }
1259
+ }
1260
+
1017
1261
  fn cycle_saved_token(&mut self, direction: i8) {
1018
1262
  let saved = sorted_token_keys(&self.shared_config_draft);
1019
1263
  if saved.is_empty() {
@@ -1037,7 +1281,13 @@ impl AppState {
1037
1281
  } else {
1038
1282
  let key = entries[index].clone();
1039
1283
  self.load_token_draft(Some(&key));
1040
- self.message = None;
1284
+ self.clear_message();
1285
+ }
1286
+ }
1287
+
1288
+ fn request_cycle_saved_network(&mut self, direction: i8) {
1289
+ if self.confirm_discard(PendingDiscardAction::CycleSavedNetwork(direction)) {
1290
+ self.cycle_saved_network(direction);
1041
1291
  }
1042
1292
  }
1043
1293
 
@@ -1064,7 +1314,7 @@ impl AppState {
1064
1314
  } else {
1065
1315
  let key = entries[index].clone();
1066
1316
  self.load_network_draft(Some(&key));
1067
- self.message = None;
1317
+ self.clear_message();
1068
1318
  }
1069
1319
  }
1070
1320
 
@@ -1186,7 +1436,12 @@ impl AppState {
1186
1436
  match key.code {
1187
1437
  KeyCode::Backspace => {
1188
1438
  target.pop();
1189
- self.message = None;
1439
+ if field_uses_network_draft(selected_field) {
1440
+ self.mark_network_dirty();
1441
+ } else {
1442
+ self.mark_token_dirty();
1443
+ }
1444
+ self.clear_message();
1190
1445
  }
1191
1446
  KeyCode::Char(ch)
1192
1447
  if !key.modifiers.contains(KeyModifiers::CONTROL)
@@ -1194,9 +1449,16 @@ impl AppState {
1194
1449
  {
1195
1450
  if is_allowed_input_char(selected_field, ch) {
1196
1451
  target.push(ch);
1197
- self.message = None;
1452
+ if field_uses_network_draft(selected_field) {
1453
+ self.mark_network_dirty();
1454
+ } else {
1455
+ self.mark_token_dirty();
1456
+ }
1457
+ self.clear_message();
1198
1458
  } else {
1199
- self.message = Some(format!("invalid character '{ch}' for the selected field"));
1459
+ self.set_error_message(format!(
1460
+ "invalid character '{ch}' for the selected field"
1461
+ ));
1200
1462
  }
1201
1463
  }
1202
1464
  _ => {}
@@ -1215,19 +1477,21 @@ impl AppState {
1215
1477
  .destination_overrides
1216
1478
  .len()
1217
1479
  .saturating_sub(1);
1218
- self.message = Some("destination override added".to_string());
1480
+ self.mark_token_dirty();
1481
+ self.set_success_message("destination override added");
1219
1482
  }
1220
1483
 
1221
1484
  fn delete_destination_override(&mut self) {
1222
1485
  if self.token_draft.destination_overrides.is_empty() {
1223
- self.message = Some("no destination override is selected".to_string());
1486
+ self.set_info_message("no destination override is selected");
1224
1487
  return;
1225
1488
  }
1226
1489
  self.token_draft
1227
1490
  .destination_overrides
1228
1491
  .remove(self.token_draft.selected_override);
1229
1492
  self.token_draft.normalize();
1230
- self.message = Some("destination override removed".to_string());
1493
+ self.mark_token_dirty();
1494
+ self.set_success_message("destination override removed");
1231
1495
  }
1232
1496
 
1233
1497
  fn add_manual_approval(&mut self) {
@@ -1239,19 +1503,21 @@ impl AppState {
1239
1503
  });
1240
1504
  self.token_draft.selected_manual_approval =
1241
1505
  self.token_draft.manual_approvals.len().saturating_sub(1);
1242
- self.message = Some("manual approval policy added".to_string());
1506
+ self.mark_token_dirty();
1507
+ self.set_success_message("manual approval policy added");
1243
1508
  }
1244
1509
 
1245
1510
  fn delete_manual_approval(&mut self) {
1246
1511
  if self.token_draft.manual_approvals.is_empty() {
1247
- self.message = Some("no manual approval policy is selected".to_string());
1512
+ self.set_info_message("no manual approval policy is selected");
1248
1513
  return;
1249
1514
  }
1250
1515
  self.token_draft
1251
1516
  .manual_approvals
1252
1517
  .remove(self.token_draft.selected_manual_approval);
1253
1518
  self.token_draft.normalize();
1254
- self.message = Some("manual approval policy removed".to_string());
1519
+ self.mark_token_dirty();
1520
+ self.set_success_message("manual approval policy removed");
1255
1521
  }
1256
1522
 
1257
1523
  fn refresh_token_metadata(&mut self) -> Result<()> {
@@ -1292,7 +1558,8 @@ impl AppState {
1292
1558
  network.chain_id = metadata.chain_id.to_string();
1293
1559
  network.decimals = metadata.decimals.to_string();
1294
1560
  }
1295
- self.message = Some("token metadata refreshed from rpc".to_string());
1561
+ self.mark_token_dirty();
1562
+ self.set_success_message("token metadata refreshed from rpc");
1296
1563
  Ok(())
1297
1564
  }
1298
1565
 
@@ -1335,7 +1602,7 @@ impl AppState {
1335
1602
  self.shared_config_draft = candidate;
1336
1603
  self.persist_shared_config()?;
1337
1604
  self.load_token_draft(Some(&token_key));
1338
- self.message = Some(format!("saved token '{}'", token_key));
1605
+ self.set_success_message(format!("saved token '{}'", token_key));
1339
1606
  Ok(())
1340
1607
  }
1341
1608
 
@@ -1354,7 +1621,7 @@ impl AppState {
1354
1621
  }
1355
1622
  self.persist_shared_config()?;
1356
1623
  self.load_token_draft(None);
1357
- self.message = Some(format!("deleted token '{}'", key));
1624
+ self.set_success_message(format!("deleted token '{}'", key));
1358
1625
  Ok(())
1359
1626
  }
1360
1627
 
@@ -1389,7 +1656,7 @@ impl AppState {
1389
1656
  self.load_network_draft(Some(&chain_key));
1390
1657
  let token_source_key = self.token_draft.source_key.clone();
1391
1658
  self.load_token_draft(token_source_key.as_deref());
1392
- self.message = Some(format!("saved network '{}'", chain_key));
1659
+ self.set_success_message(format!("saved network '{}'", chain_key));
1393
1660
  Ok(())
1394
1661
  }
1395
1662
 
@@ -1425,7 +1692,7 @@ impl AppState {
1425
1692
  }
1426
1693
  self.persist_shared_config()?;
1427
1694
  self.load_network_draft(None);
1428
- self.message = Some(format!("deleted network '{}'", key));
1695
+ self.set_success_message(format!("deleted network '{}'", key));
1429
1696
  Ok(())
1430
1697
  }
1431
1698
 
@@ -1539,10 +1806,8 @@ impl ResolvedLimitFields {
1539
1806
 
1540
1807
  enum LoopAction {
1541
1808
  Continue,
1542
- ApplyAndContinue {
1543
- params: Box<BootstrapParams>,
1544
- success_message: String,
1545
- },
1809
+ RefreshTokenMetadata,
1810
+ SaveTokenAndApply,
1546
1811
  ApplyAndExit(Box<BootstrapParams>),
1547
1812
  Cancel,
1548
1813
  }
@@ -1550,7 +1815,7 @@ enum LoopAction {
1550
1815
  pub(crate) fn run_bootstrap_tui<T>(
1551
1816
  shared_config: &WlfiConfig,
1552
1817
  print_agent_auth_token: bool,
1553
- on_apply: impl FnMut(BootstrapParams) -> Result<T>,
1818
+ on_apply: impl FnMut(BootstrapParams, &mut dyn FnMut(&str) -> Result<()>) -> Result<T>,
1554
1819
  ) -> Result<Option<T>> {
1555
1820
  enable_raw_mode().context("failed to enable raw mode")?;
1556
1821
  let mut stdout = io::stdout();
@@ -1580,17 +1845,70 @@ fn cleanup_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Re
1580
1845
  Ok(())
1581
1846
  }
1582
1847
 
1583
- fn run_event_loop<T>(
1584
- terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1848
+ fn render_frame<B: Backend>(terminal: &mut Terminal<B>, app: &AppState) -> Result<()> {
1849
+ terminal
1850
+ .draw(|frame| draw_ui(frame, app))
1851
+ .context("failed to render tui frame")?;
1852
+ Ok(())
1853
+ }
1854
+
1855
+ fn refresh_metadata_with_progress<B: Backend>(
1856
+ terminal: &mut Terminal<B>,
1857
+ app: &mut AppState,
1858
+ ) -> Result<()> {
1859
+ app.set_info_message("refreshing token metadata from rpc");
1860
+ render_frame(terminal, app)?;
1861
+ app.refresh_token_metadata()
1862
+ }
1863
+
1864
+ fn apply_with_progress<T, B: Backend>(
1865
+ terminal: &mut Terminal<B>,
1866
+ app: &mut AppState,
1867
+ params: BootstrapParams,
1868
+ on_apply: &mut impl FnMut(BootstrapParams, &mut dyn FnMut(&str) -> Result<()>) -> Result<T>,
1869
+ ) -> Result<T> {
1870
+ app.set_info_message("applying wallet changes");
1871
+ render_frame(terminal, app)?;
1872
+
1873
+ let mut on_status = |message: &str| -> Result<()> {
1874
+ app.set_info_message(message);
1875
+ render_frame(terminal, app)
1876
+ };
1877
+
1878
+ on_apply(params, &mut on_status)
1879
+ }
1880
+
1881
+ fn run_save_token_and_apply<T, B: Backend>(
1882
+ terminal: &mut Terminal<B>,
1883
+ app: &mut AppState,
1884
+ on_apply: &mut impl FnMut(BootstrapParams, &mut dyn FnMut(&str) -> Result<()>) -> Result<T>,
1885
+ ) -> Result<(T, String)> {
1886
+ if app.selected_token_requires_metadata_refresh() {
1887
+ refresh_metadata_with_progress(terminal, app)?;
1888
+ }
1889
+
1890
+ app.save_token_config()?;
1891
+ let success_message = app
1892
+ .message
1893
+ .clone()
1894
+ .unwrap_or_else(|| "saved token".to_string());
1895
+
1896
+ let params = app.build_params().map_err(|err| {
1897
+ anyhow!("{success_message} but failed to apply to wallet: {err}")
1898
+ })?;
1899
+ let output = apply_with_progress(terminal, app, params, on_apply)?;
1900
+ Ok((output, format!("{success_message} and applied to wallet")))
1901
+ }
1902
+
1903
+ fn run_event_loop<T, B: Backend>(
1904
+ terminal: &mut Terminal<B>,
1585
1905
  mut app: AppState,
1586
- mut on_apply: impl FnMut(BootstrapParams) -> Result<T>,
1906
+ mut on_apply: impl FnMut(BootstrapParams, &mut dyn FnMut(&str) -> Result<()>) -> Result<T>,
1587
1907
  ) -> Result<Option<T>> {
1588
1908
  let mut last_output = None;
1589
1909
  loop {
1590
1910
  app.normalize_selection();
1591
- terminal
1592
- .draw(|frame| draw_ui(frame, &app))
1593
- .context("failed to render tui frame")?;
1911
+ render_frame(terminal, &app)?;
1594
1912
 
1595
1913
  if !event::poll(Duration::from_millis(250)).context("failed to poll terminal events")? {
1596
1914
  continue;
@@ -1605,22 +1923,33 @@ fn run_event_loop<T>(
1605
1923
 
1606
1924
  match handle_key_event(&mut app, key)? {
1607
1925
  LoopAction::Continue => {}
1608
- LoopAction::ApplyAndContinue {
1609
- params,
1610
- success_message,
1611
- } => match on_apply(*params) {
1612
- Ok(output) => {
1926
+ LoopAction::RefreshTokenMetadata => {
1927
+ if let Err(err) = refresh_metadata_with_progress(terminal, &mut app) {
1928
+ app.set_error_message(err.to_string());
1929
+ }
1930
+ }
1931
+ LoopAction::SaveTokenAndApply => match run_save_token_and_apply(
1932
+ terminal,
1933
+ &mut app,
1934
+ &mut on_apply,
1935
+ ) {
1936
+ Ok((output, success_message)) => {
1613
1937
  last_output = Some(output);
1614
- app.message = Some(success_message);
1938
+ app.set_success_message(success_message);
1615
1939
  }
1616
1940
  Err(err) => {
1617
- app.message = Some(err.to_string());
1941
+ app.set_error_message(err.to_string());
1618
1942
  }
1619
1943
  },
1620
- LoopAction::ApplyAndExit(params) => match on_apply(*params) {
1944
+ LoopAction::ApplyAndExit(params) => match apply_with_progress(
1945
+ terminal,
1946
+ &mut app,
1947
+ *params,
1948
+ &mut on_apply,
1949
+ ) {
1621
1950
  Ok(output) => return Ok(Some(output)),
1622
1951
  Err(err) => {
1623
- app.message = Some(err.to_string());
1952
+ app.set_error_message(err.to_string());
1624
1953
  }
1625
1954
  },
1626
1955
  LoopAction::Cancel => return Ok(last_output),
@@ -1633,24 +1962,21 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
1633
1962
  match app.build_params() {
1634
1963
  Ok(params) => return Ok(LoopAction::ApplyAndExit(Box::new(params))),
1635
1964
  Err(err) => {
1636
- app.message = Some(err.to_string());
1965
+ app.set_error_message(err.to_string());
1637
1966
  return Ok(LoopAction::Continue);
1638
1967
  }
1639
1968
  }
1640
1969
  }
1641
1970
 
1642
1971
  if key.code == KeyCode::Char('r') && key.modifiers.contains(KeyModifiers::CONTROL) {
1643
- app.reload_current_view();
1644
- app.message = Some("reloaded saved data into the current draft".to_string());
1972
+ if app.request_reload_current_view() {
1973
+ app.set_success_message("reloaded saved data into the current draft");
1974
+ }
1645
1975
  return Ok(LoopAction::Continue);
1646
1976
  }
1647
1977
 
1648
1978
  if key.code == KeyCode::Char('n') && key.modifiers.contains(KeyModifiers::CONTROL) {
1649
- match app.view {
1650
- View::Tokens => app.new_token_draft(),
1651
- View::Networks => app.new_network_draft(),
1652
- View::Bootstrap => {}
1653
- }
1979
+ app.request_new_current_draft();
1654
1980
  return Ok(LoopAction::Continue);
1655
1981
  }
1656
1982
 
@@ -1676,20 +2002,20 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
1676
2002
  KeyCode::Char('q') if key.modifiers.is_empty() => return Ok(LoopAction::Cancel),
1677
2003
  KeyCode::Down | KeyCode::Char('j') => {
1678
2004
  app.select_next();
1679
- app.message = None;
2005
+ app.clear_message();
1680
2006
  return Ok(LoopAction::Continue);
1681
2007
  }
1682
2008
  KeyCode::Up | KeyCode::Char('k') => {
1683
2009
  app.select_prev();
1684
- app.message = None;
2010
+ app.clear_message();
1685
2011
  return Ok(LoopAction::Continue);
1686
2012
  }
1687
2013
  KeyCode::BackTab => {
1688
- app.previous_view();
2014
+ app.request_previous_view();
1689
2015
  return Ok(LoopAction::Continue);
1690
2016
  }
1691
2017
  KeyCode::Tab => {
1692
- app.next_view();
2018
+ app.request_next_view();
1693
2019
  return Ok(LoopAction::Continue);
1694
2020
  }
1695
2021
  KeyCode::Home => {
@@ -1701,13 +2027,13 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
1701
2027
  return Ok(LoopAction::Continue);
1702
2028
  }
1703
2029
  KeyCode::Left | KeyCode::Char('h') => {
2030
+ app.clear_message();
1704
2031
  app.step_selected(-1);
1705
- app.message = None;
1706
2032
  return Ok(LoopAction::Continue);
1707
2033
  }
1708
2034
  KeyCode::Right | KeyCode::Char('l') => {
2035
+ app.clear_message();
1709
2036
  app.step_selected(1);
1710
- app.message = None;
1711
2037
  return Ok(LoopAction::Continue);
1712
2038
  }
1713
2039
  KeyCode::Char(' ') => match app.selected_field() {
@@ -1716,9 +2042,10 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
1716
2042
  .token_draft
1717
2043
  .toggle_network_membership(&app.shared_config_draft)
1718
2044
  {
1719
- app.message = Some(err.to_string());
2045
+ app.set_error_message(err.to_string());
1720
2046
  } else {
1721
- app.message = Some("updated token network selection".to_string());
2047
+ app.mark_token_dirty();
2048
+ app.set_success_message("updated token network selection");
1722
2049
  }
1723
2050
  return Ok(LoopAction::Continue);
1724
2051
  }
@@ -1736,18 +2063,18 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
1736
2063
  _ => {}
1737
2064
  },
1738
2065
  KeyCode::Char('[') if key.modifiers.is_empty() => {
1739
- app.previous_view();
2066
+ app.request_previous_view();
1740
2067
  return Ok(LoopAction::Continue);
1741
2068
  }
1742
2069
  KeyCode::Char(']') if key.modifiers.is_empty() => {
1743
- app.next_view();
2070
+ app.request_next_view();
1744
2071
  return Ok(LoopAction::Continue);
1745
2072
  }
1746
2073
  KeyCode::Enter => match app.selected_field() {
1747
2074
  Field::Execute => match app.build_params() {
1748
2075
  Ok(params) => return Ok(LoopAction::ApplyAndExit(Box::new(params))),
1749
2076
  Err(err) => {
1750
- app.message = Some(err.to_string());
2077
+ app.set_error_message(err.to_string());
1751
2078
  return Ok(LoopAction::Continue);
1752
2079
  }
1753
2080
  },
@@ -1756,24 +2083,22 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
1756
2083
  .token_draft
1757
2084
  .toggle_network_membership(&app.shared_config_draft)
1758
2085
  {
1759
- app.message = Some(err.to_string());
2086
+ app.set_error_message(err.to_string());
1760
2087
  } else {
1761
- app.message = Some("updated token network selection".to_string());
2088
+ app.mark_token_dirty();
2089
+ app.set_success_message("updated token network selection");
1762
2090
  }
1763
2091
  return Ok(LoopAction::Continue);
1764
2092
  }
1765
2093
  Field::RefreshTokenMetadata => {
1766
- if let Err(err) = app.refresh_token_metadata() {
1767
- app.message = Some(err.to_string());
1768
- }
1769
- return Ok(LoopAction::Continue);
2094
+ return Ok(LoopAction::RefreshTokenMetadata);
1770
2095
  }
1771
2096
  Field::DestinationOverrides => {
1772
2097
  app.add_destination_override();
1773
2098
  return Ok(LoopAction::Continue);
1774
2099
  }
1775
2100
  Field::DeleteDestinationOverride => {
1776
- app.delete_destination_override();
2101
+ app.request_delete_destination_override();
1777
2102
  return Ok(LoopAction::Continue);
1778
2103
  }
1779
2104
  Field::ManualApprovals => {
@@ -1781,54 +2106,27 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
1781
2106
  return Ok(LoopAction::Continue);
1782
2107
  }
1783
2108
  Field::DeleteManualApproval => {
1784
- app.delete_manual_approval();
2109
+ app.request_delete_manual_approval();
1785
2110
  return Ok(LoopAction::Continue);
1786
2111
  }
1787
2112
  Field::SaveToken => {
1788
- if app.selected_token_requires_metadata_refresh() {
1789
- if let Err(err) = app.refresh_token_metadata() {
1790
- app.message = Some(err.to_string());
1791
- return Ok(LoopAction::Continue);
1792
- }
1793
- }
1794
- if let Err(err) = app.save_token_config() {
1795
- app.message = Some(err.to_string());
1796
- return Ok(LoopAction::Continue);
1797
- }
1798
- let success_message = app
1799
- .message
1800
- .clone()
1801
- .unwrap_or_else(|| "saved token".to_string());
1802
- match app.build_params() {
1803
- Ok(params) => {
1804
- return Ok(LoopAction::ApplyAndContinue {
1805
- params: Box::new(params),
1806
- success_message: format!("{success_message} and applied to wallet"),
1807
- });
1808
- }
1809
- Err(err) => {
1810
- app.message = Some(format!(
1811
- "{success_message} but failed to apply to wallet: {err}"
1812
- ));
1813
- return Ok(LoopAction::Continue);
1814
- }
1815
- }
2113
+ return Ok(LoopAction::SaveTokenAndApply);
1816
2114
  }
1817
2115
  Field::DeleteToken => {
1818
- if let Err(err) = app.delete_token_config() {
1819
- app.message = Some(err.to_string());
2116
+ if let Err(err) = app.request_delete_token() {
2117
+ app.set_error_message(err.to_string());
1820
2118
  }
1821
2119
  return Ok(LoopAction::Continue);
1822
2120
  }
1823
2121
  Field::SaveNetwork => {
1824
2122
  if let Err(err) = app.save_network_config() {
1825
- app.message = Some(err.to_string());
2123
+ app.set_error_message(err.to_string());
1826
2124
  }
1827
2125
  return Ok(LoopAction::Continue);
1828
2126
  }
1829
2127
  Field::DeleteNetwork => {
1830
- if let Err(err) = app.delete_network_config() {
1831
- app.message = Some(err.to_string());
2128
+ if let Err(err) = app.request_delete_network() {
2129
+ app.set_error_message(err.to_string());
1832
2130
  }
1833
2131
  return Ok(LoopAction::Continue);
1834
2132
  }
@@ -1942,11 +2240,7 @@ fn draw_ui(frame: &mut ratatui::Frame<'_>, app: &AppState) {
1942
2240
  let default_message =
1943
2241
  "Ready. Start with tokens; saved tokens expand across all selected networks at bootstrap.";
1944
2242
  let message = app.message.as_deref().unwrap_or(default_message);
1945
- let style = if app.message.is_some() {
1946
- Style::default().fg(Color::Red)
1947
- } else {
1948
- Style::default().fg(Color::Green)
1949
- };
2243
+ let style = status_message_style(app);
1950
2244
  frame.render_widget(
1951
2245
  Paragraph::new(Line::from(Span::styled(message, style)))
1952
2246
  .block(Block::default().borders(Borders::ALL).title("Status")),
@@ -1954,6 +2248,14 @@ fn draw_ui(frame: &mut ratatui::Frame<'_>, app: &AppState) {
1954
2248
  );
1955
2249
  }
1956
2250
 
2251
+ fn status_message_style(app: &AppState) -> Style {
2252
+ if app.message.is_some() {
2253
+ app.message_level.style()
2254
+ } else {
2255
+ Style::default().fg(Color::Green)
2256
+ }
2257
+ }
2258
+
1957
2259
  fn build_help_lines(view: View) -> Vec<Line<'static>> {
1958
2260
  let mut lines = vec![
1959
2261
  Line::from("Views: Tab/Shift+Tab or ]/["),
@@ -2311,7 +2613,7 @@ fn field_value(app: &AppState, field: Field) -> String {
2311
2613
  .get(app.token_draft.selected_override)
2312
2614
  .map(|item| display_unlimited_if_empty(&item.limits.per_tx_max_calldata_bytes))
2313
2615
  .unwrap_or_else(|| "n/a".to_string()),
2314
- Field::DeleteDestinationOverride => "press Enter".to_string(),
2616
+ Field::DeleteDestinationOverride => "press Enter; repeat to confirm".to_string(),
2315
2617
  Field::ManualApprovals => format!(
2316
2618
  "{} saved draft(s) — press Enter",
2317
2619
  app.token_draft.manual_approvals.len()
@@ -2346,9 +2648,9 @@ fn field_value(app: &AppState, field: Field) -> String {
2346
2648
  .get(app.token_draft.selected_manual_approval)
2347
2649
  .map(|item| blank_if_empty(&item.priority))
2348
2650
  .unwrap_or_else(|| "n/a".to_string()),
2349
- Field::DeleteManualApproval => "press Enter".to_string(),
2651
+ Field::DeleteManualApproval => "press Enter; repeat to confirm".to_string(),
2350
2652
  Field::SaveToken => "press Enter".to_string(),
2351
- Field::DeleteToken => "press Enter".to_string(),
2653
+ Field::DeleteToken => "press Enter; repeat to confirm".to_string(),
2352
2654
  Field::SelectedNetwork => app
2353
2655
  .network_draft
2354
2656
  .source_key
@@ -2360,7 +2662,7 @@ fn field_value(app: &AppState, field: Field) -> String {
2360
2662
  Field::ChainConfigRpcUrl => blank_if_empty(&app.network_draft.rpc_url),
2361
2663
  Field::ChainConfigUseAsActive => bool_label(app.network_draft.use_as_active).to_string(),
2362
2664
  Field::SaveNetwork => "press Enter".to_string(),
2363
- Field::DeleteNetwork => "press Enter".to_string(),
2665
+ Field::DeleteNetwork => "press Enter; repeat to confirm".to_string(),
2364
2666
  Field::Execute => "press Enter".to_string(),
2365
2667
  }
2366
2668
  }
@@ -2381,6 +2683,16 @@ fn display_unlimited_if_empty(value: &str) -> String {
2381
2683
  }
2382
2684
  }
2383
2685
 
2686
+ fn field_uses_network_draft(field: Field) -> bool {
2687
+ matches!(
2688
+ field,
2689
+ Field::ChainConfigKey
2690
+ | Field::ChainConfigId
2691
+ | Field::ChainConfigName
2692
+ | Field::ChainConfigRpcUrl
2693
+ )
2694
+ }
2695
+
2384
2696
  fn render_network_membership_value(token_draft: &TokenDraft, config: &WlfiConfig) -> String {
2385
2697
  let available = sorted_chain_keys(config);
2386
2698
  if available.is_empty() {
@@ -3089,13 +3401,14 @@ fn validate_optional_overlay_limit(
3089
3401
  #[cfg(test)]
3090
3402
  mod tests {
3091
3403
  use super::{
3092
- build_bootstrap_panel_lines, build_token_panel_lines, handle_key_event,
3093
- resolve_all_token_manual_approval_policies, resolve_all_token_policies, AppState,
3094
- ChainProfile, Field, KeyCode, KeyEvent, KeyModifiers, LoopAction, TokenChainProfile,
3095
- TokenDestinationOverrideProfile, TokenManualApprovalProfile, TokenPolicyProfile,
3096
- TokenProfile, View, WlfiConfig,
3404
+ apply_with_progress, build_bootstrap_panel_lines, build_token_panel_lines,
3405
+ handle_key_event, resolve_all_token_manual_approval_policies, resolve_all_token_policies,
3406
+ status_message_style, AppState, ChainProfile, Field, KeyCode, KeyEvent, KeyModifiers,
3407
+ LoopAction, MessageLevel, TokenChainProfile, TokenDestinationOverrideProfile,
3408
+ TokenManualApprovalProfile, TokenPolicyProfile, TokenProfile, View, WlfiConfig,
3097
3409
  };
3098
3410
  use crate::shared_config::WalletProfile;
3411
+ use ratatui::{backend::TestBackend, style::Color, Terminal};
3099
3412
  use std::collections::BTreeMap;
3100
3413
  use std::fs;
3101
3414
  use uuid::Uuid;
@@ -3334,7 +3647,7 @@ mod tests {
3334
3647
  }
3335
3648
 
3336
3649
  #[test]
3337
- fn save_token_returns_live_apply_action_when_metadata_is_complete() {
3650
+ fn save_token_defers_apply_to_event_loop_when_metadata_is_complete() {
3338
3651
  let config = sample_config();
3339
3652
  let mut app = AppState::from_shared_config(&config, false);
3340
3653
  let config_root =
@@ -3350,21 +3663,65 @@ mod tests {
3350
3663
  let action = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3351
3664
  .expect("handle");
3352
3665
 
3353
- match action {
3354
- LoopAction::ApplyAndContinue {
3355
- params,
3356
- success_message,
3357
- } => {
3358
- assert_eq!(params.token_policies.len(), 2);
3359
- assert!(success_message.contains("saved token 'usdc'"));
3360
- assert!(success_message.contains("applied to wallet"));
3361
- }
3362
- _ => panic!("expected save token to apply and continue"),
3363
- }
3666
+ assert!(matches!(action, LoopAction::SaveTokenAndApply));
3364
3667
 
3365
3668
  fs::remove_dir_all(config_root).expect("cleanup temp config root");
3366
3669
  }
3367
3670
 
3671
+ #[test]
3672
+ fn refresh_metadata_action_is_deferred_to_event_loop_for_progress_rendering() {
3673
+ let config = sample_config();
3674
+ let mut app = AppState::from_shared_config(&config, false);
3675
+ app.selected = app
3676
+ .visible_fields()
3677
+ .iter()
3678
+ .position(|field| *field == Field::RefreshTokenMetadata)
3679
+ .expect("refresh metadata field");
3680
+
3681
+ let action = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3682
+ .expect("handle");
3683
+
3684
+ assert!(matches!(action, LoopAction::RefreshTokenMetadata));
3685
+ }
3686
+
3687
+ #[test]
3688
+ fn apply_with_progress_surfaces_bootstrap_status_messages() {
3689
+ let mut terminal = Terminal::new(TestBackend::new(120, 40)).expect("terminal");
3690
+ let mut app = AppState::from_shared_config(&sample_config(), false);
3691
+ let params = app.build_params().expect("params");
3692
+
3693
+ let output = apply_with_progress(&mut terminal, &mut app, params, &mut |params, on_status| {
3694
+ assert_eq!(params.token_policies.len(), 2);
3695
+ on_status("initializing daemon")?;
3696
+ on_status("registering spending policies")?;
3697
+ Ok("ok".to_string())
3698
+ })
3699
+ .expect("apply succeeds");
3700
+
3701
+ assert_eq!(output, "ok");
3702
+ assert_eq!(
3703
+ app.message.as_deref(),
3704
+ Some("registering spending policies")
3705
+ );
3706
+ }
3707
+
3708
+ #[test]
3709
+ fn status_message_style_uses_message_severity() {
3710
+ let mut app = AppState::from_shared_config(&sample_config(), false);
3711
+
3712
+ app.set_info_message("loading");
3713
+ assert_eq!(app.message_level, MessageLevel::Info);
3714
+ assert_eq!(status_message_style(&app).fg, Some(Color::Cyan));
3715
+
3716
+ app.set_success_message("saved");
3717
+ assert_eq!(app.message_level, MessageLevel::Success);
3718
+ assert_eq!(status_message_style(&app).fg, Some(Color::Green));
3719
+
3720
+ app.set_error_message("failed");
3721
+ assert_eq!(app.message_level, MessageLevel::Error);
3722
+ assert_eq!(status_message_style(&app).fg, Some(Color::Red));
3723
+ }
3724
+
3368
3725
  #[test]
3369
3726
  fn advanced_fields_are_hidden_by_default() {
3370
3727
  let app = AppState::from_shared_config(&sample_config(), false);
@@ -3407,4 +3764,152 @@ mod tests {
3407
3764
  let app = AppState::from_shared_config(&WlfiConfig::default(), false);
3408
3765
  assert_eq!(super::field_value(&app, Field::NetworkAddress), "native");
3409
3766
  }
3767
+
3768
+ #[test]
3769
+ fn tab_requires_confirmation_before_discarding_dirty_token_draft() {
3770
+ let mut app = AppState::from_shared_config(&sample_config(), false);
3771
+ app.selected = app
3772
+ .visible_fields()
3773
+ .iter()
3774
+ .position(|field| *field == Field::TokenKey)
3775
+ .expect("token key field");
3776
+
3777
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
3778
+ .expect("edit token key");
3779
+ assert!(app.token_dirty);
3780
+
3781
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
3782
+ .expect("first tab");
3783
+ assert_eq!(app.view, View::Tokens);
3784
+ assert!(app
3785
+ .message
3786
+ .as_deref()
3787
+ .expect("discard warning")
3788
+ .contains("unsaved changes in the token draft"));
3789
+
3790
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
3791
+ .expect("second tab");
3792
+ assert_eq!(app.view, View::Networks);
3793
+ }
3794
+
3795
+ #[test]
3796
+ fn reload_requires_confirmation_before_discarding_dirty_network_draft() {
3797
+ let mut app = AppState::from_shared_config(&sample_config(), false);
3798
+ app.view = View::Networks;
3799
+ let original_name = app.network_draft.name.clone();
3800
+ app.selected = app
3801
+ .visible_fields()
3802
+ .iter()
3803
+ .position(|field| *field == Field::ChainConfigName)
3804
+ .expect("chain config name field");
3805
+
3806
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
3807
+ .expect("edit network name");
3808
+ assert!(app.network_dirty);
3809
+ assert!(app.network_draft.name.ends_with('x'));
3810
+
3811
+ let _ = handle_key_event(
3812
+ &mut app,
3813
+ KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
3814
+ )
3815
+ .expect("first reload");
3816
+ assert!(app.network_draft.name.ends_with('x'));
3817
+ assert!(app
3818
+ .message
3819
+ .as_deref()
3820
+ .expect("discard warning")
3821
+ .contains("unsaved changes in the network draft"));
3822
+
3823
+ let _ = handle_key_event(
3824
+ &mut app,
3825
+ KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
3826
+ )
3827
+ .expect("second reload");
3828
+ assert_eq!(app.network_draft.name, original_name);
3829
+ assert!(!app.network_dirty);
3830
+ }
3831
+
3832
+ #[test]
3833
+ fn cycling_saved_token_requires_confirmation_when_current_draft_is_dirty() {
3834
+ let mut app = AppState::from_shared_config(&sample_config(), false);
3835
+ app.selected = app
3836
+ .visible_fields()
3837
+ .iter()
3838
+ .position(|field| *field == Field::TokenKey)
3839
+ .expect("token key field");
3840
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
3841
+ .expect("edit token key");
3842
+
3843
+ app.selected = app
3844
+ .visible_fields()
3845
+ .iter()
3846
+ .position(|field| *field == Field::SelectedToken)
3847
+ .expect("selected token field");
3848
+
3849
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))
3850
+ .expect("first cycle");
3851
+ assert_eq!(app.token_draft.source_key.as_deref(), Some("usdc"));
3852
+ assert!(app
3853
+ .message
3854
+ .as_deref()
3855
+ .expect("discard warning")
3856
+ .contains("unsaved changes in the token draft"));
3857
+
3858
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))
3859
+ .expect("second cycle");
3860
+ assert!(app.token_draft.source_key.is_none());
3861
+ assert!(!app.token_dirty);
3862
+ }
3863
+
3864
+ #[test]
3865
+ fn delete_manual_approval_requires_confirmation() {
3866
+ let mut app = AppState::from_shared_config(&sample_config(), false);
3867
+ app.selected = app
3868
+ .visible_fields()
3869
+ .iter()
3870
+ .position(|field| *field == Field::DeleteManualApproval)
3871
+ .expect("delete manual approval field");
3872
+
3873
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3874
+ .expect("first delete");
3875
+ assert_eq!(app.token_draft.manual_approvals.len(), 1);
3876
+ assert!(app
3877
+ .message
3878
+ .as_deref()
3879
+ .expect("confirmation")
3880
+ .contains("repeat the action to confirm deleting the selected manual approval policy"));
3881
+
3882
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3883
+ .expect("second delete");
3884
+ assert!(app.token_draft.manual_approvals.is_empty());
3885
+ }
3886
+
3887
+ #[test]
3888
+ fn delete_token_requires_confirmation() {
3889
+ let mut app = AppState::from_shared_config(&sample_config(), false);
3890
+ let config_root =
3891
+ std::env::temp_dir().join(format!("wlfi-agent-admin-delete-token-{}", Uuid::new_v4()));
3892
+ fs::create_dir_all(&config_root).expect("create temp config root");
3893
+ app.config_path = config_root.join("config.json");
3894
+ app.selected = app
3895
+ .visible_fields()
3896
+ .iter()
3897
+ .position(|field| *field == Field::DeleteToken)
3898
+ .expect("delete token field");
3899
+
3900
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3901
+ .expect("first delete");
3902
+ assert!(app.shared_config_draft.tokens.contains_key("usdc"));
3903
+ assert!(app
3904
+ .message
3905
+ .as_deref()
3906
+ .expect("confirmation")
3907
+ .contains("repeat the action to confirm deleting the selected token"));
3908
+
3909
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3910
+ .expect("second delete");
3911
+ assert!(!app.shared_config_draft.tokens.contains_key("usdc"));
3912
+
3913
+ fs::remove_dir_all(config_root).expect("cleanup temp config root");
3914
+ }
3410
3915
  }