deepagents-cli 0.0.27__tar.gz → 0.0.28__tar.gz

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 (124) hide show
  1. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/CHANGELOG.md +15 -0
  2. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/PKG-INFO +1 -1
  3. deepagents_cli-0.0.28/deepagents_cli/_version.py +3 -0
  4. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/app.py +10 -3
  5. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/config.py +19 -3
  6. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/input.py +143 -33
  7. deepagents_cli-0.0.27/deepagents_cli/image_utils.py → deepagents_cli-0.0.28/deepagents_cli/media_utils.py +184 -9
  8. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/textual_adapter.py +11 -7
  9. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/tool_display.py +13 -2
  10. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/autocomplete.py +90 -25
  11. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/chat_input.py +74 -47
  12. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/model_selector.py +18 -8
  13. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/status.py +54 -23
  14. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/pyproject.toml +1 -1
  15. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_app.py +1 -1
  16. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_autocomplete.py +114 -2
  17. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_chat_input.py +143 -10
  18. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_compact.py +2 -2
  19. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_config.py +2 -0
  20. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_exception_handling.py +16 -16
  21. deepagents_cli-0.0.28/tests/unit_tests/test_media_utils.py +733 -0
  22. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_model_selector.py +83 -0
  23. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_ui.py +28 -0
  24. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/uv.lock +1 -1
  25. deepagents_cli-0.0.27/deepagents_cli/_version.py +0 -3
  26. deepagents_cli-0.0.27/tests/unit_tests/test_image_utils.py +0 -360
  27. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/.gitignore +0 -0
  28. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/Makefile +0 -0
  29. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/README.md +0 -0
  30. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/__init__.py +0 -0
  31. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/__main__.py +0 -0
  32. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/agent.py +0 -0
  33. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/app.tcss +0 -0
  34. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/built_in_skills/__init__.py +0 -0
  35. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/built_in_skills/skill-creator/SKILL.md +0 -0
  36. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/built_in_skills/skill-creator/scripts/init_skill.py +0 -0
  37. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/built_in_skills/skill-creator/scripts/quick_validate.py +0 -0
  38. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/clipboard.py +0 -0
  39. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/default_agent_prompt.md +0 -0
  40. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/file_ops.py +0 -0
  41. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/integrations/__init__.py +0 -0
  42. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/integrations/daytona.py +0 -0
  43. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/integrations/langsmith.py +0 -0
  44. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/integrations/modal.py +0 -0
  45. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/integrations/runloop.py +0 -0
  46. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/integrations/sandbox_factory.py +0 -0
  47. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/integrations/sandbox_provider.py +0 -0
  48. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/local_context.py +0 -0
  49. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/main.py +0 -0
  50. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/model_config.py +0 -0
  51. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/non_interactive.py +0 -0
  52. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/project_utils.py +0 -0
  53. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/py.typed +0 -0
  54. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/sessions.py +0 -0
  55. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/skills/__init__.py +0 -0
  56. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/skills/commands.py +0 -0
  57. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/skills/load.py +0 -0
  58. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/subagents.py +0 -0
  59. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/system_prompt.md +0 -0
  60. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/tools.py +0 -0
  61. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/ui.py +0 -0
  62. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/update_check.py +0 -0
  63. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/__init__.py +0 -0
  64. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/_links.py +0 -0
  65. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/approval.py +0 -0
  66. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/diff.py +0 -0
  67. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/history.py +0 -0
  68. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/loading.py +0 -0
  69. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/message_store.py +0 -0
  70. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/messages.py +0 -0
  71. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/thread_selector.py +0 -0
  72. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/tool_renderers.py +0 -0
  73. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/tool_widgets.py +0 -0
  74. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/deepagents_cli/widgets/welcome.py +0 -0
  75. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/examples/skills/arxiv-search/SKILL.md +0 -0
  76. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/examples/skills/arxiv-search/arxiv_search.py +0 -0
  77. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/examples/skills/langgraph-docs/SKILL.md +0 -0
  78. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/examples/skills/skill-creator/SKILL.md +0 -0
  79. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/examples/skills/skill-creator/scripts/init_skill.py +0 -0
  80. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/examples/skills/skill-creator/scripts/quick_validate.py +0 -0
  81. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/examples/skills/web-research/SKILL.md +0 -0
  82. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/images/cli.png +0 -0
  83. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/scripts/check_imports.py +0 -0
  84. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/README.md +0 -0
  85. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/integration_tests/__init__.py +0 -0
  86. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/integration_tests/benchmarks/__init__.py +0 -0
  87. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/integration_tests/benchmarks/test_startup_benchmarks.py +0 -0
  88. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/integration_tests/conftest.py +0 -0
  89. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/integration_tests/test_sandbox_factory.py +0 -0
  90. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/integration_tests/test_sandbox_operations.py +0 -0
  91. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/__init__.py +0 -0
  92. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/conftest.py +0 -0
  93. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/skills/__init__.py +0 -0
  94. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/skills/test_commands.py +0 -0
  95. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/skills/test_load.py +0 -0
  96. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_agent.py +0 -0
  97. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_approval.py +0 -0
  98. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_args.py +0 -0
  99. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_charset.py +0 -0
  100. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_compact_tool.py +0 -0
  101. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_end_to_end.py +0 -0
  102. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_file_ops.py +0 -0
  103. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_history.py +0 -0
  104. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_imports.py +0 -0
  105. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_input_parsing.py +0 -0
  106. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_local_context.py +0 -0
  107. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_main.py +0 -0
  108. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_main_args.py +0 -0
  109. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_message_store.py +0 -0
  110. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_messages.py +0 -0
  111. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_model_config.py +0 -0
  112. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_model_switch.py +0 -0
  113. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_non_interactive.py +0 -0
  114. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_sessions.py +0 -0
  115. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_shell_allow_list.py +0 -0
  116. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_subagents.py +0 -0
  117. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_textual_adapter.py +0 -0
  118. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_thread_selector.py +0 -0
  119. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_token_tracker.py +0 -0
  120. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_update_check.py +0 -0
  121. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_version.py +0 -0
  122. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/test_welcome.py +0 -0
  123. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/tools/__init__.py +0 -0
  124. {deepagents_cli-0.0.27 → deepagents_cli-0.0.28}/tests/unit_tests/tools/test_fetch_url.py +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.0.28](https://github.com/langchain-ai/deepagents/compare/deepagents-cli==0.0.27...deepagents-cli==0.0.28) (2026-03-05)
4
+
5
+ ### Features
6
+
7
+ * Add video support to multimodal inputs ([#1521](https://github.com/langchain-ai/deepagents/issues/1521)) ([f9b49b7](https://github.com/langchain-ai/deepagents/commit/f9b49b7341bd42b5278a03496743e4709689598e))
8
+ * Add NVIDIA api key support and default model ([#1577](https://github.com/langchain-ai/deepagents/issues/1577)) ([9ce2660](https://github.com/langchain-ai/deepagents/commit/9ce2660a67c3497cff18d27131fb7ef49e85b310))
9
+ * Fuzzy search for slash command autocomplete ([#1660](https://github.com/langchain-ai/deepagents/issues/1660)) ([5f6e9c0](https://github.com/langchain-ai/deepagents/commit/5f6e9c014e6a99783b3113184cc12f0179a902f0))
10
+ * Tab autocomplete in model selector ([#1669](https://github.com/langchain-ai/deepagents/issues/1669)) ([28bd0aa](https://github.com/langchain-ai/deepagents/commit/28bd0aaca737b8bb194ecb9f6612989b9aacec02))
11
+
12
+ ### Bug Fixes
13
+
14
+ * Backspace at cursor position 0 exits mode even with text ([#1666](https://github.com/langchain-ai/deepagents/issues/1666)) ([dfa4c1f](https://github.com/langchain-ai/deepagents/commit/dfa4c1fedcecf2bb17d8ffef01cf50efe6c80fb0))
15
+ * Skip auto-approve toggle when modal screen is open ([#1668](https://github.com/langchain-ai/deepagents/issues/1668)) ([6597f0b](https://github.com/langchain-ai/deepagents/commit/6597f0b8da3c3bd701a42e228660d459cefe3f64))
16
+ * Truncate model name in status bar on narrow terminals ([#1665](https://github.com/langchain-ai/deepagents/issues/1665)) ([0e24a04](https://github.com/langchain-ai/deepagents/commit/0e24a04aa9e5894735522ce23295bb27fd2b8190))
17
+
3
18
  ## [0.0.27](https://github.com/langchain-ai/deepagents/compare/deepagents-cli==0.0.26...deepagents-cli==0.0.27) (2026-03-04)
4
19
 
5
20
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepagents-cli
3
- Version: 0.0.27
3
+ Version: 0.0.28
4
4
  Summary: Terminal interface for Deep Agents - interactive AI agent with file operations, shell access, and sub-agent capabilities.
5
5
  Project-URL: Homepage, https://docs.langchain.com/oss/python/deepagents/overview
6
6
  Project-URL: Documentation, https://reference.langchain.com/python/deepagents/
@@ -0,0 +1,3 @@
1
+ """Version information for `deepagents-cli`."""
2
+
3
+ __version__ = "0.0.28" # x-release-please-version
@@ -497,9 +497,9 @@ class DeepAgentsApp(App):
497
497
  self._message_store = MessageStore()
498
498
  # Lazily imported here to avoid pulling image dependencies into
499
499
  # argument parsing paths.
500
- from deepagents_cli.input import ImageTracker
500
+ from deepagents_cli.input import MediaTracker
501
501
 
502
- self._image_tracker = ImageTracker()
502
+ self._image_tracker = MediaTracker()
503
503
 
504
504
  def compose(self) -> ComposeResult:
505
505
  """Compose the application layout.
@@ -2484,6 +2484,10 @@ class DeepAgentsApp(App):
2484
2484
  web search, URL fetch) run without prompting. Updates the status
2485
2485
  bar indicator and session state.
2486
2486
  """
2487
+ # shift+tab is reused for navigation inside modal screens (e.g.
2488
+ # ModelSelectorScreen); skip the toggle so it doesn't fire through.
2489
+ if isinstance(self.screen, ModalScreen):
2490
+ return
2487
2491
  self._auto_approve = not self._auto_approve
2488
2492
  if self._status_bar:
2489
2493
  self._status_bar.set_auto_approve(enabled=self._auto_approve)
@@ -2892,7 +2896,10 @@ class DeepAgentsApp(App):
2892
2896
  # Post-swap: update UI and save config
2893
2897
  display = f"{settings.model_provider}:{settings.model_name}"
2894
2898
  if self._status_bar:
2895
- self._status_bar.set_model(display)
2899
+ self._status_bar.set_model(
2900
+ provider=settings.model_provider or "",
2901
+ model=settings.model_name or "",
2902
+ )
2896
2903
 
2897
2904
  config_saved = save_recent_model(display)
2898
2905
  if config_saved:
@@ -383,6 +383,7 @@ class Settings:
383
383
  openai_api_key: OpenAI API key if available.
384
384
  anthropic_api_key: Anthropic API key if available.
385
385
  google_api_key: Google API key if available.
386
+ nvidia_api_key: NVIDIA API key if available.
386
387
  tavily_api_key: Tavily API key if available.
387
388
  google_cloud_project: Google Cloud project ID for VertexAI
388
389
  authentication.
@@ -401,6 +402,7 @@ class Settings:
401
402
  openai_api_key: str | None
402
403
  anthropic_api_key: str | None
403
404
  google_api_key: str | None
405
+ nvidia_api_key: str | None
404
406
  tavily_api_key: str | None
405
407
 
406
408
  # Google Cloud configuration (for VertexAI)
@@ -435,6 +437,7 @@ class Settings:
435
437
  openai_key = os.environ.get("OPENAI_API_KEY")
436
438
  anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
437
439
  google_key = os.environ.get("GOOGLE_API_KEY")
440
+ nvidia_key = os.environ.get("NVIDIA_API_KEY")
438
441
  tavily_key = os.environ.get("TAVILY_API_KEY")
439
442
  google_cloud_project = os.environ.get("GOOGLE_CLOUD_PROJECT")
440
443
 
@@ -459,6 +462,7 @@ class Settings:
459
462
  openai_api_key=openai_key,
460
463
  anthropic_api_key=anthropic_key,
461
464
  google_api_key=google_key,
465
+ nvidia_api_key=nvidia_key,
462
466
  tavily_api_key=tavily_key,
463
467
  google_cloud_project=google_cloud_project,
464
468
  deepagents_langchain_project=deepagents_langchain_project,
@@ -482,6 +486,11 @@ class Settings:
482
486
  """Check if Google API key is configured."""
483
487
  return self.google_api_key is not None
484
488
 
489
+ @property
490
+ def has_nvidia(self) -> bool:
491
+ """Check if NVIDIA API key is configured."""
492
+ return self.nvidia_api_key is not None
493
+
485
494
  @property
486
495
  def has_vertex_ai(self) -> bool:
487
496
  """Check if VertexAI is available (Google Cloud project set, no API key).
@@ -1084,8 +1093,9 @@ def detect_provider(model_name: str) -> str | None:
1084
1093
  model_name: Model name to detect provider from.
1085
1094
 
1086
1095
  Returns:
1087
- Provider name (openai, anthropic, google_genai, google_vertexai) or
1088
- `None` if the provider cannot be determined from the name alone.
1096
+ Provider name (openai, anthropic, google_genai, google_vertexai,
1097
+ nvidia) or `None` if the provider cannot be determined from the
1098
+ name alone.
1089
1099
  """
1090
1100
  model_lower = model_name.lower()
1091
1101
 
@@ -1102,6 +1112,9 @@ def detect_provider(model_name: str) -> str | None:
1102
1112
  return "google_vertexai"
1103
1113
  return "google_genai"
1104
1114
 
1115
+ if model_lower.startswith(("nemotron", "nvidia/")):
1116
+ return "nvidia"
1117
+
1105
1118
  return None
1106
1119
 
1107
1120
 
@@ -1135,11 +1148,13 @@ def _get_default_model_spec() -> str:
1135
1148
  return "google_genai:gemini-3.1-pro-preview"
1136
1149
  if settings.has_vertex_ai:
1137
1150
  return "google_vertexai:gemini-3.1-pro-preview"
1151
+ if settings.has_nvidia:
1152
+ return "nvidia:nvidia/nemotron-3-nano-30b-a3b"
1138
1153
 
1139
1154
  msg = (
1140
1155
  "No credentials configured. Please set one of: "
1141
1156
  "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, "
1142
- "or GOOGLE_CLOUD_PROJECT"
1157
+ "GOOGLE_CLOUD_PROJECT, or NVIDIA_API_KEY"
1143
1158
  )
1144
1159
  raise ModelConfigError(msg)
1145
1160
 
@@ -1309,6 +1324,7 @@ def _create_model_via_init(
1309
1324
  "openai": "langchain-openai",
1310
1325
  "google_genai": "langchain-google-genai",
1311
1326
  "google_vertexai": "langchain-google-vertexai",
1327
+ "nvidia": "langchain-nvidia-ai-endpoints",
1312
1328
  }
1313
1329
  package = package_map.get(provider, f"langchain-{provider}")
1314
1330
  msg = (
@@ -1,14 +1,15 @@
1
- """Input handling utilities including image tracking and file mention parsing."""
1
+ """Input handling utilities including image/video tracking and file mention parsing."""
2
2
 
3
3
  import logging
4
4
  import re
5
5
  import shlex
6
6
  from dataclasses import dataclass
7
7
  from pathlib import Path
8
+ from typing import Literal
8
9
  from urllib.parse import unquote, urlparse
9
10
 
10
11
  from deepagents_cli.config import console
11
- from deepagents_cli.image_utils import ImageData
12
+ from deepagents_cli.media_utils import ImageData, VideoData
12
13
 
13
14
  logger = logging.getLogger(__name__)
14
15
 
@@ -52,6 +53,9 @@ in `UserMessage.compose()` additionally checks `start == 0` before styling
52
53
  slash commands, so a `/` mid-string is not highlighted.
53
54
  """
54
55
 
56
+ MediaKind = Literal["image", "video"]
57
+ """Accepted values for the `kind` parameter in `MediaTracker` methods."""
58
+
55
59
  IMAGE_PLACEHOLDER_PATTERN = re.compile(r"\[image (?P<id>\d+)\]")
56
60
  """Pattern for image placeholders with a named `id` capture group.
57
61
 
@@ -59,6 +63,13 @@ Used to extract numeric IDs from placeholder tokens so the tracker can prune
59
63
  stale entries and compute the next available ID.
60
64
  """
61
65
 
66
+ VIDEO_PLACEHOLDER_PATTERN = re.compile(r"\[video (?P<id>\d+)\]")
67
+ """Pattern for video placeholders with a named `id` capture group.
68
+
69
+ Used to extract numeric IDs from placeholder tokens so the tracker can prune
70
+ stale entries and compute the next available ID.
71
+ """
72
+
62
73
  _UNICODE_SPACE_EQUIVALENTS = str.maketrans(
63
74
  {
64
75
  "\u00a0": " ", # NO-BREAK SPACE
@@ -91,32 +102,76 @@ class ParsedPastedPathPayload:
91
102
  token_end: int | None = None
92
103
 
93
104
 
94
- class ImageTracker:
95
- """Track pasted images in the current conversation."""
105
+ class MediaTracker:
106
+ """Track pasted images and videos in the current conversation."""
96
107
 
97
108
  def __init__(self) -> None:
98
- """Initialize an empty image tracker.
109
+ """Initialize an empty media tracker.
99
110
 
100
- Sets up an empty list to store images and initializes the ID counter
101
- to 1 for generating unique placeholder identifiers.
111
+ Sets up empty lists to store images and videos, and initializes the
112
+ ID counters to 1 for generating unique placeholder identifiers.
102
113
  """
103
114
  self.images: list[ImageData] = []
104
- self.next_id = 1
115
+ self.videos: list[VideoData] = []
116
+ self.next_image_id: int = 1
117
+ self.next_video_id: int = 1
118
+
119
+ def add_media(self, data: ImageData | VideoData, kind: MediaKind) -> str:
120
+ """Add a media item and return its placeholder text.
121
+
122
+ Args:
123
+ data: The image or video data to track.
124
+ kind: Media type key.
125
+
126
+ Returns:
127
+ Placeholder string like "[image 1]" or "[video 1]".
128
+ """
129
+ if kind == "image":
130
+ placeholder = f"[image {self.next_image_id}]"
131
+ data.placeholder = placeholder
132
+ self.images.append(data) # type: ignore[arg-type]
133
+ self.next_image_id += 1
134
+ else:
135
+ placeholder = f"[video {self.next_video_id}]"
136
+ data.placeholder = placeholder
137
+ self.videos.append(data) # type: ignore[arg-type]
138
+ self.next_video_id += 1
139
+ return placeholder
105
140
 
106
141
  def add_image(self, image_data: ImageData) -> str:
107
142
  """Add an image and return its placeholder text.
108
143
 
109
144
  Args:
110
- image_data: The image data to track
145
+ image_data: The image data to track.
111
146
 
112
147
  Returns:
113
- Placeholder string like "[image 1]"
148
+ Placeholder string like "[image 1]".
114
149
  """
115
- placeholder = f"[image {self.next_id}]"
116
- image_data.placeholder = placeholder
117
- self.images.append(image_data)
118
- self.next_id += 1
119
- return placeholder
150
+ return self.add_media(image_data, "image")
151
+
152
+ def add_video(self, video_data: VideoData) -> str:
153
+ """Add a video and return its placeholder text.
154
+
155
+ Args:
156
+ video_data: The video data to track.
157
+
158
+ Returns:
159
+ Placeholder string like "[video 1]".
160
+ """
161
+ return self.add_media(video_data, "video")
162
+
163
+ def get_media(self, kind: MediaKind) -> list[ImageData] | list[VideoData]:
164
+ """Get all tracked media of a given type.
165
+
166
+ Args:
167
+ kind: Media type key.
168
+
169
+ Returns:
170
+ Copy of the list of tracked media items.
171
+ """
172
+ if kind == "image":
173
+ return list(self.images)
174
+ return list(self.videos)
120
175
 
121
176
  def get_images(self) -> list[ImageData]:
122
177
  """Get all tracked images.
@@ -124,39 +179,94 @@ class ImageTracker:
124
179
  Returns:
125
180
  Copy of the list of tracked images.
126
181
  """
127
- return self.images.copy()
182
+ return list(self.images)
183
+
184
+ def get_videos(self) -> list[VideoData]:
185
+ """Get all tracked videos.
186
+
187
+ Returns:
188
+ Copy of the list of tracked videos.
189
+ """
190
+ return list(self.videos)
128
191
 
129
192
  def clear(self) -> None:
130
- """Clear all tracked images and reset counter."""
193
+ """Clear all tracked media and reset counters."""
131
194
  self.images.clear()
132
- self.next_id = 1
195
+ self.videos.clear()
196
+ self.next_image_id = 1
197
+ self.next_video_id = 1
133
198
 
134
199
  def sync_to_text(self, text: str) -> None:
135
- """Retain only images still referenced by placeholders in current text.
200
+ """Retain only media still referenced by placeholders in current text.
136
201
 
137
202
  Args:
138
203
  text: Current input text shown to the user.
139
204
  """
140
- placeholders = {
141
- match.group(0) for match in IMAGE_PLACEHOLDER_PATTERN.finditer(text)
142
- }
143
- if not placeholders:
205
+ img_found = self._sync_kind_images(text)
206
+ vid_found = self._sync_kind_videos(text)
207
+ if not img_found and not vid_found:
144
208
  self.clear()
145
- return
146
209
 
210
+ def _sync_kind_images(self, text: str) -> bool:
211
+ """Sync image list to surviving placeholders in text.
212
+
213
+ Args:
214
+ text: Current input text.
215
+
216
+ Returns:
217
+ Whether any image placeholders were found.
218
+ """
219
+ placeholders = {m.group(0) for m in IMAGE_PLACEHOLDER_PATTERN.finditer(text)}
147
220
  self.images = [img for img in self.images if img.placeholder in placeholders]
148
221
  if not self.images:
149
- self.next_id = 1
150
- return
222
+ self.next_image_id = 1
223
+ else:
224
+ self.next_image_id = self._max_placeholder_id(
225
+ self.images, IMAGE_PLACEHOLDER_PATTERN, len(self.images)
226
+ )
227
+ return bool(placeholders)
151
228
 
152
- max_id = 0
153
- for image in self.images:
154
- match = IMAGE_PLACEHOLDER_PATTERN.fullmatch(image.placeholder)
155
- if match is None:
156
- continue
157
- max_id = max(max_id, int(match.group("id")))
229
+ def _sync_kind_videos(self, text: str) -> bool:
230
+ """Sync video list to surviving placeholders in text.
231
+
232
+ Args:
233
+ text: Current input text.
234
+
235
+ Returns:
236
+ Whether any video placeholders were found.
237
+ """
238
+ placeholders = {m.group(0) for m in VIDEO_PLACEHOLDER_PATTERN.finditer(text)}
239
+ self.videos = [vid for vid in self.videos if vid.placeholder in placeholders]
240
+ if not self.videos:
241
+ self.next_video_id = 1
242
+ else:
243
+ self.next_video_id = self._max_placeholder_id(
244
+ self.videos, VIDEO_PLACEHOLDER_PATTERN, len(self.videos)
245
+ )
246
+ return bool(placeholders)
247
+
248
+ @staticmethod
249
+ def _max_placeholder_id(
250
+ items: list[ImageData] | list[VideoData],
251
+ pattern: re.Pattern[str],
252
+ fallback_count: int,
253
+ ) -> int:
254
+ """Compute next ID from the highest surviving placeholder.
255
+
256
+ Args:
257
+ items: Surviving media items.
258
+ pattern: Placeholder regex with an `id` group.
259
+ fallback_count: Fallback when no IDs can be parsed.
158
260
 
159
- self.next_id = max_id + 1 if max_id else len(self.images) + 1
261
+ Returns:
262
+ Next ID value (max_id + 1).
263
+ """
264
+ max_id = 0
265
+ for item in items:
266
+ match = pattern.fullmatch(item.placeholder)
267
+ if match is not None:
268
+ max_id = max(max_id, int(match.group("id")))
269
+ return max_id + 1 if max_id else fallback_count + 1
160
270
 
161
271
 
162
272
  def parse_file_mentions(text: str) -> tuple[str, list[Path]]:
@@ -1,4 +1,4 @@
1
- """Utilities for handling image paste from clipboard."""
1
+ """Utilities for handling image and video media from clipboard and files."""
2
2
 
3
3
  import base64
4
4
  import io
@@ -12,9 +12,43 @@ import subprocess # noqa: S404
12
12
  import sys
13
13
  import tempfile
14
14
  from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from langchain_core.messages.content import VideoContentBlock
15
19
 
16
20
  logger = logging.getLogger(__name__)
17
21
 
22
+ IMAGE_EXTENSIONS: frozenset[str] = frozenset(
23
+ {
24
+ ".png",
25
+ ".jpg",
26
+ ".jpeg",
27
+ ".gif",
28
+ ".bmp",
29
+ ".tiff",
30
+ ".tif",
31
+ ".webp",
32
+ ".ico",
33
+ }
34
+ )
35
+ """Common image file extensions supported by PIL."""
36
+
37
+ VIDEO_EXTENSIONS: frozenset[str] = frozenset(
38
+ {
39
+ ".mp4",
40
+ ".mov",
41
+ ".avi",
42
+ ".webm",
43
+ ".m4v",
44
+ ".wmv",
45
+ }
46
+ )
47
+ """Video file extensions with validated magic-byte support."""
48
+
49
+ MAX_MEDIA_BYTES: int = 20 * 1024 * 1024
50
+ """Maximum media file size (20 MB). Keeps base64 payload under ~27 MB."""
51
+
18
52
 
19
53
  def _get_executable(name: str) -> str | None:
20
54
  """Get full path to an executable using shutil.which().
@@ -48,6 +82,28 @@ class ImageData:
48
82
  }
49
83
 
50
84
 
85
+ @dataclass
86
+ class VideoData:
87
+ """Represents a pasted video with its base64 encoding."""
88
+
89
+ base64_data: str
90
+ format: str # "mp4", "quicktime", etc.
91
+ placeholder: str # Display text like "[video 1]"
92
+
93
+ def to_message_content(self) -> "VideoContentBlock":
94
+ """Convert to LangChain `VideoContentBlock` format.
95
+
96
+ Returns:
97
+ `VideoContentBlock` with base64 data and mime_type.
98
+ """
99
+ from langchain_core.messages.content import create_video_block
100
+
101
+ return create_video_block(
102
+ base64=self.base64_data,
103
+ mime_type=f"video/{self.format}",
104
+ )
105
+
106
+
51
107
  def get_clipboard_image() -> ImageData | None:
52
108
  """Attempt to read an image from the system clipboard.
53
109
 
@@ -79,6 +135,19 @@ def get_image_from_path(path: pathlib.Path) -> ImageData | None:
79
135
  from PIL import Image, UnidentifiedImageError
80
136
 
81
137
  try:
138
+ file_size = path.stat().st_size
139
+ if file_size == 0:
140
+ logger.debug("Image file is empty: %s", path)
141
+ return None
142
+ if file_size > MAX_MEDIA_BYTES:
143
+ logger.warning(
144
+ "Image file %s is too large (%d MB, max %d MB)",
145
+ path,
146
+ file_size // (1024 * 1024),
147
+ MAX_MEDIA_BYTES // (1024 * 1024),
148
+ )
149
+ return None
150
+
82
151
  image_bytes = path.read_bytes()
83
152
  if not image_bytes:
84
153
  return None
@@ -95,7 +164,7 @@ def get_image_from_path(path: pathlib.Path) -> ImageData | None:
95
164
  image_format = "png"
96
165
 
97
166
  return ImageData(
98
- base64_data=encode_image_to_base64(image_bytes),
167
+ base64_data=encode_to_base64(image_bytes),
99
168
  format=image_format,
100
169
  placeholder="[image]",
101
170
  )
@@ -104,6 +173,105 @@ def get_image_from_path(path: pathlib.Path) -> ImageData | None:
104
173
  return None
105
174
 
106
175
 
176
+ def _detect_video_format(data: bytes) -> str | None:
177
+ """Detect video MIME subtype from magic bytes.
178
+
179
+ Args:
180
+ data: Raw file bytes (at least 12 bytes for reliable detection).
181
+
182
+ Returns:
183
+ MIME subtype (e.g. "mp4", "webm") or `None` if unrecognized.
184
+ """
185
+ min_avi_len = 12
186
+ if data[4:8] == b"ftyp":
187
+ # ftyp box: major brand at bytes 8-12 distinguishes MOV vs MP4
188
+ brand = data[8:12]
189
+ if brand == b"qt ":
190
+ return "quicktime"
191
+ return "mp4"
192
+ if data[:4] == b"RIFF" and len(data) >= min_avi_len and data[8:12] == b"AVI ":
193
+ return "avi"
194
+ if data[:4] == b"\x30\x26\xb2\x75": # ASF/WMV
195
+ return "x-ms-wmv"
196
+ if data[:4] == b"\x1a\x45\xdf\xa3": # WebM/Matroska (EBML header)
197
+ return "webm"
198
+ return None
199
+
200
+
201
+ def get_video_from_path(path: pathlib.Path) -> VideoData | None:
202
+ """Read and encode a video file from disk.
203
+
204
+ Args:
205
+ path: Path to the video file.
206
+
207
+ Returns:
208
+ `VideoData` when the file is a valid video, otherwise `None`.
209
+ """
210
+ suffix = path.suffix.lower()
211
+ if suffix not in VIDEO_EXTENSIONS:
212
+ return None
213
+
214
+ try:
215
+ file_size = path.stat().st_size
216
+ if file_size == 0:
217
+ logger.debug("Video file is empty: %s", path)
218
+ return None
219
+ if file_size > MAX_MEDIA_BYTES:
220
+ logger.warning(
221
+ "Video file %s is too large (%d MB, max %d MB)",
222
+ path,
223
+ file_size // (1024 * 1024),
224
+ MAX_MEDIA_BYTES // (1024 * 1024),
225
+ )
226
+ return None
227
+
228
+ video_bytes = path.read_bytes()
229
+
230
+ # Validate it's a real video file by checking magic bytes
231
+ # MP4 starts with ftyp, MOV also uses ftyp, AVI starts with RIFF
232
+ min_video_len = 8
233
+ if len(video_bytes) < min_video_len:
234
+ logger.debug("Video file too small (%d bytes): %s", len(video_bytes), path)
235
+ return None
236
+
237
+ # Detect format from magic bytes (not extension) so renamed files
238
+ # get the correct MIME type.
239
+ detected_format = _detect_video_format(video_bytes)
240
+ if detected_format is None:
241
+ logger.warning(
242
+ "Video file %s has unrecognized signature for extension '%s'; "
243
+ "skipping. If this is a valid video, the format may not be "
244
+ "supported yet.",
245
+ path,
246
+ suffix,
247
+ )
248
+ return None
249
+
250
+ return VideoData(
251
+ base64_data=encode_to_base64(video_bytes),
252
+ format=detected_format,
253
+ placeholder="[video]",
254
+ )
255
+ except OSError as e:
256
+ logger.warning("Failed to load video from %s: %s", path, e, exc_info=True)
257
+ return None
258
+
259
+
260
+ def get_media_from_path(path: pathlib.Path) -> ImageData | VideoData | None:
261
+ """Try to load a file as an image first, then as a video.
262
+
263
+ Args:
264
+ path: Path to the media file.
265
+
266
+ Returns:
267
+ `ImageData` or `VideoData` if the file is valid media, otherwise `None`.
268
+ """
269
+ result: ImageData | VideoData | None = get_image_from_path(path)
270
+ if result is not None:
271
+ return result
272
+ return get_video_from_path(path)
273
+
274
+
107
275
  def _get_macos_clipboard_image() -> ImageData | None:
108
276
  """Get clipboard image on macOS using pngpaste or osascript.
109
277
 
@@ -269,27 +437,30 @@ def _get_clipboard_via_osascript() -> ImageData | None:
269
437
  logger.debug("Failed to clean up temp file %s: %s", temp_path, e)
270
438
 
271
439
 
272
- def encode_image_to_base64(image_bytes: bytes) -> str:
273
- """Encode image bytes to base64 string.
440
+ def encode_to_base64(data: bytes) -> str:
441
+ """Encode raw bytes to a base64 string.
274
442
 
275
443
  Args:
276
- image_bytes: Raw image bytes
444
+ data: Raw bytes to encode.
277
445
 
278
446
  Returns:
279
447
  Base64-encoded string.
280
448
  """
281
- return base64.b64encode(image_bytes).decode("utf-8")
449
+ return base64.b64encode(data).decode("utf-8")
282
450
 
283
451
 
284
- def create_multimodal_content(text: str, images: list[ImageData]) -> list[dict]:
285
- """Create multimodal message content with text and images.
452
+ def create_multimodal_content(
453
+ text: str, images: list[ImageData], videos: list[VideoData] | None = None
454
+ ) -> list[dict]:
455
+ """Create multimodal message content with text, images, and videos.
286
456
 
287
457
  Args:
288
458
  text: Text content of the message
289
459
  images: List of ImageData objects
460
+ videos: Optional list of VideoData objects
290
461
 
291
462
  Returns:
292
- List of content blocks in LangChain format.
463
+ List of content blocks in LangChain message format.
293
464
  """
294
465
  content_blocks = []
295
466
 
@@ -300,4 +471,8 @@ def create_multimodal_content(text: str, images: list[ImageData]) -> list[dict]:
300
471
  # Add image blocks
301
472
  content_blocks.extend(image.to_message_content() for image in images)
302
473
 
474
+ # Add video blocks
475
+ if videos:
476
+ content_blocks.extend(video.to_message_content() for video in videos)
477
+
303
478
  return content_blocks