@pheem49/mint 1.9.0 → 1.10.0

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 (90) hide show
  1. package/Cargo.lock +55 -3
  2. package/Cargo.toml +2 -1
  3. package/Release_Note.md +285 -140
  4. package/bin/mint +0 -0
  5. package/crates/mint-cli/Cargo.toml +1 -0
  6. package/crates/mint-cli/src/actions.rs +233 -0
  7. package/crates/mint-cli/src/agent.rs +287 -43
  8. package/crates/mint-cli/src/gmail.rs +22 -2
  9. package/crates/mint-cli/src/interactive.rs +2888 -0
  10. package/crates/mint-cli/src/main.rs +336 -2854
  11. package/crates/mint-cli/src/markdown.rs +294 -0
  12. package/crates/mint-cli/src/onboard.rs +44 -1
  13. package/crates/mint-cli/src/updater.rs +48 -6
  14. package/crates/mint-core/Cargo.toml +1 -0
  15. package/crates/mint-core/src/agent_loop.rs +1 -0
  16. package/crates/mint-core/src/api_server.rs +376 -13
  17. package/crates/mint-core/src/browser.rs +629 -101
  18. package/crates/mint-core/src/channels.rs +105 -6
  19. package/crates/mint-core/src/chat.rs +52 -4
  20. package/crates/mint-core/src/code_tools.rs +29 -11
  21. package/crates/mint-core/src/config.rs +118 -0
  22. package/crates/mint-core/src/image_gen.rs +294 -33
  23. package/crates/mint-core/src/lib.rs +5 -2
  24. package/crates/mint-core/src/mcp.rs +119 -6
  25. package/crates/mint-core/src/orchestration.rs +133 -11
  26. package/crates/mint-core/src/pictures.rs +64 -1
  27. package/crates/mint-core/src/skills.rs +82 -6
  28. package/crates/mint-core/src/video_gen.rs +235 -0
  29. package/package.json +8 -2
  30. package/shared-modules/luajit/luajit.json +2 -2
  31. package/skills-lock.json +41 -0
  32. package/src/renderer/index-web.html +1 -1
  33. package/src/renderer/index.html +1 -1
  34. package/src/renderer/shared/constants/slashCommands.ts +27 -0
  35. package/src/renderer/shared/platform.ts +3 -0
  36. package/src/renderer/shared/types.ts +4 -0
  37. package/src/renderer/shared/utils/approval.tsx +86 -7
  38. package/src/renderer/shared/utils/markdown.tsx +217 -88
  39. package/src/renderer/shared/utils/slashCommandProcessor.ts +239 -0
  40. package/src/renderer/src/components/ChatPanel.tsx +461 -64
  41. package/src/renderer/src/components/DashboardSidebar.tsx +11 -2
  42. package/src/renderer/src/components/ImageStudioPanel.tsx +87 -4
  43. package/src/renderer/src/components/MintDashboard.tsx +150 -5
  44. package/src/renderer/src/components/PicturesLibrary.tsx +156 -15
  45. package/src/renderer/src/components/Settings/AutomationTab.tsx +316 -18
  46. package/src/renderer/src/components/Settings/GeneralTab.tsx +762 -634
  47. package/src/renderer/src/components/Settings/PluginsTab.tsx +304 -70
  48. package/src/renderer/src/components/Settings/ThemeTab.tsx +6 -1
  49. package/src/renderer/src/components/SettingsWindow.tsx +38 -2
  50. package/src/renderer/src/components/VeoStudioPanel.tsx +549 -0
  51. package/src/renderer/src/css/image-studio.css +106 -0
  52. package/src/renderer/src/css/settings.css +158 -0
  53. package/src/renderer/src/css/styles.css +352 -1
  54. package/src/renderer/src/css/veo-studio.css +626 -0
  55. package/src/renderer/src/index.css +1 -0
  56. package/src/renderer/src/tauri.ts +144 -17
  57. package/src/renderer/src-web/components/ChatPanel.tsx +360 -88
  58. package/src/renderer/src-web/components/DashboardSidebar.tsx +16 -1
  59. package/src/renderer/src-web/components/ImageStudioPanel.tsx +87 -4
  60. package/src/renderer/src-web/components/MintDashboard.tsx +137 -4
  61. package/src/renderer/src-web/components/PicturesLibrary.tsx +155 -15
  62. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +316 -18
  63. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +762 -629
  64. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +304 -70
  65. package/src/renderer/src-web/components/Settings/ThemeTab.tsx +6 -1
  66. package/src/renderer/src-web/components/SettingsWindow.tsx +35 -2
  67. package/src/renderer/src-web/components/VeoStudioPanel.tsx +549 -0
  68. package/src/renderer/src-web/css/image-studio.css +106 -0
  69. package/src/renderer/src-web/css/settings.css +158 -0
  70. package/src/renderer/src-web/css/styles.css +353 -1
  71. package/src/renderer/src-web/css/veo-studio.css +650 -0
  72. package/src/renderer/src-web/index.css +1 -0
  73. package/src/renderer/src-web/tauri.ts +135 -16
  74. package/src-tauri/src/lib.rs +114 -8
  75. package/src-tauri/src/webhooks.rs +45 -31
  76. package/src-tauri/src/workflows.rs +1 -0
  77. package/src-tauri/tauri.conf.json +1 -1
  78. package/temp_gridgeist/AGENTS.md +8 -0
  79. package/temp_gridgeist/CHANGELOG.md +130 -0
  80. package/temp_gridgeist/LICENSE +21 -0
  81. package/temp_gridgeist/README.md +323 -0
  82. package/temp_gridgeist/README.th.md +325 -0
  83. package/temp_gridgeist/skills/gridgeist/SKILL.md +88 -0
  84. package/temp_gridgeist/skills/gridgeist/agents/openai.yaml +9 -0
  85. package/temp_gridgeist/skills/gridgeist/assets/gridgeist-small.svg +10 -0
  86. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.png +0 -0
  87. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.svg +18 -0
  88. package/temp_gridgeist/skills/gridgeist/references/design-language.md +88 -0
  89. package/temp_gridgeist/skills/gridgeist/references/review-checklist.md +128 -0
  90. package/temp_gridgeist/skills/gridgeist/references/system-contract.md +109 -0
@@ -10,8 +10,8 @@ use crate::MintConfig;
10
10
  // Public request / response types
11
11
  // ─────────────────────────────────────────────────────────────────────────────
12
12
 
13
- /// A request to generate images from any supported provider.
14
- #[derive(Debug, Clone, Deserialize)]
13
+ /// A request to generate or edit images from any supported provider.
14
+ #[derive(Debug, Clone, Deserialize, Default)]
15
15
  #[serde(rename_all = "camelCase")]
16
16
  pub struct ImageGenRequest {
17
17
  pub prompt: String,
@@ -30,6 +30,15 @@ pub struct ImageGenRequest {
30
30
  /// Falls back to `config.image_gen_provider` when omitted.
31
31
  #[serde(default)]
32
32
  pub provider: Option<String>,
33
+ /// Optional input image data URI (`data:image/png;base64,...`) for Image-to-Image / Inpainting
34
+ #[serde(default)]
35
+ pub image_data_uri: Option<String>,
36
+ /// Optional mask image data URI for Inpainting
37
+ #[serde(default)]
38
+ pub mask_data_uri: Option<String>,
39
+ /// Optional mode: "generate" | "edit" | "inpaint". Inferred if omitted.
40
+ #[serde(default)]
41
+ pub mode: Option<String>,
33
42
  }
34
43
 
35
44
  /// One generated image returned from the model.
@@ -76,6 +85,10 @@ pub enum ImageGenError {
76
85
  // ── Generic ──────────────────────────────────────────────────────────────
77
86
  #[error("unsupported image generation provider: {0}")]
78
87
  UnsupportedProvider(String),
88
+ #[error(
89
+ "image-to-image editing is not supported by {0} — please use Replicate, Stability AI, or DALL·E"
90
+ )]
91
+ UnsupportedMode(String),
79
92
  #[error("API request failed: {0}")]
80
93
  Request(#[from] reqwest::Error),
81
94
  #[error("the model returned no images for this prompt")]
@@ -129,6 +142,15 @@ async fn call_nanobanana(
129
142
  config: &MintConfig,
130
143
  request: &ImageGenRequest,
131
144
  ) -> Result<ImageGenResponse, ImageGenError> {
145
+ if request.image_data_uri.is_some()
146
+ || request.mode.as_deref() == Some("edit")
147
+ || request.mode.as_deref() == Some("inpaint")
148
+ {
149
+ return Err(ImageGenError::UnsupportedMode(
150
+ "NanoBanana (Gemini Text-to-Image)".to_owned(),
151
+ ));
152
+ }
153
+
132
154
  let api_key = if config.api_key.trim().is_empty() {
133
155
  std::env::var("GEMINI_API_KEY").unwrap_or_default()
134
156
  } else {
@@ -270,6 +292,105 @@ async fn call_dalle(
270
292
  return Err(ImageGenError::MissingOpenAiKey);
271
293
  }
272
294
 
295
+ if let Some(ref img_data_uri) = request.image_data_uri {
296
+ let (mime_type, img_bytes) = parse_data_uri(img_data_uri)
297
+ .ok_or_else(|| ImageGenError::ModelError("invalid base64 image data URI".to_owned()))?;
298
+
299
+ let size = aspect_ratio_to_dalle_size(request.aspect_ratio.as_deref());
300
+ let boundary = format!("MintBoundary{}", uuid_v4_hex());
301
+
302
+ let mut text_fields = vec![
303
+ ("prompt".to_owned(), request.prompt.clone()),
304
+ ("n".to_owned(), "1".to_owned()),
305
+ ("size".to_owned(), size.to_owned()),
306
+ ("response_format".to_owned(), "b64_json".to_owned()),
307
+ ("model".to_owned(), "dall-e-2".to_owned()),
308
+ ];
309
+ if let Some(ref neg) = request.negative_prompt {
310
+ if !neg.trim().is_empty() {
311
+ text_fields.push((
312
+ "prompt".to_owned(),
313
+ format!("{} (avoid: {})", request.prompt, neg),
314
+ ));
315
+ }
316
+ }
317
+
318
+ let mut file_fields = vec![(
319
+ "image",
320
+ "image.png",
321
+ mime_type.as_str(),
322
+ img_bytes.as_slice(),
323
+ )];
324
+
325
+ let mask_bytes_storage;
326
+ if let Some(ref mask_data_uri) = request.mask_data_uri {
327
+ if let Some((mask_mime, mask_bytes)) = parse_data_uri(mask_data_uri) {
328
+ mask_bytes_storage = (mask_mime, mask_bytes);
329
+ file_fields.push((
330
+ "mask",
331
+ "mask.png",
332
+ mask_bytes_storage.0.as_str(),
333
+ mask_bytes_storage.1.as_slice(),
334
+ ));
335
+ }
336
+ }
337
+
338
+ let body = build_multipart_binary(&boundary, &text_fields, &file_fields);
339
+
340
+ let response: Value = client
341
+ .post("https://api.openai.com/v1/images/edits")
342
+ .bearer_auth(&api_key)
343
+ .header(
344
+ "content-type",
345
+ format!("multipart/form-data; boundary={boundary}"),
346
+ )
347
+ .body(body)
348
+ .send()
349
+ .await?
350
+ .error_for_status()
351
+ .map_err(ImageGenError::Request)?
352
+ .json()
353
+ .await?;
354
+
355
+ if let Some(err) = response.get("error") {
356
+ let msg = err["message"]
357
+ .as_str()
358
+ .unwrap_or("unknown OpenAI API error");
359
+ return Err(ImageGenError::ModelError(msg.to_owned()));
360
+ }
361
+
362
+ let data = response["data"]
363
+ .as_array()
364
+ .ok_or(ImageGenError::UnexpectedResponse)?;
365
+
366
+ let images: Vec<GeneratedImage> = data
367
+ .iter()
368
+ .filter_map(|item| {
369
+ let b64 = item["b64_json"].as_str()?;
370
+ if b64.is_empty() || STANDARD.decode(b64).is_err() {
371
+ return None;
372
+ }
373
+ let data_uri = format!("data:image/png;base64,{b64}");
374
+ Some(GeneratedImage {
375
+ data_uri,
376
+ mime_type: "image/png".to_owned(),
377
+ })
378
+ })
379
+ .collect();
380
+
381
+ if images.is_empty() {
382
+ return Err(ImageGenError::NoImagesReturned);
383
+ }
384
+
385
+ return Ok(ImageGenResponse {
386
+ images,
387
+ model: "dall-e-2".to_owned(),
388
+ provider: "dalle".to_owned(),
389
+ prompt: request.prompt.clone(),
390
+ description: None,
391
+ });
392
+ }
393
+
273
394
  let model = request
274
395
  .model
275
396
  .as_deref()
@@ -393,42 +514,98 @@ async fn call_stability(
393
514
  }
394
515
  });
395
516
 
396
- // Route: Stable Image Core uses /core; SD3.x models use /sd3
397
- let (endpoint, model_param) = if model == "core" {
517
+ let boundary = format!("MintBoundary{}", uuid_v4_hex());
518
+
519
+ let (endpoint, raw_body) = if let Some(ref img_uri) = request.image_data_uri {
520
+ let (mime_type, img_bytes) = parse_data_uri(img_uri)
521
+ .ok_or_else(|| ImageGenError::ModelError("invalid base64 image data URI".to_owned()))?;
522
+
523
+ let is_inpaint =
524
+ request.mask_data_uri.is_some() || request.mode.as_deref() == Some("inpaint");
525
+ let endpoint = if is_inpaint {
526
+ "https://api.stability.ai/v2beta/stable-image/edit/inpaint".to_owned()
527
+ } else {
528
+ "https://api.stability.ai/v2beta/stable-image/generate/sd3".to_owned()
529
+ };
530
+
531
+ let mut body_parts: Vec<(String, String)> = vec![
532
+ ("prompt".into(), request.prompt.clone()),
533
+ ("output_format".into(), "png".to_owned()),
534
+ ];
535
+ if !is_inpaint && model != "core" {
536
+ body_parts.push(("model".into(), model.clone()));
537
+ body_parts.push(("mode".into(), "image-to-image".to_owned()));
538
+ body_parts.push(("strength".into(), "0.7".to_owned()));
539
+ }
540
+
541
+ if let Some(neg) = &request.negative_prompt {
542
+ let neg = neg.trim();
543
+ if !neg.is_empty() {
544
+ body_parts.push(("negative_prompt".into(), neg.to_owned()));
545
+ }
546
+ }
547
+
548
+ let mut file_fields = vec![(
549
+ "image",
550
+ "input.png",
551
+ mime_type.as_str(),
552
+ img_bytes.as_slice(),
553
+ )];
554
+ let mask_storage;
555
+ if let Some(ref mask_uri) = request.mask_data_uri {
556
+ if let Some((mask_mime, mask_bytes)) = parse_data_uri(mask_uri) {
557
+ mask_storage = (mask_mime, mask_bytes);
558
+ file_fields.push((
559
+ "mask",
560
+ "mask.png",
561
+ mask_storage.0.as_str(),
562
+ mask_storage.1.as_slice(),
563
+ ));
564
+ }
565
+ }
566
+
398
567
  (
399
- "https://api.stability.ai/v2beta/stable-image/generate/core".to_owned(),
400
- None,
568
+ endpoint,
569
+ build_multipart_binary(&boundary, &body_parts, &file_fields),
401
570
  )
402
571
  } else {
403
- (
404
- "https://api.stability.ai/v2beta/stable-image/generate/sd3".to_owned(),
405
- Some(model.clone()),
406
- )
407
- };
408
-
409
- let aspect_ratio = request.aspect_ratio.as_deref().unwrap_or("1:1");
572
+ // Route: Stable Image Core uses /core; SD3.x models use /sd3
573
+ let (endpoint, model_param) = if model == "core" {
574
+ (
575
+ "https://api.stability.ai/v2beta/stable-image/generate/core".to_owned(),
576
+ None,
577
+ )
578
+ } else {
579
+ (
580
+ "https://api.stability.ai/v2beta/stable-image/generate/sd3".to_owned(),
581
+ Some(model.clone()),
582
+ )
583
+ };
410
584
 
411
- // Build multipart/form-data manually (reqwest multipart feature not enabled).
412
- let boundary = format!("MintBoundary{}", uuid_v4_hex());
585
+ let aspect_ratio = request.aspect_ratio.as_deref().unwrap_or("1:1");
413
586
 
414
- let mut body_parts: Vec<(String, String)> = vec![
415
- ("prompt".into(), request.prompt.clone()),
416
- ("aspect_ratio".into(), aspect_ratio.to_owned()),
417
- ("output_format".into(), "png".to_owned()),
418
- ];
587
+ let mut body_parts: Vec<(String, String)> = vec![
588
+ ("prompt".into(), request.prompt.clone()),
589
+ ("aspect_ratio".into(), aspect_ratio.to_owned()),
590
+ ("output_format".into(), "png".to_owned()),
591
+ ];
419
592
 
420
- if let Some(neg) = &request.negative_prompt {
421
- let neg = neg.trim();
422
- if !neg.is_empty() {
423
- body_parts.push(("negative_prompt".into(), neg.to_owned()));
593
+ if let Some(neg) = &request.negative_prompt {
594
+ let neg = neg.trim();
595
+ if !neg.is_empty() {
596
+ body_parts.push(("negative_prompt".into(), neg.to_owned()));
597
+ }
424
598
  }
425
- }
426
599
 
427
- if let Some(m) = model_param {
428
- body_parts.push(("model".into(), m));
429
- }
600
+ if let Some(m) = model_param {
601
+ body_parts.push(("model".into(), m));
602
+ }
430
603
 
431
- let raw_body = build_multipart_text(&boundary, &body_parts);
604
+ (
605
+ endpoint,
606
+ build_multipart_text(&boundary, &body_parts).into_bytes(),
607
+ )
608
+ };
432
609
 
433
610
  let response = client
434
611
  .post(&endpoint)
@@ -604,17 +781,23 @@ async fn call_replicate(
604
781
  .map(str::to_owned)
605
782
  .unwrap_or_else(|| {
606
783
  let m = config.replicate_model.trim();
607
- if m.is_empty() {
608
- "black-forest-labs/flux-1.1-pro".to_owned()
609
- } else {
784
+ if !m.is_empty() {
610
785
  m.to_owned()
786
+ } else if request.image_data_uri.is_some() {
787
+ if request.mask_data_uri.is_some() {
788
+ "black-forest-labs/flux-fill-dev".to_owned()
789
+ } else {
790
+ "timbrooks/instruct-pix2pix".to_owned()
791
+ }
792
+ } else {
793
+ "black-forest-labs/flux-1.1-pro".to_owned()
611
794
  }
612
795
  });
613
796
 
614
797
  let num_images = request.num_images.unwrap_or(1).clamp(1, 4);
615
798
  let aspect_ratio = request.aspect_ratio.as_deref().unwrap_or("1:1");
616
799
 
617
- // Build input — common fields used by FLUX and SDXL families
800
+ // Build input — common fields used by FLUX, InstructPix2Pix, and SDXL families
618
801
  let mut input = json!({
619
802
  "prompt": request.prompt,
620
803
  "num_outputs": num_images,
@@ -623,6 +806,13 @@ async fn call_replicate(
623
806
  "output_quality": 90
624
807
  });
625
808
 
809
+ if let Some(ref img_uri) = request.image_data_uri {
810
+ input["image"] = json!(img_uri);
811
+ if let Some(ref mask_uri) = request.mask_data_uri {
812
+ input["mask"] = json!(mask_uri);
813
+ }
814
+ }
815
+
626
816
  if let Some(neg) = &request.negative_prompt {
627
817
  let neg = neg.trim();
628
818
  if !neg.is_empty() {
@@ -790,6 +980,58 @@ fn build_multipart_text(boundary: &str, fields: &[(String, String)]) -> String {
790
980
  body
791
981
  }
792
982
 
983
+ /// Parse base64 data URI (e.g. data:image/png;base64,...) into (mime_type, bytes).
984
+ fn parse_data_uri(data_uri: &str) -> Option<(String, Vec<u8>)> {
985
+ if !data_uri.starts_with("data:") {
986
+ return None;
987
+ }
988
+ let parts: Vec<&str> = data_uri.splitn(2, ',').collect();
989
+ if parts.len() != 2 {
990
+ return None;
991
+ }
992
+ let header = parts[0];
993
+ let b64 = parts[1];
994
+
995
+ let mime_type = header
996
+ .trim_start_matches("data:")
997
+ .split(';')
998
+ .next()
999
+ .unwrap_or("image/png")
1000
+ .trim()
1001
+ .to_string();
1002
+
1003
+ let bytes = STANDARD.decode(b64.trim()).ok()?;
1004
+ Some((mime_type, bytes))
1005
+ }
1006
+
1007
+ /// Build multipart body containing both text fields and binary file fields.
1008
+ fn build_multipart_binary(
1009
+ boundary: &str,
1010
+ text_fields: &[(String, String)],
1011
+ file_fields: &[(&str, &str, &str, &[u8])],
1012
+ ) -> Vec<u8> {
1013
+ let mut body = Vec::new();
1014
+ for (name, value) in text_fields {
1015
+ body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
1016
+ body.extend_from_slice(
1017
+ format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
1018
+ );
1019
+ body.extend_from_slice(value.as_bytes());
1020
+ body.extend_from_slice(b"\r\n");
1021
+ }
1022
+ for (param_name, file_name, mime_type, bytes) in file_fields {
1023
+ body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
1024
+ body.extend_from_slice(
1025
+ format!("Content-Disposition: form-data; name=\"{param_name}\"; filename=\"{file_name}\"\r\n").as_bytes(),
1026
+ );
1027
+ body.extend_from_slice(format!("Content-Type: {mime_type}\r\n\r\n").as_bytes());
1028
+ body.extend_from_slice(bytes);
1029
+ body.extend_from_slice(b"\r\n");
1030
+ }
1031
+ body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
1032
+ body
1033
+ }
1034
+
793
1035
  /// Build the final prompt string, optionally embedding aspect ratio and negative
794
1036
  /// prompt guidance. Used by providers that accept a plain text prompt.
795
1037
  fn build_prompt(request: &ImageGenRequest) -> String {
@@ -832,6 +1074,9 @@ mod tests {
832
1074
  num_images: None,
833
1075
  model: None,
834
1076
  provider: None,
1077
+ image_data_uri: None,
1078
+ mask_data_uri: None,
1079
+ mode: None,
835
1080
  }
836
1081
  }
837
1082
 
@@ -850,6 +1095,9 @@ mod tests {
850
1095
  num_images: Some(2),
851
1096
  model: None,
852
1097
  provider: None,
1098
+ image_data_uri: None,
1099
+ mask_data_uri: None,
1100
+ mode: None,
853
1101
  };
854
1102
  let result = build_prompt(&req);
855
1103
  assert!(result.contains("a cat"));
@@ -866,6 +1114,9 @@ mod tests {
866
1114
  num_images: None,
867
1115
  model: None,
868
1116
  provider: None,
1117
+ image_data_uri: None,
1118
+ mask_data_uri: None,
1119
+ mode: None,
869
1120
  };
870
1121
  assert!(!build_prompt(&req).contains("aspect ratio"));
871
1122
  }
@@ -892,4 +1143,14 @@ mod tests {
892
1143
  assert_eq!(aspect_ratio_to_ideogram(Some("1:1")), "ASPECT_1_1");
893
1144
  assert_eq!(aspect_ratio_to_ideogram(None), "ASPECT_1_1");
894
1145
  }
1146
+
1147
+ #[test]
1148
+ fn parse_data_uri_valid() {
1149
+ let uri = "data:image/png;base64,aGVsbG8=";
1150
+ let parsed = parse_data_uri(uri);
1151
+ assert!(parsed.is_some());
1152
+ let (mime, bytes) = parsed.unwrap();
1153
+ assert_eq!(mime, "image/png");
1154
+ assert_eq!(bytes, b"hello");
1155
+ }
895
1156
  }
@@ -20,6 +20,7 @@ pub mod skills;
20
20
  pub mod symbols;
21
21
  pub mod tasks;
22
22
  pub mod tts;
23
+ pub mod video_gen;
23
24
  pub mod weather;
24
25
  pub mod web_search;
25
26
  pub mod workflows;
@@ -31,8 +32,9 @@ pub use chat::{
31
32
  };
32
33
 
33
34
  pub use browser::{
34
- BrowserTab, click, is_browser_running, list_tabs, navigate, read_page_text,
35
- spawn_automation_browser, type_text,
35
+ BrowserTab, click, get_element_coordinates, is_browser_running, key_press, list_tabs,
36
+ mouse_click, mouse_move, navigate, read_page_text, screenshot, spawn_automation_browser,
37
+ type_text, type_text_native,
36
38
  };
37
39
  pub use code_tools::{
38
40
  AppliedCodeEdit, CodeEdit, CodeEditPreview, CodeEditProposal, CodeFile, CodeInspectionError,
@@ -82,6 +84,7 @@ pub use skills::{SkillError, learn_skill, learned_skills_context};
82
84
  pub use symbols::{CodeSymbol, SymbolError, SymbolIndex, build_symbol_index};
83
85
  pub use tasks::{Task, TaskError, TaskStore, tasks_path};
84
86
  pub use tts::{TtsUrl, google_tts_urls};
87
+ pub use video_gen::{VideoGenError, VideoGenRequest, VideoGenResponse, generate_video};
85
88
  pub use weather::{WeatherError, WeatherReport, weather};
86
89
  pub use workflows::{WorkflowError, load_workflows, save_workflows, workflows_path};
87
90
  pub mod api_server;
@@ -2,7 +2,10 @@ use std::{
2
2
  collections::BTreeMap,
3
3
  io::{BufRead, BufReader, Write},
4
4
  process::{Child, Command, Stdio},
5
- sync::mpsc,
5
+ sync::{
6
+ atomic::{AtomicBool, Ordering},
7
+ mpsc,
8
+ },
6
9
  time::{Duration, Instant},
7
10
  };
8
11
 
@@ -12,7 +15,8 @@ use thiserror::Error;
12
15
 
13
16
  use crate::{ConfigError, MintConfig, load_config, save_config};
14
17
 
15
- const MCP_TIMEOUT: Duration = Duration::from_secs(10);
18
+ const MCP_TIMEOUT: Duration = Duration::from_secs(30);
19
+ static OAUTH_DETECTED: AtomicBool = AtomicBool::new(false);
16
20
 
17
21
  #[derive(Debug, Clone, Deserialize, Serialize)]
18
22
  pub struct McpServer {
@@ -21,6 +25,8 @@ pub struct McpServer {
21
25
  pub args: Vec<String>,
22
26
  #[serde(default)]
23
27
  pub env: BTreeMap<String, String>,
28
+ #[serde(default, skip_serializing_if = "Option::is_none")]
29
+ pub icon: Option<String>,
24
30
  }
25
31
 
26
32
  #[derive(Debug, Error)]
@@ -33,7 +39,9 @@ pub enum McpError {
33
39
  InvalidEnvironment,
34
40
  #[error("MCP server '{0}' is not configured")]
35
41
  MissingServer(String),
36
- #[error("MCP tool '{server}/{tool}' is not allowed by policy")]
42
+ #[error(
43
+ "MCP tool '{server}/{tool}' is not allowed by policy. To allow it, please run: /mcp allow {server} {tool} (or /mcp allow {server} * to allow all tools on this server)"
44
+ )]
37
45
  NotAllowed { server: String, tool: String },
38
46
  #[error("unable to start MCP server '{command}': {source}")]
39
47
  Start {
@@ -82,6 +90,7 @@ pub fn add_mcp_server(
82
90
  command: command.into(),
83
91
  args,
84
92
  env: parse_env(env)?,
93
+ icon: None,
85
94
  },
86
95
  );
87
96
  save_mcp_servers(&mut config, servers)
@@ -186,18 +195,86 @@ fn parse_env(values: Vec<String>) -> Result<BTreeMap<String, String>, McpError>
186
195
  .collect()
187
196
  }
188
197
 
198
+ fn find_url(line: &str) -> Option<String> {
199
+ let start_idx = line.find("http://").or_else(|| line.find("https://"))?;
200
+ let rest = &line[start_idx..];
201
+ let end_idx = rest
202
+ .find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '<' || c == '>')
203
+ .unwrap_or(rest.len());
204
+ let url = rest[..end_idx].to_string();
205
+
206
+ let lower = url.to_lowercase();
207
+ if lower.contains("registry.npmjs.org")
208
+ || lower.contains("npmjs.com")
209
+ || lower.contains("github.com/modelcontextprotocol")
210
+ {
211
+ return None;
212
+ }
213
+
214
+ if lower.contains("oauth")
215
+ || lower.contains("auth")
216
+ || lower.contains("login")
217
+ || lower.contains("google.com")
218
+ || lower.contains("authorize")
219
+ {
220
+ Some(url)
221
+ } else {
222
+ None
223
+ }
224
+ }
225
+
226
+ fn open_url_in_browser(url: &str) -> std::io::Result<()> {
227
+ #[cfg(target_os = "linux")]
228
+ {
229
+ Command::new("xdg-open").arg(url).spawn().map(|_| ())
230
+ }
231
+ #[cfg(target_os = "macos")]
232
+ {
233
+ Command::new("open").arg(url).spawn().map(|_| ())
234
+ }
235
+ #[cfg(target_os = "windows")]
236
+ {
237
+ Command::new("cmd")
238
+ .args(&["/C", "start", url])
239
+ .spawn()
240
+ .map(|_| ())
241
+ }
242
+ #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
243
+ {
244
+ let _ = url;
245
+ Ok(())
246
+ }
247
+ }
248
+
189
249
  fn start_server(server: &McpServer) -> Result<Child, McpError> {
190
250
  let mut process = Command::new(&server.command)
191
251
  .args(&server.args)
192
252
  .envs(&server.env)
193
253
  .stdin(Stdio::piped())
194
254
  .stdout(Stdio::piped())
195
- .stderr(Stdio::null())
255
+ .stderr(Stdio::piped())
196
256
  .spawn()
197
257
  .map_err(|source| McpError::Start {
198
258
  command: server.command.clone(),
199
259
  source,
200
260
  })?;
261
+
262
+ if let Some(stderr) = process.stderr.take() {
263
+ std::thread::spawn(move || {
264
+ let reader = BufReader::new(stderr);
265
+ for line in reader.lines().map_while(Result::ok) {
266
+ if let Some(url) = find_url(&line) {
267
+ println!(
268
+ "\n\x1b[1;33m[MCP Authorization Needed]\x1b[0m Opening browser to authenticate: {}\n",
269
+ url
270
+ );
271
+ OAUTH_DETECTED.store(true, Ordering::Relaxed);
272
+ let _ = open_url_in_browser(&url);
273
+ }
274
+ }
275
+ });
276
+ }
277
+
201
278
  write_message(
202
279
  &mut process,
203
280
  &json!({
@@ -224,26 +301,62 @@ fn exchange(process: &mut Child, request: Value) -> Result<Value, McpError> {
224
301
  let (sender, receiver) = mpsc::channel();
225
302
  std::thread::spawn(move || {
226
303
  for line in BufReader::new(stdout).lines().map_while(Result::ok) {
304
+ if let Some(url) = find_url(&line) {
305
+ println!(
306
+ "\n\x1b[1;33m[MCP Authorization Needed]\x1b[0m Opening browser to authenticate: {}\n",
307
+ url
308
+ );
309
+ OAUTH_DETECTED.store(true, Ordering::Relaxed);
310
+ let _ = open_url_in_browser(&url);
311
+ }
227
312
  if let Ok(value) = serde_json::from_str::<Value>(&line) {
228
313
  let _ = sender.send(value);
229
314
  }
230
315
  }
231
316
  });
317
+
318
+ let timeout = if OAUTH_DETECTED.load(Ordering::Relaxed) {
319
+ Duration::from_secs(120)
320
+ } else {
321
+ MCP_TIMEOUT
322
+ };
323
+
232
324
  let started = Instant::now();
233
- while started.elapsed() < MCP_TIMEOUT {
325
+ while started.elapsed() < timeout {
234
326
  let response = receiver
235
- .recv_timeout(MCP_TIMEOUT.saturating_sub(started.elapsed()))
327
+ .recv_timeout(timeout.saturating_sub(started.elapsed()))
236
328
  .map_err(|_| McpError::Timeout)?;
237
329
  if response["id"] == 2 {
330
+ OAUTH_DETECTED.store(false, Ordering::Relaxed);
238
331
  if let Some(error) = response.get("error") {
239
332
  return Err(McpError::Tool(error.clone()));
240
333
  }
241
334
  return Ok(response.get("result").cloned().unwrap_or(Value::Null));
242
335
  }
243
336
  }
337
+ OAUTH_DETECTED.store(false, Ordering::Relaxed);
244
338
  Err(McpError::Timeout)
245
339
  }
246
340
 
341
+ pub fn list_server_tools(config: &MintConfig, server_name: &str) -> Result<Value, McpError> {
342
+ let servers = configured_mcp_servers(config)?;
343
+ let server = servers
344
+ .get(server_name)
345
+ .ok_or_else(|| McpError::MissingServer(server_name.into()))?;
346
+ let mut process = start_server(server)?;
347
+ let result = exchange(
348
+ &mut process,
349
+ json!({
350
+ "jsonrpc": "2.0",
351
+ "id": 2,
352
+ "method": "tools/list",
353
+ "params": {}
354
+ }),
355
+ );
356
+ let _ = process.kill();
357
+ result
358
+ }
359
+
247
360
  fn write_message(process: &mut Child, message: &Value) -> Result<(), McpError> {
248
361
  let stdin = process.stdin.as_mut().ok_or(McpError::MissingStdin)?;
249
362
  writeln!(stdin, "{message}").map_err(McpError::Write)?;