code-muse 0.0.1__py3-none-any.whl

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 (394) hide show
  1. code_muse/__init__.py +26 -0
  2. code_muse/__main__.py +10 -0
  3. code_muse/agents/__init__.py +31 -0
  4. code_muse/agents/_builder.py +214 -0
  5. code_muse/agents/_compaction.py +506 -0
  6. code_muse/agents/_diagnostics.py +171 -0
  7. code_muse/agents/_history.py +382 -0
  8. code_muse/agents/_key_listeners.py +148 -0
  9. code_muse/agents/_non_streaming_render.py +148 -0
  10. code_muse/agents/_runtime.py +596 -0
  11. code_muse/agents/agent_creator_agent.py +603 -0
  12. code_muse/agents/agent_helios.py +47 -0
  13. code_muse/agents/agent_manager.py +740 -0
  14. code_muse/agents/agent_muse.py +78 -0
  15. code_muse/agents/agent_planning.py +44 -0
  16. code_muse/agents/agent_qa_melpomene.py +207 -0
  17. code_muse/agents/base_agent.py +194 -0
  18. code_muse/agents/event_stream_handler.py +361 -0
  19. code_muse/agents/json_agent.py +201 -0
  20. code_muse/agents/prompt_v3.py +521 -0
  21. code_muse/agents/subagent_stream_handler.py +273 -0
  22. code_muse/callbacks.py +941 -0
  23. code_muse/chatgpt_codex_client.py +333 -0
  24. code_muse/claude_cache_client.py +853 -0
  25. code_muse/cli_runner/__init__.py +319 -0
  26. code_muse/cli_runner/args.py +63 -0
  27. code_muse/cli_runner/loop.py +510 -0
  28. code_muse/cli_runner/resume.py +72 -0
  29. code_muse/cli_runner/runner.py +161 -0
  30. code_muse/command_line/__init__.py +1 -0
  31. code_muse/command_line/add_model_menu.py +1331 -0
  32. code_muse/command_line/agent_menu.py +674 -0
  33. code_muse/command_line/attachments.py +397 -0
  34. code_muse/command_line/autosave_menu.py +709 -0
  35. code_muse/command_line/clipboard.py +528 -0
  36. code_muse/command_line/colors_menu.py +530 -0
  37. code_muse/command_line/command_handler.py +262 -0
  38. code_muse/command_line/command_registry.py +150 -0
  39. code_muse/command_line/config_commands.py +711 -0
  40. code_muse/command_line/core_commands.py +740 -0
  41. code_muse/command_line/diff_menu.py +865 -0
  42. code_muse/command_line/file_path_completion.py +73 -0
  43. code_muse/command_line/load_context_completion.py +57 -0
  44. code_muse/command_line/model_picker_completion.py +512 -0
  45. code_muse/command_line/model_settings_menu.py +983 -0
  46. code_muse/command_line/onboarding_slides.py +162 -0
  47. code_muse/command_line/onboarding_wizard.py +337 -0
  48. code_muse/command_line/pagination.py +41 -0
  49. code_muse/command_line/pin_command_completion.py +329 -0
  50. code_muse/command_line/prompt_toolkit_completion.py +886 -0
  51. code_muse/command_line/session_commands.py +304 -0
  52. code_muse/command_line/shell_passthrough.py +145 -0
  53. code_muse/command_line/skills_completion.py +158 -0
  54. code_muse/command_line/types.py +18 -0
  55. code_muse/command_line/uc_menu.py +908 -0
  56. code_muse/command_line/utils.py +105 -0
  57. code_muse/command_line/wiggum_state.py +77 -0
  58. code_muse/config.py +1138 -0
  59. code_muse/config_agent.py +168 -0
  60. code_muse/config_appearance.py +241 -0
  61. code_muse/config_model.py +357 -0
  62. code_muse/config_security.py +73 -0
  63. code_muse/error_logging.py +132 -0
  64. code_muse/evals/__init__.py +35 -0
  65. code_muse/evals/eval_helpers.py +81 -0
  66. code_muse/evals/eval_runner.py +299 -0
  67. code_muse/evals/sample_evals/__init__.py +1 -0
  68. code_muse/evals/sample_evals/eval_frugal_reads.py +59 -0
  69. code_muse/evals/sample_evals/eval_memory_planning.py +31 -0
  70. code_muse/evals/sample_evals/eval_shell_efficiency.py +39 -0
  71. code_muse/evals/sample_evals/eval_tool_masking.py +33 -0
  72. code_muse/fs_scan_cache/__init__.py +31 -0
  73. code_muse/fs_scan_cache/invalidation_hooks.py +89 -0
  74. code_muse/fs_scan_cache/scan_cache_core.cpython-314-darwin.so +0 -0
  75. code_muse/fs_scan_cache/scan_cache_core.pyx +203 -0
  76. code_muse/fs_scan_cache/tool_integration.py +309 -0
  77. code_muse/fs_scan_cache/ttl_policy.py +44 -0
  78. code_muse/gemini_code_assist.py +383 -0
  79. code_muse/gemini_model.py +838 -0
  80. code_muse/hook_engine/README.md +105 -0
  81. code_muse/hook_engine/__init__.py +21 -0
  82. code_muse/hook_engine/aliases.py +153 -0
  83. code_muse/hook_engine/engine.py +221 -0
  84. code_muse/hook_engine/executor.py +347 -0
  85. code_muse/hook_engine/matcher.py +154 -0
  86. code_muse/hook_engine/models.py +245 -0
  87. code_muse/hook_engine/registry.py +114 -0
  88. code_muse/hook_engine/trust.py +268 -0
  89. code_muse/hook_engine/validator.py +144 -0
  90. code_muse/http_utils.py +360 -0
  91. code_muse/keymap.py +128 -0
  92. code_muse/list_filtering.py +26 -0
  93. code_muse/main.py +10 -0
  94. code_muse/messaging/__init__.py +259 -0
  95. code_muse/messaging/bus.py +621 -0
  96. code_muse/messaging/commands.py +166 -0
  97. code_muse/messaging/markdown_patches.py +57 -0
  98. code_muse/messaging/message_queue.py +397 -0
  99. code_muse/messaging/messages.py +591 -0
  100. code_muse/messaging/queue_console.py +269 -0
  101. code_muse/messaging/renderers.py +308 -0
  102. code_muse/messaging/rich_renderer.py +1158 -0
  103. code_muse/messaging/shimmer.py +154 -0
  104. code_muse/messaging/spinner/__init__.py +87 -0
  105. code_muse/messaging/spinner/console_spinner.py +250 -0
  106. code_muse/messaging/spinner/spinner_base.py +82 -0
  107. code_muse/messaging/subagent_console.py +458 -0
  108. code_muse/model_factory.py +1203 -0
  109. code_muse/model_switching.py +59 -0
  110. code_muse/model_utils.py +156 -0
  111. code_muse/models.json +66 -0
  112. code_muse/models_cache/__init__.py +26 -0
  113. code_muse/models_cache/blocking_lru_cache.py +98 -0
  114. code_muse/models_cache/cache_writer.py +86 -0
  115. code_muse/models_cache/sha256_hash.cpython-314-darwin.so +0 -0
  116. code_muse/models_cache/sha256_hash.pyx +34 -0
  117. code_muse/models_cache/startup_integration.py +75 -0
  118. code_muse/models_dev_api.json +1 -0
  119. code_muse/models_dev_parser.py +590 -0
  120. code_muse/motion.py +126 -0
  121. code_muse/plugins/__init__.py +471 -0
  122. code_muse/plugins/agent_skills/__init__.py +32 -0
  123. code_muse/plugins/agent_skills/config.py +176 -0
  124. code_muse/plugins/agent_skills/discovery.py +309 -0
  125. code_muse/plugins/agent_skills/downloader.py +389 -0
  126. code_muse/plugins/agent_skills/installer.py +19 -0
  127. code_muse/plugins/agent_skills/metadata.py +293 -0
  128. code_muse/plugins/agent_skills/prompt_builder.py +66 -0
  129. code_muse/plugins/agent_skills/register_callbacks.py +298 -0
  130. code_muse/plugins/agent_skills/remote_catalog.py +320 -0
  131. code_muse/plugins/agent_skills/skill_catalog.py +254 -0
  132. code_muse/plugins/agent_skills/skills_install_menu.py +690 -0
  133. code_muse/plugins/agent_skills/skills_menu.py +791 -0
  134. code_muse/plugins/autonomous_memory/__init__.py +39 -0
  135. code_muse/plugins/autonomous_memory/bm25_scorer.cpython-314-darwin.so +0 -0
  136. code_muse/plugins/autonomous_memory/bm25_scorer.cpython-314-x86_64-linux-gnu.so +0 -0
  137. code_muse/plugins/autonomous_memory/bm25_scorer.pyx +291 -0
  138. code_muse/plugins/autonomous_memory/consolidation.py +82 -0
  139. code_muse/plugins/autonomous_memory/extraction.py +382 -0
  140. code_muse/plugins/autonomous_memory/lease_lock.py +105 -0
  141. code_muse/plugins/autonomous_memory/memory_injection.py +59 -0
  142. code_muse/plugins/autonomous_memory/register_callbacks.py +268 -0
  143. code_muse/plugins/autonomous_memory/secret_scanner.py +62 -0
  144. code_muse/plugins/autonomous_memory/session_scanner.py +163 -0
  145. code_muse/plugins/aws_bedrock/__init__.py +14 -0
  146. code_muse/plugins/aws_bedrock/config.py +99 -0
  147. code_muse/plugins/aws_bedrock/register_callbacks.py +241 -0
  148. code_muse/plugins/aws_bedrock/utils.py +153 -0
  149. code_muse/plugins/azure_foundry/README.md +238 -0
  150. code_muse/plugins/azure_foundry/__init__.py +15 -0
  151. code_muse/plugins/azure_foundry/config.py +125 -0
  152. code_muse/plugins/azure_foundry/discovery.py +187 -0
  153. code_muse/plugins/azure_foundry/register_callbacks.py +495 -0
  154. code_muse/plugins/azure_foundry/token.py +180 -0
  155. code_muse/plugins/azure_foundry/utils.py +345 -0
  156. code_muse/plugins/build_filter/__init__.py +1 -0
  157. code_muse/plugins/build_filter/register_callbacks.py +201 -0
  158. code_muse/plugins/build_filter/strategies/__init__.py +1 -0
  159. code_muse/plugins/build_filter/strategies/build.py +397 -0
  160. code_muse/plugins/chatgpt_oauth/__init__.py +6 -0
  161. code_muse/plugins/chatgpt_oauth/config.py +52 -0
  162. code_muse/plugins/chatgpt_oauth/oauth_flow.py +338 -0
  163. code_muse/plugins/chatgpt_oauth/register_callbacks.py +172 -0
  164. code_muse/plugins/chatgpt_oauth/test_plugin.py +301 -0
  165. code_muse/plugins/chatgpt_oauth/utils.py +538 -0
  166. code_muse/plugins/checkpointing/__init__.py +29 -0
  167. code_muse/plugins/checkpointing/checkpoint_hook.py +51 -0
  168. code_muse/plugins/checkpointing/conversation_snapshots.py +117 -0
  169. code_muse/plugins/checkpointing/register_callbacks.py +51 -0
  170. code_muse/plugins/checkpointing/restore_command.py +263 -0
  171. code_muse/plugins/checkpointing/rewind_shortcut.py +88 -0
  172. code_muse/plugins/checkpointing/shadow_git.py +90 -0
  173. code_muse/plugins/claude_code_hooks/__init__.py +1 -0
  174. code_muse/plugins/claude_code_hooks/config.py +188 -0
  175. code_muse/plugins/claude_code_hooks/register_callbacks.py +208 -0
  176. code_muse/plugins/claude_code_oauth/README.md +167 -0
  177. code_muse/plugins/claude_code_oauth/SETUP.md +93 -0
  178. code_muse/plugins/claude_code_oauth/__init__.py +25 -0
  179. code_muse/plugins/claude_code_oauth/config.py +52 -0
  180. code_muse/plugins/claude_code_oauth/fast_mode.py +124 -0
  181. code_muse/plugins/claude_code_oauth/prompt_handler.py +63 -0
  182. code_muse/plugins/claude_code_oauth/register_callbacks.py +547 -0
  183. code_muse/plugins/claude_code_oauth/test_fast_mode.py +165 -0
  184. code_muse/plugins/claude_code_oauth/test_plugin.py +283 -0
  185. code_muse/plugins/claude_code_oauth/token_refresh_heartbeat.py +237 -0
  186. code_muse/plugins/claude_code_oauth/utils.py +664 -0
  187. code_muse/plugins/copilot_auth/__init__.py +11 -0
  188. code_muse/plugins/copilot_auth/config.py +91 -0
  189. code_muse/plugins/copilot_auth/reasoning_client.py +409 -0
  190. code_muse/plugins/copilot_auth/register_callbacks.py +461 -0
  191. code_muse/plugins/copilot_auth/utils.py +584 -0
  192. code_muse/plugins/custom_commands/__init__.py +14 -0
  193. code_muse/plugins/custom_commands/args_injection.py +82 -0
  194. code_muse/plugins/custom_commands/command_discovery.py +89 -0
  195. code_muse/plugins/custom_commands/command_toml_schema.py +71 -0
  196. code_muse/plugins/custom_commands/register_callbacks.py +176 -0
  197. code_muse/plugins/customizable_commands/__init__.py +0 -0
  198. code_muse/plugins/customizable_commands/register_callbacks.py +136 -0
  199. code_muse/plugins/destructive_command_guard/__init__.py +14 -0
  200. code_muse/plugins/destructive_command_guard/detector.py +375 -0
  201. code_muse/plugins/destructive_command_guard/register_callbacks.py +148 -0
  202. code_muse/plugins/example_custom_command/README.md +280 -0
  203. code_muse/plugins/example_custom_command/register_callbacks.py +51 -0
  204. code_muse/plugins/file_permission_handler/__init__.py +4 -0
  205. code_muse/plugins/file_permission_handler/register_callbacks.py +441 -0
  206. code_muse/plugins/filter_engine/__init__.py +30 -0
  207. code_muse/plugins/filter_engine/classifier.py +153 -0
  208. code_muse/plugins/filter_engine/content_detector.py +184 -0
  209. code_muse/plugins/filter_engine/dispatcher.py +244 -0
  210. code_muse/plugins/filter_engine/register_callbacks.py +188 -0
  211. code_muse/plugins/filter_engine/registry.py +279 -0
  212. code_muse/plugins/filter_engine/strategies/__init__.py +8 -0
  213. code_muse/plugins/filter_engine/strategies/ast_compressor.cpython-314-darwin.so +0 -0
  214. code_muse/plugins/filter_engine/strategies/ast_compressor.cpython-314-x86_64-linux-gnu.so +0 -0
  215. code_muse/plugins/filter_engine/strategies/ast_compressor.pyx +348 -0
  216. code_muse/plugins/filter_engine/strategies/ast_parser.py +167 -0
  217. code_muse/plugins/filter_engine/strategies/code.cpython-314-darwin.so +0 -0
  218. code_muse/plugins/filter_engine/strategies/code.cpython-314-x86_64-linux-gnu.so +0 -0
  219. code_muse/plugins/filter_engine/strategies/code.pyx +584 -0
  220. code_muse/plugins/filter_engine/strategies/git.cpython-314-darwin.so +0 -0
  221. code_muse/plugins/filter_engine/strategies/git.cpython-314-x86_64-linux-gnu.so +0 -0
  222. code_muse/plugins/filter_engine/strategies/git.pyx +438 -0
  223. code_muse/plugins/filter_engine/strategies/json_compressor.cpython-314-darwin.so +0 -0
  224. code_muse/plugins/filter_engine/strategies/json_compressor.pyx +253 -0
  225. code_muse/plugins/filter_engine/strategies/json_patterns.cpython-314-darwin.so +0 -0
  226. code_muse/plugins/filter_engine/strategies/json_patterns.pyx +178 -0
  227. code_muse/plugins/filter_engine/strategies/lint.cpython-314-darwin.so +0 -0
  228. code_muse/plugins/filter_engine/strategies/lint.cpython-314-x86_64-linux-gnu.so +0 -0
  229. code_muse/plugins/filter_engine/strategies/lint.pyx +626 -0
  230. code_muse/plugins/filter_engine/strategies/test.cpython-314-darwin.so +0 -0
  231. code_muse/plugins/filter_engine/strategies/test.cpython-314-x86_64-linux-gnu.so +0 -0
  232. code_muse/plugins/filter_engine/strategies/test.pyx +431 -0
  233. code_muse/plugins/filter_engine/verbosity.py +63 -0
  234. code_muse/plugins/force_push_guard/__init__.py +5 -0
  235. code_muse/plugins/force_push_guard/detector.py +96 -0
  236. code_muse/plugins/force_push_guard/register_callbacks.py +144 -0
  237. code_muse/plugins/force_push_guard/test_detector.py +143 -0
  238. code_muse/plugins/frontend_emitter/__init__.py +25 -0
  239. code_muse/plugins/frontend_emitter/emitter.py +121 -0
  240. code_muse/plugins/frontend_emitter/register_callbacks.py +259 -0
  241. code_muse/plugins/gac/__init__.py +4 -0
  242. code_muse/plugins/gac/git_ops.py +136 -0
  243. code_muse/plugins/gac/prompt.py +191 -0
  244. code_muse/plugins/gac/register_callbacks.py +82 -0
  245. code_muse/plugins/hook_creator/__init__.py +1 -0
  246. code_muse/plugins/hook_creator/register_callbacks.py +34 -0
  247. code_muse/plugins/hook_manager/__init__.py +1 -0
  248. code_muse/plugins/hook_manager/config.py +289 -0
  249. code_muse/plugins/hook_manager/hooks_menu.py +563 -0
  250. code_muse/plugins/hook_manager/register_callbacks.py +227 -0
  251. code_muse/plugins/hook_monitor/register_callbacks.py +36 -0
  252. code_muse/plugins/mindpack/__init__.py +0 -0
  253. code_muse/plugins/mindpack/factory.py +930 -0
  254. code_muse/plugins/mindpack/judge.py +573 -0
  255. code_muse/plugins/mindpack/memory.py +100 -0
  256. code_muse/plugins/mindpack/mindpack_menu.py +1552 -0
  257. code_muse/plugins/mindpack/orchestration.py +605 -0
  258. code_muse/plugins/mindpack/register_callbacks.py +175 -0
  259. code_muse/plugins/mindpack/schemas.py +358 -0
  260. code_muse/plugins/mindpack/tools.py +387 -0
  261. code_muse/plugins/oauth_muse_html.py +226 -0
  262. code_muse/plugins/ollama_setup/__init__.py +5 -0
  263. code_muse/plugins/ollama_setup/completer.py +36 -0
  264. code_muse/plugins/ollama_setup/register_callbacks.py +410 -0
  265. code_muse/plugins/plan_command/__init__.py +0 -0
  266. code_muse/plugins/plan_command/register_callbacks.py +206 -0
  267. code_muse/plugins/plan_mode/__init__.py +37 -0
  268. code_muse/plugins/plan_mode/mode_cycling.py +40 -0
  269. code_muse/plugins/plan_mode/plan_generation.py +68 -0
  270. code_muse/plugins/plan_mode/plan_hooks.py +74 -0
  271. code_muse/plugins/plan_mode/plan_mode_tools.py +138 -0
  272. code_muse/plugins/plan_mode/register_callbacks.py +121 -0
  273. code_muse/plugins/plugin_trust/register_callbacks.py +140 -0
  274. code_muse/plugins/policy_engine/__init__.py +46 -0
  275. code_muse/plugins/policy_engine/approval_flow_integration.py +59 -0
  276. code_muse/plugins/policy_engine/policy_evaluator.py +75 -0
  277. code_muse/plugins/policy_engine/policy_file_discovery.py +90 -0
  278. code_muse/plugins/policy_engine/policy_toml_schema.py +115 -0
  279. code_muse/plugins/policy_engine/register_callbacks.py +112 -0
  280. code_muse/plugins/pop_command/__init__.py +1 -0
  281. code_muse/plugins/pop_command/register_callbacks.py +189 -0
  282. code_muse/plugins/prompt_newline/__init__.py +13 -0
  283. code_muse/plugins/prompt_newline/config.py +19 -0
  284. code_muse/plugins/prompt_newline/register_callbacks.py +159 -0
  285. code_muse/plugins/safety_status/__init__.py +0 -0
  286. code_muse/plugins/safety_status/register_callbacks.py +113 -0
  287. code_muse/plugins/semantic_compression/__init__.py +6 -0
  288. code_muse/plugins/semantic_compression/compressor.py +295 -0
  289. code_muse/plugins/semantic_compression/config.py +123 -0
  290. code_muse/plugins/semantic_compression/register_callbacks.py +320 -0
  291. code_muse/plugins/shell_minimizer/__init__.py +50 -0
  292. code_muse/plugins/shell_minimizer/builtin_filters.toml +393 -0
  293. code_muse/plugins/shell_minimizer/pipeline.py +556 -0
  294. code_muse/plugins/shell_minimizer/primitives.py +482 -0
  295. code_muse/plugins/shell_minimizer/register_callbacks.py +276 -0
  296. code_muse/plugins/shell_safety/__init__.py +6 -0
  297. code_muse/plugins/shell_safety/agent_shell_safety.py +69 -0
  298. code_muse/plugins/shell_safety/command_cache.py +149 -0
  299. code_muse/plugins/shell_safety/register_callbacks.py +202 -0
  300. code_muse/plugins/synthetic_status/__init__.py +1 -0
  301. code_muse/plugins/synthetic_status/register_callbacks.py +128 -0
  302. code_muse/plugins/synthetic_status/status_api.py +145 -0
  303. code_muse/plugins/token_caching/__init__.py +21 -0
  304. code_muse/plugins/token_caching/cache_hit_tracking.py +128 -0
  305. code_muse/plugins/token_caching/cacheable_prefix_detection.py +28 -0
  306. code_muse/plugins/token_caching/register_callbacks.py +54 -0
  307. code_muse/plugins/token_caching/stats_display.py +35 -0
  308. code_muse/plugins/token_tracking/__init__.py +26 -0
  309. code_muse/plugins/token_tracking/database.py +381 -0
  310. code_muse/plugins/token_tracking/edit_analyzer.py +97 -0
  311. code_muse/plugins/token_tracking/record.py +55 -0
  312. code_muse/plugins/token_tracking/register_callbacks.py +277 -0
  313. code_muse/plugins/token_tracking/reports.py +329 -0
  314. code_muse/plugins/universal_constructor/__init__.py +13 -0
  315. code_muse/plugins/universal_constructor/models.py +136 -0
  316. code_muse/plugins/universal_constructor/register_callbacks.py +47 -0
  317. code_muse/plugins/universal_constructor/registry.py +390 -0
  318. code_muse/plugins/universal_constructor/runner.py +474 -0
  319. code_muse/plugins/universal_constructor/safety.py +440 -0
  320. code_muse/plugins/universal_constructor/sandbox.py +584 -0
  321. code_muse/provider_identity.py +105 -0
  322. code_muse/pydantic_patches.py +410 -0
  323. code_muse/reopenable_async_client.py +233 -0
  324. code_muse/round_robin_model.py +151 -0
  325. code_muse/secret_storage.py +74 -0
  326. code_muse/security/__init__.py +1 -0
  327. code_muse/security/redaction.cpython-314-darwin.so +0 -0
  328. code_muse/security/redaction.cpython-314-x86_64-linux-gnu.so +0 -0
  329. code_muse/security/redaction.pyx +135 -0
  330. code_muse/session_storage.py +565 -0
  331. code_muse/status_display.py +261 -0
  332. code_muse/stream_parser/__init__.py +76 -0
  333. code_muse/stream_parser/assistant_text_parser.py +90 -0
  334. code_muse/stream_parser/citation_parser.py +76 -0
  335. code_muse/stream_parser/inline_hidden_tag_parser.py +236 -0
  336. code_muse/stream_parser/proposed_plan_parser.py +158 -0
  337. code_muse/stream_parser/stream_text_chunk.py +23 -0
  338. code_muse/stream_parser/stream_text_parser.py +27 -0
  339. code_muse/stream_parser/tagged_line_parser.cpython-314-darwin.so +0 -0
  340. code_muse/stream_parser/tagged_line_parser.pyx +251 -0
  341. code_muse/stream_parser/utf8_stream_parser.cpython-314-darwin.so +0 -0
  342. code_muse/stream_parser/utf8_stream_parser.pyx +206 -0
  343. code_muse/summarization_agent.py +308 -0
  344. code_muse/terminal_utils.cpython-314-darwin.so +0 -0
  345. code_muse/terminal_utils.cpython-314-x86_64-linux-gnu.so +0 -0
  346. code_muse/terminal_utils.pyx +483 -0
  347. code_muse/tools/__init__.py +459 -0
  348. code_muse/tools/agent_tools.py +613 -0
  349. code_muse/tools/ask_user_question/__init__.py +26 -0
  350. code_muse/tools/ask_user_question/constants.py +73 -0
  351. code_muse/tools/ask_user_question/demo_tui.py +55 -0
  352. code_muse/tools/ask_user_question/handler.py +232 -0
  353. code_muse/tools/ask_user_question/models.py +302 -0
  354. code_muse/tools/ask_user_question/registration.py +37 -0
  355. code_muse/tools/ask_user_question/renderers.py +336 -0
  356. code_muse/tools/ask_user_question/terminal_ui.py +327 -0
  357. code_muse/tools/ask_user_question/theme.py +156 -0
  358. code_muse/tools/ask_user_question/tui_loop.py +422 -0
  359. code_muse/tools/background_jobs.py +99 -0
  360. code_muse/tools/browser/__init__.py +37 -0
  361. code_muse/tools/browser/browser_control.py +289 -0
  362. code_muse/tools/browser/browser_interactions.py +545 -0
  363. code_muse/tools/browser/browser_locators.py +640 -0
  364. code_muse/tools/browser/browser_manager.py +376 -0
  365. code_muse/tools/browser/browser_navigation.py +251 -0
  366. code_muse/tools/browser/browser_screenshot.py +180 -0
  367. code_muse/tools/browser/browser_scripts.py +462 -0
  368. code_muse/tools/browser/browser_workflows.py +222 -0
  369. code_muse/tools/chrome_cdp/__init__.py +1070 -0
  370. code_muse/tools/chrome_cdp/register_callbacks.py +61 -0
  371. code_muse/tools/command_runner.py +1401 -0
  372. code_muse/tools/common.py +1407 -0
  373. code_muse/tools/display.py +87 -0
  374. code_muse/tools/file_modifications.py +1099 -0
  375. code_muse/tools/file_operations.py +860 -0
  376. code_muse/tools/image_tools.py +185 -0
  377. code_muse/tools/meetin_proxy/__init__.py +243 -0
  378. code_muse/tools/meetin_proxy/capture_addon.py +82 -0
  379. code_muse/tools/meetin_proxy/proxy_manager.py +326 -0
  380. code_muse/tools/meetin_proxy/register_callbacks.py +45 -0
  381. code_muse/tools/path_policy.py +219 -0
  382. code_muse/tools/skills_tools.py +586 -0
  383. code_muse/tools/subagent_context.py +158 -0
  384. code_muse/tools/tools_content.py +50 -0
  385. code_muse/tools/universal_constructor.py +965 -0
  386. code_muse/uvx_detection.py +241 -0
  387. code_muse/version_checker.py +86 -0
  388. code_muse-0.0.1.data/data/code_muse/models.json +66 -0
  389. code_muse-0.0.1.data/data/code_muse/models_dev_api.json +1 -0
  390. code_muse-0.0.1.dist-info/METADATA +845 -0
  391. code_muse-0.0.1.dist-info/RECORD +394 -0
  392. code_muse-0.0.1.dist-info/WHEEL +4 -0
  393. code_muse-0.0.1.dist-info/entry_points.txt +2 -0
  394. code_muse-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1331 @@
1
+ """Interactive terminal UI for browsing and adding models from models_dev_api.json.
2
+
3
+ Provides a beautiful split-panel interface for browsing providers and models
4
+ with live preview of model details and one-click addition to extra_models.json.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from prompt_toolkit.application import Application
14
+ from prompt_toolkit.key_binding import KeyBindings
15
+ from prompt_toolkit.layout import Dimension, Layout, VSplit, Window
16
+ from prompt_toolkit.layout.controls import FormattedTextControl
17
+ from prompt_toolkit.widgets import Frame
18
+
19
+ from code_muse.command_line.pagination import (
20
+ ensure_visible_page,
21
+ get_page_bounds,
22
+ get_page_for_index,
23
+ get_total_pages,
24
+ )
25
+ from code_muse.command_line.utils import safe_input
26
+ from code_muse.config import EXTRA_MODELS_FILE, set_config_value
27
+ from code_muse.list_filtering import query_matches_text
28
+ from code_muse.messaging import emit_error, emit_info, emit_warning
29
+ from code_muse.models_dev_parser import ModelInfo, ModelsDevRegistry, ProviderInfo
30
+ from code_muse.tools.command_runner import set_awaiting_user_input
31
+
32
+ PAGE_SIZE = 15 # Items per page
33
+
34
+ # Hardcoded OpenAI-compatible endpoints for providers that have dedicated SDKs
35
+ # but actually work fine with custom_openai. These are fallbacks when provider.api is not set.
36
+ PROVIDER_ENDPOINTS = {
37
+ "xai": "https://api.x.ai/v1",
38
+ "cohere": "https://api.cohere.com/compatibility/v1", # Cohere's OpenAI-compatible endpoint
39
+ "groq": "https://api.groq.com/openai/v1",
40
+ "mistral": "https://api.mistral.ai/v1",
41
+ "togetherai": "https://api.together.xyz/v1",
42
+ "perplexity": "https://api.perplexity.ai",
43
+ "deepinfra": "https://api.deepinfra.com/v1/openai",
44
+ "aihubmix": "https://aihubmix.com/v1",
45
+ }
46
+
47
+ # Providers that require custom SDK implementations we don't support yet.
48
+ # These use non-OpenAI-compatible APIs or require special authentication (AWS SigV4, GCP, etc.)
49
+ UNSUPPORTED_PROVIDERS = {
50
+ "amazon-bedrock": "Use /bedrock-setup to configure (aws_bedrock plugin)",
51
+ "google-vertex": "Requires GCP service account authentication",
52
+ "google-vertex-anthropic": "Requires GCP service account authentication",
53
+ "cloudflare-workers-ai": "Requires account ID in URL path",
54
+ "vercel": "Vercel AI Gateway - not yet supported",
55
+ "v0": "Vercel v0 - not yet supported",
56
+ "ollama-cloud": "Requires user-specific Ollama instance URL",
57
+ }
58
+
59
+
60
+ PROVIDER_IDENTITY_MAPPING = {
61
+ "openai": "openai",
62
+ "anthropic": "anthropic",
63
+ "google": "google",
64
+ "google-vertex": "google",
65
+ "mistral": "mistral",
66
+ "groq": "groq",
67
+ "together-ai": "together_ai",
68
+ "fireworks": "fireworks",
69
+ "deepseek": "deepseek",
70
+ "openrouter": "openrouter",
71
+ "cerebras": "cerebras",
72
+ "cohere": "cohere",
73
+ "perplexity": "perplexity",
74
+ "minimax": "minimax",
75
+ "azure-openai": "azure_openai",
76
+ "xai": "xai",
77
+ }
78
+
79
+
80
+ def derive_provider_identity(provider: ProviderInfo) -> str:
81
+ """Derive the persisted provider identity for imported models."""
82
+ provider_id = (provider.id or "").strip()
83
+ if not provider_id:
84
+ return "unknown"
85
+ return PROVIDER_IDENTITY_MAPPING.get(provider_id, provider_id.replace("-", "_"))
86
+
87
+
88
+ class AddModelMenu:
89
+ """Interactive TUI for browsing and adding models."""
90
+
91
+ def __init__(self):
92
+ """Initialize the model browser menu."""
93
+ self.registry: ModelsDevRegistry | None = None
94
+ self.providers: list[ProviderInfo] = []
95
+ self.current_provider: ProviderInfo | None = None
96
+ self.current_models: list[ModelInfo] = []
97
+
98
+ # State management
99
+ self.view_mode = "providers" # "providers" or "models"
100
+ self.selected_provider_idx = 0
101
+ self.selected_model_idx = 0
102
+ self.current_page = 0
103
+ self.result = None # Track if user added a model
104
+ self.provider_filter = ""
105
+ self.model_filter = ""
106
+
107
+ # Pending model for credential prompting
108
+ self.pending_model: ModelInfo | None = None
109
+ self.pending_provider: ProviderInfo | None = None
110
+
111
+ # Custom model support
112
+ self.is_custom_model_selected = False
113
+ self.custom_model_name: str | None = None
114
+
115
+ # Initialize registry
116
+ self._initialize_registry()
117
+
118
+ def _initialize_registry(self):
119
+ """Initialize the ModelsDevRegistry with error handling.
120
+
121
+ Fetches from live models.dev API first, falls back to bundled JSON.
122
+ """
123
+ try:
124
+ self.registry = (
125
+ ModelsDevRegistry()
126
+ ) # Will try API first, then bundled fallback
127
+ self.providers = self.registry.get_providers()
128
+ if not self.providers:
129
+ emit_error("No providers found in models database")
130
+ except FileNotFoundError as e:
131
+ emit_error(f"Models database unavailable: {e}")
132
+ except Exception as e:
133
+ emit_error(f"Error loading models registry: {e}")
134
+
135
+ def _get_current_provider(self) -> ProviderInfo | None:
136
+ """Get the currently selected provider."""
137
+ filtered_providers = self._filtered_providers()
138
+ if 0 <= self.selected_provider_idx < len(filtered_providers):
139
+ return filtered_providers[self.selected_provider_idx]
140
+ return None
141
+
142
+ def _get_current_model(self) -> ModelInfo | None:
143
+ """Get the currently selected model.
144
+
145
+ Returns None if "Custom model" option is selected (which is at index len(current_models)).
146
+ """
147
+ if self.view_mode == "models" and self.current_provider:
148
+ # Check if custom model option is selected (it's the last item)
149
+ filtered_models = self._filtered_models()
150
+ if self._should_show_custom_model() and (
151
+ self.selected_model_idx == len(filtered_models)
152
+ ):
153
+ return None # Custom model selected
154
+ if 0 <= self.selected_model_idx < len(filtered_models):
155
+ return filtered_models[self.selected_model_idx]
156
+ return None
157
+
158
+ def _is_custom_model_selected(self) -> bool:
159
+ """Check if the custom model option is currently selected."""
160
+ if self.view_mode == "models" and self.current_provider:
161
+ return self._should_show_custom_model() and (
162
+ self.selected_model_idx == len(self._filtered_models())
163
+ )
164
+ return False
165
+
166
+ def _filtered_providers(self) -> list[ProviderInfo]:
167
+ provider_filter = getattr(self, "provider_filter", "")
168
+ if not provider_filter:
169
+ return self.providers
170
+ return [
171
+ provider
172
+ for provider in self.providers
173
+ if query_matches_text(provider_filter, provider.name, provider.id)
174
+ ]
175
+
176
+ def _filtered_models(self) -> list[ModelInfo]:
177
+ model_filter = getattr(self, "model_filter", "")
178
+ if not model_filter:
179
+ return self.current_models
180
+ return [
181
+ model
182
+ for model in self.current_models
183
+ if query_matches_text(
184
+ model_filter,
185
+ model.name,
186
+ model.model_id,
187
+ getattr(model, "full_id", ""),
188
+ )
189
+ ]
190
+
191
+ def _should_show_custom_model(self) -> bool:
192
+ model_filter = getattr(self, "model_filter", "")
193
+ return (
194
+ not model_filter
195
+ or not self._filtered_models()
196
+ or query_matches_text(model_filter, "custom model")
197
+ )
198
+
199
+ def _get_active_filter_text(self) -> str:
200
+ if self.view_mode == "providers":
201
+ return getattr(self, "provider_filter", "")
202
+ return getattr(self, "model_filter", "")
203
+
204
+ def _sync_provider_selection(self, preferred_provider: ProviderInfo | None) -> None:
205
+ filtered_providers = self._filtered_providers()
206
+ if not filtered_providers:
207
+ self.selected_provider_idx = 0
208
+ self.current_page = 0
209
+ return
210
+
211
+ if preferred_provider and preferred_provider in filtered_providers:
212
+ self.selected_provider_idx = filtered_providers.index(preferred_provider)
213
+ else:
214
+ self.selected_provider_idx = min(
215
+ self.selected_provider_idx, len(filtered_providers) - 1
216
+ )
217
+ self._ensure_selection_visible()
218
+
219
+ def _sync_model_selection(
220
+ self, preferred_model: ModelInfo | None, preferred_custom: bool
221
+ ) -> None:
222
+ filtered_models = self._filtered_models()
223
+ total_items = len(filtered_models) + int(self._should_show_custom_model())
224
+ if total_items <= 0:
225
+ self.selected_model_idx = 0
226
+ self.current_page = 0
227
+ return
228
+
229
+ if preferred_custom and self._should_show_custom_model():
230
+ self.selected_model_idx = len(filtered_models)
231
+ elif preferred_model and preferred_model in filtered_models:
232
+ self.selected_model_idx = filtered_models.index(preferred_model)
233
+ else:
234
+ self.selected_model_idx = min(self.selected_model_idx, total_items - 1)
235
+ self._ensure_selection_visible()
236
+
237
+ def _set_provider_filter(self, value: str) -> None:
238
+ preferred_provider = self._get_current_provider()
239
+ self.provider_filter = value
240
+ self._sync_provider_selection(preferred_provider)
241
+
242
+ def _set_model_filter(self, value: str) -> None:
243
+ preferred_model = self._get_current_model()
244
+ preferred_custom = self._is_custom_model_selected()
245
+ self.model_filter = value
246
+ self._sync_model_selection(preferred_model, preferred_custom)
247
+
248
+ def _append_filter_char(self, value: str) -> None:
249
+ if self.view_mode == "providers":
250
+ self._set_provider_filter(getattr(self, "provider_filter", "") + value)
251
+ else:
252
+ self._set_model_filter(getattr(self, "model_filter", "") + value)
253
+
254
+ def _delete_filter_char(self) -> bool:
255
+ if self.view_mode == "providers":
256
+ provider_filter = getattr(self, "provider_filter", "")
257
+ if not provider_filter:
258
+ return False
259
+ self._set_provider_filter(provider_filter[:-1])
260
+ return True
261
+
262
+ model_filter = getattr(self, "model_filter", "")
263
+ if not model_filter:
264
+ return False
265
+ self._set_model_filter(model_filter[:-1])
266
+ return True
267
+
268
+ def _clear_active_filter(self) -> bool:
269
+ if self.view_mode == "providers":
270
+ if not getattr(self, "provider_filter", ""):
271
+ return False
272
+ self._set_provider_filter("")
273
+ return True
274
+
275
+ if not getattr(self, "model_filter", ""):
276
+ return False
277
+ self._set_model_filter("")
278
+ return True
279
+
280
+ def _get_total_items(self) -> int:
281
+ """Return the number of items in the active list view."""
282
+ if self.view_mode == "providers":
283
+ return len(self._filtered_providers())
284
+ return len(self._filtered_models()) + int(self._should_show_custom_model())
285
+
286
+ def _get_selected_index(self) -> int:
287
+ """Return the selected absolute index for the active list view."""
288
+ if self.view_mode == "providers":
289
+ return self.selected_provider_idx
290
+ return self.selected_model_idx
291
+
292
+ def _set_selected_index(self, index: int) -> None:
293
+ """Set the selected absolute index for the active list view."""
294
+ if self.view_mode == "providers":
295
+ self.selected_provider_idx = index
296
+ else:
297
+ self.selected_model_idx = index
298
+
299
+ def _ensure_selection_visible(self) -> None:
300
+ """Keep the selected item on the current page."""
301
+ self.current_page = ensure_visible_page(
302
+ self._get_selected_index(),
303
+ self.current_page,
304
+ self._get_total_items(),
305
+ PAGE_SIZE,
306
+ )
307
+
308
+ def _go_to_previous_page(self) -> None:
309
+ """Move to the previous page and select its first item."""
310
+ if self.current_page <= 0:
311
+ return
312
+ self.current_page -= 1
313
+ self._set_selected_index(self.current_page * PAGE_SIZE)
314
+
315
+ def _go_to_next_page(self) -> None:
316
+ """Move to the next page and select its first item."""
317
+ total_pages = get_total_pages(self._get_total_items(), PAGE_SIZE)
318
+ if self.current_page >= total_pages - 1:
319
+ return
320
+ self.current_page += 1
321
+ self._set_selected_index(self.current_page * PAGE_SIZE)
322
+
323
+ def _render_provider_list(self) -> list:
324
+ """Render the provider list panel."""
325
+ lines = []
326
+
327
+ lines.append(("", " Providers"))
328
+ lines.append(("", "\n\n"))
329
+
330
+ if not self.providers:
331
+ lines.append(("fg:yellow", " No providers available."))
332
+ lines.append(("", "\n\n"))
333
+ self._render_navigation_hints(lines)
334
+ return lines
335
+
336
+ filter_label = getattr(self, "provider_filter", "") or "type to filter"
337
+ lines.append(("fg:ansibrightblack", f" Filter: {filter_label}"))
338
+ lines.append(("", "\n\n"))
339
+
340
+ filtered_providers = self._filtered_providers()
341
+ if not filtered_providers:
342
+ lines.append(("fg:yellow", " No providers match the current filter."))
343
+ lines.append(("", "\n\n"))
344
+ self._render_navigation_hints(lines)
345
+ return lines
346
+
347
+ # Show providers for current page
348
+ total_pages = get_total_pages(len(filtered_providers), PAGE_SIZE)
349
+ start_idx, end_idx = get_page_bounds(
350
+ self.current_page,
351
+ len(filtered_providers),
352
+ PAGE_SIZE,
353
+ )
354
+
355
+ for i in range(start_idx, end_idx):
356
+ provider = filtered_providers[i]
357
+ is_selected = i == self.selected_provider_idx
358
+ is_unsupported = provider.id in UNSUPPORTED_PROVIDERS
359
+
360
+ # Format: "> Provider Name (X models)" or " Provider Name (X models)"
361
+ prefix = " > " if is_selected else " "
362
+ suffix = " ⚠️" if is_unsupported else ""
363
+ label = f"{prefix}{provider.name} ({provider.model_count} models){suffix}"
364
+
365
+ # Use dimmed color for unsupported providers
366
+ if is_unsupported:
367
+ lines.append(("fg:ansibrightblack dim", label))
368
+ elif is_selected:
369
+ lines.append(("fg:ansibrightblack", label))
370
+ else:
371
+ lines.append(("fg:ansibrightblack", label))
372
+
373
+ lines.append(("", "\n"))
374
+
375
+ lines.append(("", "\n"))
376
+ lines.append(
377
+ ("fg:ansibrightblack", f" Page {self.current_page + 1}/{total_pages}")
378
+ )
379
+ lines.append(("", "\n"))
380
+
381
+ self._render_navigation_hints(lines)
382
+ return lines
383
+
384
+ def _render_model_list(self) -> list:
385
+ """Render the model list panel."""
386
+ lines = []
387
+
388
+ if not self.current_provider:
389
+ lines.append(("fg:yellow", " No provider selected."))
390
+ lines.append(("", "\n\n"))
391
+ self._render_navigation_hints(lines)
392
+ return lines
393
+
394
+ lines.append(("", f" {self.current_provider.name} Models"))
395
+ lines.append(("", "\n"))
396
+ filter_label = getattr(self, "model_filter", "") or "type to filter"
397
+ lines.append(("fg:ansibrightblack", f" Filter: {filter_label}"))
398
+ lines.append(("", "\n\n"))
399
+
400
+ filtered_models = self._filtered_models()
401
+ custom_visible = self._should_show_custom_model()
402
+ if not filtered_models and not custom_visible:
403
+ lines.append(("fg:yellow", " No models match the current filter."))
404
+ lines.append(("", "\n\n"))
405
+ self._render_navigation_hints(lines)
406
+ return lines
407
+
408
+ # Total items = models + 1 for custom model option
409
+ total_items = len(filtered_models) + int(custom_visible)
410
+ total_pages = get_total_pages(total_items, PAGE_SIZE)
411
+ start_idx, end_idx = get_page_bounds(self.current_page, total_items, PAGE_SIZE)
412
+
413
+ # Render models from the current page
414
+ for i in range(start_idx, end_idx):
415
+ # Check if this is the custom model option (last item)
416
+ if custom_visible and i == len(filtered_models):
417
+ is_selected = i == self.selected_model_idx
418
+ if is_selected:
419
+ lines.append(("fg:ansicyan bold", " > ✨ Custom model..."))
420
+ else:
421
+ lines.append(("fg:ansicyan", " ✨ Custom model..."))
422
+ lines.append(("", "\n"))
423
+ continue
424
+
425
+ model = filtered_models[i]
426
+ is_selected = i == self.selected_model_idx
427
+
428
+ # Create capability icons
429
+ icons = []
430
+ if model.has_vision:
431
+ icons.append("👁")
432
+ if model.tool_call:
433
+ icons.append("🔧")
434
+ if model.reasoning:
435
+ icons.append("🧠")
436
+
437
+ icon_str = " ".join(icons) + " " if icons else ""
438
+
439
+ if is_selected:
440
+ lines.append(("fg:ansibrightblack", f" > {icon_str}{model.name}"))
441
+ else:
442
+ lines.append(("fg:ansibrightblack", f" {icon_str}{model.name}"))
443
+
444
+ lines.append(("", "\n"))
445
+
446
+ lines.append(("", "\n"))
447
+ lines.append(
448
+ ("fg:ansibrightblack", f" Page {self.current_page + 1}/{total_pages}")
449
+ )
450
+ lines.append(("", "\n"))
451
+
452
+ self._render_navigation_hints(lines)
453
+ return lines
454
+
455
+ def _render_navigation_hints(self, lines: list):
456
+ """Render navigation hints at the bottom of the list panel."""
457
+ lines.append(("", "\n"))
458
+ lines.append(("fg:ansibrightblack", " ↑/↓ "))
459
+ lines.append(("", "Navigate "))
460
+ lines.append(("fg:ansibrightblack", "←/→ "))
461
+ lines.append(("", "Page\n"))
462
+ lines.append(("fg:ansibrightblack", " Type "))
463
+ lines.append(("", "Filter list\n"))
464
+ lines.append(("fg:ansibrightblack", " Backspace "))
465
+ lines.append(("", "Delete filter char\n"))
466
+ lines.append(("fg:ansibrightblack", " Ctrl+U "))
467
+ lines.append(("", "Clear filter\n"))
468
+ if self.view_mode == "providers":
469
+ lines.append(("fg:green", " Enter "))
470
+ lines.append(("", "Select\n"))
471
+ else:
472
+ lines.append(("fg:green", " Enter "))
473
+ lines.append(("", "Add Model\n"))
474
+ lines.append(("fg:ansibrightblack", " Esc/Back "))
475
+ lines.append(("", "Back\n"))
476
+ lines.append(("fg:ansibrightred", " Ctrl+C "))
477
+ lines.append(("", "Cancel"))
478
+
479
+ def _render_model_details(self) -> list:
480
+ """Render the model details panel."""
481
+ lines = []
482
+
483
+ lines.append(("dim cyan", " MODEL DETAILS"))
484
+ lines.append(("", "\n\n"))
485
+
486
+ if self.view_mode == "providers":
487
+ provider = self._get_current_provider()
488
+ if not provider:
489
+ lines.append(("fg:yellow", " No provider selected."))
490
+ return lines
491
+
492
+ lines.append(("bold", f" {provider.name}"))
493
+ lines.append(("", "\n"))
494
+ lines.append(("fg:ansibrightblack", f" ID: {provider.id}"))
495
+ lines.append(("", "\n"))
496
+ lines.append(("fg:ansibrightblack", f" Models: {provider.model_count}"))
497
+ lines.append(("", "\n"))
498
+ lines.append(("fg:ansibrightblack", f" API: {provider.api}"))
499
+ lines.append(("", "\n"))
500
+
501
+ # Show unsupported warning if applicable
502
+ if provider.id in UNSUPPORTED_PROVIDERS:
503
+ lines.append(("", "\n"))
504
+ lines.append(("fg:ansired bold", " ⚠️ UNSUPPORTED PROVIDER"))
505
+ lines.append(("", "\n"))
506
+ lines.append(("fg:ansired", f" {UNSUPPORTED_PROVIDERS[provider.id]}"))
507
+ lines.append(("", "\n"))
508
+ lines.append(
509
+ (
510
+ "fg:ansibrightblack",
511
+ " Models from this provider cannot be added.",
512
+ )
513
+ )
514
+ lines.append(("", "\n"))
515
+
516
+ if provider.env:
517
+ lines.append(("", "\n"))
518
+ lines.append(("bold", " Environment Variables:"))
519
+ lines.append(("", "\n"))
520
+ for env_var in provider.env:
521
+ lines.append(("fg:ansibrightblack", f" • {env_var}"))
522
+ lines.append(("", "\n"))
523
+
524
+ if provider.doc:
525
+ lines.append(("", "\n"))
526
+ lines.append(("bold", " Documentation:"))
527
+ lines.append(("", "\n"))
528
+ lines.append(("fg:ansibrightblack", f" {provider.doc}"))
529
+ lines.append(("", "\n"))
530
+
531
+ else: # models view
532
+ model = self._get_current_model()
533
+ provider = self.current_provider
534
+
535
+ if not provider:
536
+ lines.append(("fg:yellow", " No model selected."))
537
+ return lines
538
+
539
+ # Handle custom model option
540
+ if self._is_custom_model_selected():
541
+ lines.append(("bold", " ✨ Custom Model"))
542
+ lines.append(("", "\n\n"))
543
+ lines.append(("fg:ansicyan", " Add a model not listed in models.dev"))
544
+ lines.append(("", "\n\n"))
545
+ lines.append(("bold", " How it works:"))
546
+ lines.append(("", "\n"))
547
+ lines.append(("fg:ansibrightblack", " 1. Press Enter to select"))
548
+ lines.append(("", "\n"))
549
+ lines.append(("fg:ansibrightblack", " 2. Enter the model ID/name"))
550
+ lines.append(("", "\n"))
551
+ lines.append(
552
+ ("fg:ansibrightblack", f" 3. Uses {provider.name}'s API endpoint")
553
+ )
554
+ lines.append(("", "\n\n"))
555
+ lines.append(("bold", " Use cases:"))
556
+ lines.append(("", "\n"))
557
+ lines.append(("fg:ansibrightblack", " • Newly released models"))
558
+ lines.append(("", "\n"))
559
+ lines.append(("fg:ansibrightblack", " • Fine-tuned models"))
560
+ lines.append(("", "\n"))
561
+ lines.append(("fg:ansibrightblack", " • Preview/beta models"))
562
+ lines.append(("", "\n"))
563
+ lines.append(("fg:ansibrightblack", " • Custom deployments"))
564
+ lines.append(("", "\n\n"))
565
+ if provider.env:
566
+ lines.append(("bold", " Required credentials:"))
567
+ lines.append(("", "\n"))
568
+ for env_var in provider.env:
569
+ lines.append(("fg:ansibrightblack", f" • {env_var}"))
570
+ lines.append(("", "\n"))
571
+ return lines
572
+
573
+ if not model:
574
+ lines.append(("fg:yellow", " No model selected."))
575
+ return lines
576
+
577
+ lines.append(("bold", f" {provider.name} - {model.name}"))
578
+ lines.append(("", "\n\n"))
579
+
580
+ # BIG WARNING for models without tool calling
581
+ if not model.tool_call:
582
+ lines.append(("fg:ansiyellow bold", " ⚠️ NO TOOL CALLING SUPPORT"))
583
+ lines.append(("", "\n"))
584
+ lines.append(
585
+ ("fg:ansiyellow", " This model cannot use tools (file ops,")
586
+ )
587
+ lines.append(("", "\n"))
588
+ lines.append(
589
+ ("fg:ansiyellow", " shell commands, etc). It will be very")
590
+ )
591
+ lines.append(("", "\n"))
592
+ lines.append(("fg:ansiyellow", " limited for coding tasks!"))
593
+ lines.append(("", "\n\n"))
594
+
595
+ # Capabilities
596
+ lines.append(("bold", " Capabilities:"))
597
+ lines.append(("", "\n"))
598
+
599
+ capabilities = [
600
+ ("Vision", model.has_vision),
601
+ ("Tool Calling", model.tool_call),
602
+ ("Reasoning", model.reasoning),
603
+ ("Temperature", model.temperature),
604
+ ("Structured Output", model.structured_output),
605
+ ("Attachments", model.attachment),
606
+ ]
607
+
608
+ for cap_name, has_cap in capabilities:
609
+ if has_cap:
610
+ lines.append(("fg:green", f" ✓ {cap_name}"))
611
+ else:
612
+ lines.append(("fg:ansibrightblack", f" ✗ {cap_name}"))
613
+ lines.append(("", "\n"))
614
+
615
+ # Pricing
616
+ lines.append(("", "\n"))
617
+ lines.append(("bold", " Pricing:"))
618
+ lines.append(("", "\n"))
619
+
620
+ if model.cost_input is not None or model.cost_output is not None:
621
+ if model.cost_input is not None:
622
+ lines.append(
623
+ (
624
+ "fg:ansibrightblack",
625
+ f" Input: ${model.cost_input:.6f}/token",
626
+ )
627
+ )
628
+ lines.append(("", "\n"))
629
+ if model.cost_output is not None:
630
+ lines.append(
631
+ (
632
+ "fg:ansibrightblack",
633
+ f" Output: ${model.cost_output:.6f}/token",
634
+ )
635
+ )
636
+ lines.append(("", "\n"))
637
+ if model.cost_cache_read is not None:
638
+ lines.append(
639
+ (
640
+ "fg:ansibrightblack",
641
+ f" Cache Read: ${model.cost_cache_read:.6f}/token",
642
+ )
643
+ )
644
+ lines.append(("", "\n"))
645
+ else:
646
+ lines.append(("fg:ansibrightblack", " Pricing not available"))
647
+ lines.append(("", "\n"))
648
+
649
+ # Limits
650
+ lines.append(("", "\n"))
651
+ lines.append(("bold", " Limits:"))
652
+ lines.append(("", "\n"))
653
+
654
+ if model.context_length > 0:
655
+ lines.append(
656
+ (
657
+ "fg:ansibrightblack",
658
+ f" Context: {model.context_length:,} tokens",
659
+ )
660
+ )
661
+ lines.append(("", "\n"))
662
+ if model.max_output > 0:
663
+ lines.append(
664
+ (
665
+ "fg:ansibrightblack",
666
+ f" Max Output: {model.max_output:,} tokens",
667
+ )
668
+ )
669
+ lines.append(("", "\n"))
670
+
671
+ # Modalities
672
+ if model.input_modalities or model.output_modalities:
673
+ lines.append(("", "\n"))
674
+ lines.append(("bold", " Modalities:"))
675
+ lines.append(("", "\n"))
676
+
677
+ if model.input_modalities:
678
+ lines.append(
679
+ (
680
+ "fg:ansibrightblack",
681
+ f" Input: {', '.join(model.input_modalities)}",
682
+ )
683
+ )
684
+ lines.append(("", "\n"))
685
+ if model.output_modalities:
686
+ lines.append(
687
+ (
688
+ "fg:ansibrightblack",
689
+ f" Output: {', '.join(model.output_modalities)}",
690
+ )
691
+ )
692
+ lines.append(("", "\n"))
693
+
694
+ # Metadata
695
+ lines.append(("", "\n"))
696
+ lines.append(("bold", " Metadata:"))
697
+ lines.append(("", "\n"))
698
+
699
+ lines.append(("fg:ansibrightblack", f" Model ID: {model.model_id}"))
700
+ lines.append(("", "\n"))
701
+ lines.append(("fg:ansibrightblack", f" Full ID: {model.full_id}"))
702
+ lines.append(("", "\n"))
703
+
704
+ if model.knowledge:
705
+ lines.append(
706
+ ("fg:ansibrightblack", f" Knowledge: {model.knowledge}")
707
+ )
708
+ lines.append(("", "\n"))
709
+
710
+ if model.release_date:
711
+ lines.append(
712
+ ("fg:ansibrightblack", f" Released: {model.release_date}")
713
+ )
714
+ lines.append(("", "\n"))
715
+
716
+ lines.append(
717
+ ("fg:ansibrightblack", f" Open Weights: {model.open_weights}")
718
+ )
719
+ lines.append(("", "\n"))
720
+
721
+ return lines
722
+
723
+ def _add_model_to_extra_config(
724
+ self, model: ModelInfo, provider: ProviderInfo
725
+ ) -> bool:
726
+ """Add a model to the extra_models.json configuration file.
727
+
728
+ The extra_models.json format is a dictionary where:
729
+ - Keys are user-friendly model names (e.g., "provider-model-name")
730
+ - Values contain type, name, custom_endpoint (if needed), and context_length
731
+ """
732
+ try:
733
+ # Load existing extra models (dictionary format)
734
+ extra_models_path = Path(EXTRA_MODELS_FILE)
735
+ extra_models: dict = {}
736
+
737
+ if extra_models_path.exists():
738
+ try:
739
+ with open(extra_models_path, encoding="utf-8") as f:
740
+ extra_models = json.load(f)
741
+ if not isinstance(extra_models, dict):
742
+ emit_error(
743
+ "extra_models.json must be a dictionary, not a list"
744
+ )
745
+ return False
746
+ except json.JSONDecodeError as e:
747
+ emit_error(f"Error parsing extra_models.json: {e}")
748
+ return False
749
+
750
+ # Create a unique key for this model (provider-modelname format)
751
+ model_key = f"{provider.id}-{model.model_id}".replace("/", "-").replace(
752
+ ":", "-"
753
+ )
754
+
755
+ # Check for duplicates
756
+ if model_key in extra_models:
757
+ emit_info(f"Model {model_key} is already in extra_models.json")
758
+ return True # Not an error, just already exists
759
+
760
+ # Convert to Muse config format (dictionary value)
761
+ config = self._build_model_config(model, provider)
762
+ extra_models[model_key] = config
763
+
764
+ # Ensure directory exists
765
+ extra_models_path.parent.mkdir(parents=True, exist_ok=True)
766
+
767
+ # Save updated configuration (atomic write)
768
+ temp_path = extra_models_path.with_suffix(".tmp")
769
+ with open(temp_path, "w", encoding="utf-8") as f:
770
+ json.dump(extra_models, f, indent=4, ensure_ascii=False)
771
+ temp_path.replace(extra_models_path)
772
+
773
+ emit_info(f"Added {model_key} to extra_models.json")
774
+ return True
775
+
776
+ except Exception as e:
777
+ emit_error(f"Error adding model to extra_models.json: {e}")
778
+ return False
779
+
780
+ def _build_model_config(self, model: ModelInfo, provider: ProviderInfo) -> dict:
781
+ """Build a Muse compatible model configuration.
782
+
783
+ Format matches models.json structure:
784
+ {
785
+ "type": "openai" | "anthropic" | "gemini" | "custom_openai" | etc.,
786
+ "name": "actual-model-id",
787
+ "custom_endpoint": {"url": "...", "api_key": "$ENV_VAR"}, # if needed
788
+ "context_length": 200000
789
+ }
790
+ """
791
+ # Map provider IDs to Muse types
792
+ type_mapping = {
793
+ "openai": "openai",
794
+ "anthropic": "anthropic",
795
+ "google": "gemini",
796
+ "google-vertex": "gemini",
797
+ "mistral": "custom_openai",
798
+ "groq": "custom_openai",
799
+ "together-ai": "custom_openai",
800
+ "fireworks": "custom_openai",
801
+ "deepseek": "custom_openai",
802
+ "openrouter": "custom_openai",
803
+ "cerebras": "cerebras",
804
+ "cohere": "custom_openai",
805
+ "perplexity": "custom_openai",
806
+ "minimax": "custom_anthropic",
807
+ }
808
+
809
+ # Determine the model type
810
+ model_type = type_mapping.get(provider.id, "custom_openai")
811
+
812
+ # Special case: kimi-for-coding provider uses "kimi-for-coding" as the model name
813
+ # instead of the model_id from models.dev (which is "kimi-k2-thinking")
814
+ if provider.id == "kimi-for-coding":
815
+ model_name = "kimi-for-coding"
816
+ else:
817
+ model_name = model.model_id
818
+
819
+ config: dict = {
820
+ "type": model_type,
821
+ "provider": derive_provider_identity(provider),
822
+ "name": model_name,
823
+ }
824
+
825
+ # Add custom endpoint for non-standard providers
826
+ if model_type == "custom_openai":
827
+ # Get the API URL - prefer provider.api, fall back to hardcoded endpoints
828
+ api_url = provider.api
829
+ if not api_url or api_url == "N/A":
830
+ api_url = PROVIDER_ENDPOINTS.get(provider.id)
831
+
832
+ if api_url:
833
+ # Determine the API key environment variable
834
+ api_key_env = f"${provider.env[0]}" if provider.env else "$API_KEY"
835
+ config["custom_endpoint"] = {"url": api_url, "api_key": api_key_env}
836
+
837
+ # Special handling for minimax: uses custom_anthropic but needs custom_endpoint
838
+ # and the URL needs /v1 stripped (comes as https://api.minimax.io/anthropic/v1)
839
+ if provider.id == "minimax" and provider.api:
840
+ api_url = provider.api
841
+ # Strip /v1 suffix if present
842
+ if api_url.endswith("/v1"):
843
+ api_url = api_url[:-3]
844
+ api_key_env = f"${provider.env[0]}" if provider.env else "$API_KEY"
845
+ config["custom_endpoint"] = {"url": api_url, "api_key": api_key_env}
846
+
847
+ # Add context length if available
848
+ if model.context_length and model.context_length > 0:
849
+ config["context_length"] = model.context_length
850
+
851
+ # Add supported settings based on model type
852
+ if model_type == "anthropic":
853
+ config["supported_settings"] = [
854
+ "temperature",
855
+ "extended_thinking",
856
+ "budget_tokens",
857
+ ]
858
+ elif model_type == "openai" and "gpt-5" in model.model_id:
859
+ # GPT-5 models have special settings
860
+ if "codex" in model.model_id:
861
+ config["supported_settings"] = [
862
+ "temperature",
863
+ "top_p",
864
+ "reasoning_effort",
865
+ ]
866
+ else:
867
+ config["supported_settings"] = [
868
+ "temperature",
869
+ "top_p",
870
+ "reasoning_effort",
871
+ "verbosity",
872
+ ]
873
+ else:
874
+ # Default settings for most models
875
+ config["supported_settings"] = ["temperature", "seed", "top_p"]
876
+
877
+ return config
878
+
879
+ def update_display(self):
880
+ """Update the display based on current state."""
881
+ if self.view_mode == "providers":
882
+ self.menu_control.text = self._render_provider_list()
883
+ else:
884
+ self.menu_control.text = self._render_model_list()
885
+
886
+ self.preview_control.text = self._render_model_details()
887
+
888
+ def _enter_provider(self):
889
+ """Enter the selected provider to view its models."""
890
+ provider = self._get_current_provider()
891
+ if not provider or not self.registry:
892
+ return
893
+
894
+ self.current_provider = provider
895
+ self.current_models = self.registry.get_models(provider.id)
896
+ self.view_mode = "models"
897
+ self.model_filter = ""
898
+ self.selected_model_idx = 0
899
+ self.current_page = get_page_for_index(self.selected_model_idx, PAGE_SIZE)
900
+ self.update_display()
901
+
902
+ def _go_back_to_providers(self):
903
+ """Go back to providers view."""
904
+ self.view_mode = "providers"
905
+ self.current_provider = None
906
+ self.current_models = []
907
+ self.model_filter = ""
908
+ self.selected_model_idx = 0
909
+ self.current_page = get_page_for_index(self.selected_provider_idx, PAGE_SIZE)
910
+ self.update_display()
911
+
912
+ def _add_current_model(self):
913
+ """Add the currently selected model to extra_models.json."""
914
+ provider = self.current_provider
915
+
916
+ if not provider:
917
+ return
918
+
919
+ # Block unsupported providers
920
+ if provider.id in UNSUPPORTED_PROVIDERS:
921
+ self.result = "unsupported"
922
+ return
923
+
924
+ # Check if custom model option is selected
925
+ if self._is_custom_model_selected():
926
+ self.is_custom_model_selected = True
927
+ self.pending_provider = provider
928
+ self.result = (
929
+ "pending_custom_model" # Signal to prompt for custom model name
930
+ )
931
+ return
932
+
933
+ model = self._get_current_model()
934
+ if model:
935
+ # Store model/provider for credential prompting after TUI exits
936
+ self.pending_model = model
937
+ self.pending_provider = provider
938
+ self.result = "pending_credentials" # Signal to prompt for credentials
939
+
940
+ def _try_add_current_model(self) -> bool:
941
+ """Attempt to add the current model selection and report success."""
942
+ if self.view_mode != "models" or self._get_total_items() <= 0:
943
+ return False
944
+
945
+ self._add_current_model()
946
+ return self.result is not None
947
+
948
+ def _get_missing_env_vars(self, provider: ProviderInfo) -> list[str]:
949
+ """Check which required env vars are missing for a provider."""
950
+ missing = []
951
+ for env_var in provider.env:
952
+ if not os.environ.get(env_var):
953
+ missing.append(env_var)
954
+ return missing
955
+
956
+ def _prompt_for_credentials(self, provider: ProviderInfo) -> bool:
957
+ """Prompt user for missing credentials and save them.
958
+
959
+ Returns:
960
+ True if all credentials were provided (or none needed), False if user cancelled
961
+ """
962
+ missing_vars = self._get_missing_env_vars(provider)
963
+
964
+ if not missing_vars:
965
+ emit_info(
966
+ f"✅ All required credentials for {provider.name} are already set!"
967
+ )
968
+ return True
969
+
970
+ emit_info(f"\n🔑 {provider.name} requires the following credentials:\n")
971
+
972
+ for env_var in missing_vars:
973
+ # Show helpful hints based on common env var patterns
974
+ hint = self._get_env_var_hint(env_var)
975
+ if hint:
976
+ emit_info(f" {hint}")
977
+
978
+ try:
979
+ # Use safe_input for cross-platform compatibility (Windows fix)
980
+ value = safe_input(f" Enter {env_var} (or press Enter to skip): ")
981
+
982
+ if not value:
983
+ emit_warning(
984
+ f"Skipped {env_var} - you can set it later with /set {env_var}=<value>"
985
+ )
986
+ continue
987
+
988
+ value = str(value)
989
+
990
+ # Save to config
991
+ set_config_value(env_var, value)
992
+ # Also set in current environment so it's immediately available
993
+ os.environ[env_var] = value
994
+ emit_info(f"✅ Saved {env_var} to config")
995
+
996
+ except KeyboardInterrupt, EOFError:
997
+ emit_info("") # Clean newline
998
+ emit_warning("Credential input cancelled")
999
+ return False
1000
+
1001
+ return True
1002
+
1003
+ def _create_custom_model_info(
1004
+ self, model_name: str, context_length: int = 128000
1005
+ ) -> ModelInfo:
1006
+ """Create a ModelInfo object for a custom model.
1007
+
1008
+ Since we don't know the model's capabilities, we assume reasonable defaults.
1009
+ """
1010
+ provider_id = self.pending_provider.id if self.pending_provider else "custom"
1011
+ return ModelInfo(
1012
+ provider_id=provider_id,
1013
+ model_id=model_name,
1014
+ name=model_name,
1015
+ tool_call=True, # Assume true for usability
1016
+ temperature=True,
1017
+ context_length=context_length,
1018
+ max_output=min(
1019
+ 16384, context_length // 4
1020
+ ), # Reasonable default based on context
1021
+ input_modalities=["text"],
1022
+ output_modalities=["text"],
1023
+ )
1024
+
1025
+ def _prompt_for_custom_model(self) -> tuple[str, int | None]:
1026
+ """Prompt user for custom model details.
1027
+
1028
+ Returns:
1029
+ Tuple of (model_name, context_length) if provided, None if cancelled
1030
+ """
1031
+ provider = self.pending_provider
1032
+ if not provider:
1033
+ return None
1034
+
1035
+ emit_info(f"\n✨ Adding custom model for {provider.name}\n")
1036
+ emit_info(" Enter the model ID exactly as the provider expects it.")
1037
+ emit_info(
1038
+ " Examples: gpt-4-turbo-preview, claude-3-opus-20240229, gemini-1.5-pro-latest\n"
1039
+ )
1040
+
1041
+ try:
1042
+ model_name = safe_input(" Model ID: ")
1043
+
1044
+ if not model_name:
1045
+ emit_warning("No model name provided, cancelled.")
1046
+ return None
1047
+
1048
+ # Ask for context size
1049
+ emit_info("\n Enter the context window size (in tokens).")
1050
+ emit_info(" Common sizes: 8192, 32768, 128000, 200000, 1000000\n")
1051
+
1052
+ context_input = safe_input(" Context size [128000]: ")
1053
+
1054
+ if not context_input:
1055
+ context_length = 128000 # Default
1056
+ else:
1057
+ # Handle k/K suffix (e.g., "128k" -> 128000)
1058
+ context_input_lower = context_input.lower().replace(",", "")
1059
+ if context_input_lower.endswith("k"):
1060
+ try:
1061
+ context_length = int(float(context_input_lower[:-1]) * 1000)
1062
+ except ValueError:
1063
+ emit_warning("Invalid context size, using default 128000")
1064
+ context_length = 128000
1065
+ elif context_input_lower.endswith("m"):
1066
+ try:
1067
+ context_length = int(float(context_input_lower[:-1]) * 1000000)
1068
+ except ValueError:
1069
+ emit_warning("Invalid context size, using default 128000")
1070
+ context_length = 128000
1071
+ else:
1072
+ try:
1073
+ context_length = int(context_input)
1074
+ except ValueError:
1075
+ emit_warning("Invalid context size, using default 128000")
1076
+ context_length = 128000
1077
+
1078
+ return (model_name, context_length)
1079
+
1080
+ except KeyboardInterrupt, EOFError:
1081
+ emit_info("") # Clean newline
1082
+ emit_warning("Custom model input cancelled")
1083
+ return None
1084
+
1085
+ def _get_env_var_hint(self, env_var: str) -> str:
1086
+ """Get a helpful hint for common environment variables."""
1087
+ hints = {
1088
+ "OPENAI_API_KEY": "💡 Get your API key from https://platform.openai.com/api-keys",
1089
+ "ANTHROPIC_API_KEY": "💡 Get your API key from https://console.anthropic.com/",
1090
+ "GEMINI_API_KEY": "💡 Get your API key from https://aistudio.google.com/apikey",
1091
+ "GOOGLE_API_KEY": "💡 Get your API key from https://aistudio.google.com/apikey",
1092
+ "AZURE_API_KEY": "💡 Get your API key from Azure Portal > Your OpenAI Resource > Keys",
1093
+ "AZURE_RESOURCE_NAME": "💡 Your Azure OpenAI resource name (not the full URL)",
1094
+ "GROQ_API_KEY": "💡 Get your API key from https://console.groq.com/keys",
1095
+ "MISTRAL_API_KEY": "💡 Get your API key from https://console.mistral.ai/",
1096
+ "COHERE_API_KEY": "💡 Get your API key from https://dashboard.cohere.com/api-keys",
1097
+ "DEEPSEEK_API_KEY": "💡 Get your API key from https://platform.deepseek.com/",
1098
+ "TOGETHER_API_KEY": "💡 Get your API key from https://api.together.xyz/settings/api-keys",
1099
+ "FIREWORKS_API_KEY": "💡 Get your API key from https://fireworks.ai/api-keys",
1100
+ "OPENROUTER_API_KEY": "💡 Get your API key from https://openrouter.ai/keys",
1101
+ "PERPLEXITY_API_KEY": "💡 Get your API key from https://www.perplexity.ai/settings/api",
1102
+ "CEREBRAS_API_KEY": "💡 Get your API key from https://cloud.cerebras.ai/",
1103
+ "HUGGINGFACE_API_KEY": "💡 Get your API key from https://huggingface.co/settings/tokens",
1104
+ "XAI_API_KEY": "💡 Get your API key from https://console.x.ai/",
1105
+ }
1106
+ return hints.get(env_var, "")
1107
+
1108
+ def run(self) -> bool:
1109
+ """Run the interactive model browser (synchronous).
1110
+
1111
+ Returns:
1112
+ True if a model was added, False otherwise
1113
+ """
1114
+ if not self.registry or not self.providers:
1115
+ emit_warning("No models data available.")
1116
+ return False
1117
+
1118
+ # Build UI
1119
+ self.menu_control = FormattedTextControl(text="")
1120
+ self.preview_control = FormattedTextControl(text="")
1121
+
1122
+ menu_window = Window(
1123
+ content=self.menu_control, wrap_lines=True, width=Dimension(weight=30)
1124
+ )
1125
+ preview_window = Window(
1126
+ content=self.preview_control, wrap_lines=True, width=Dimension(weight=70)
1127
+ )
1128
+
1129
+ menu_frame = Frame(menu_window, width=Dimension(weight=30), title="Browse")
1130
+ preview_frame = Frame(
1131
+ preview_window, width=Dimension(weight=70), title="Details"
1132
+ )
1133
+
1134
+ root_container = VSplit([menu_frame, preview_frame])
1135
+
1136
+ # Key bindings
1137
+ kb = KeyBindings()
1138
+
1139
+ @kb.add("up")
1140
+ @kb.add("c-p") # Ctrl+P = previous (Emacs-style)
1141
+ def _(event):
1142
+ if self.view_mode == "providers":
1143
+ if self.selected_provider_idx > 0:
1144
+ self.selected_provider_idx -= 1
1145
+ self._ensure_selection_visible()
1146
+ else: # models view
1147
+ if self.selected_model_idx > 0:
1148
+ self.selected_model_idx -= 1
1149
+ self._ensure_selection_visible()
1150
+ self.update_display()
1151
+
1152
+ @kb.add("down")
1153
+ @kb.add("c-n") # Ctrl+N = next (Emacs-style)
1154
+ def _(event):
1155
+ if self.view_mode == "providers":
1156
+ if self.selected_provider_idx < len(self._filtered_providers()) - 1:
1157
+ self.selected_provider_idx += 1
1158
+ self._ensure_selection_visible()
1159
+ else: # models view - include custom model option at the end
1160
+ max_index = self._get_total_items() - 1
1161
+ if self.selected_model_idx < max_index:
1162
+ self.selected_model_idx += 1
1163
+ self._ensure_selection_visible()
1164
+ self.update_display()
1165
+
1166
+ @kb.add("left")
1167
+ def _(event):
1168
+ """Previous page."""
1169
+ self._go_to_previous_page()
1170
+ self.update_display()
1171
+
1172
+ @kb.add("right")
1173
+ def _(event):
1174
+ """Next page."""
1175
+ self._go_to_next_page()
1176
+ self.update_display()
1177
+
1178
+ @kb.add("enter")
1179
+ def _(event):
1180
+ if self.view_mode == "providers":
1181
+ self._enter_provider()
1182
+ elif self.view_mode == "models":
1183
+ # Enter adds the model when viewing models
1184
+ if self._try_add_current_model():
1185
+ event.app.exit()
1186
+
1187
+ @kb.add("escape")
1188
+ def _(event):
1189
+ if self.view_mode == "models":
1190
+ self._go_back_to_providers()
1191
+
1192
+ @kb.add("backspace")
1193
+ def _(event):
1194
+ if self._delete_filter_char():
1195
+ self.update_display()
1196
+ return
1197
+ if self.view_mode == "models":
1198
+ self._go_back_to_providers()
1199
+
1200
+ @kb.add("c-u")
1201
+ def _(event):
1202
+ if self._clear_active_filter():
1203
+ self.update_display()
1204
+
1205
+ @kb.add("<any>")
1206
+ def _(event):
1207
+ if not event.data or not event.data.isprintable():
1208
+ return
1209
+ self._append_filter_char(event.data)
1210
+ self.update_display()
1211
+
1212
+ @kb.add("c-c")
1213
+ def _(event):
1214
+ event.app.exit()
1215
+
1216
+ layout = Layout(root_container)
1217
+ app = Application(
1218
+ layout=layout,
1219
+ key_bindings=kb,
1220
+ full_screen=False,
1221
+ mouse_support=False,
1222
+ )
1223
+
1224
+ set_awaiting_user_input(True)
1225
+
1226
+ # Enter alternate screen buffer once for entire session
1227
+ sys.stdout.write("\033[?1049h") # Enter alternate buffer
1228
+ sys.stdout.write("\033[2J\033[H") # Clear and home
1229
+ sys.stdout.flush()
1230
+ time.sleep(0.05)
1231
+
1232
+ try:
1233
+ # Initial display
1234
+ self.update_display()
1235
+
1236
+ # Just clear the current buffer (don't switch buffers)
1237
+ sys.stdout.write("\033[2J\033[H") # Clear screen within current buffer
1238
+ sys.stdout.flush()
1239
+
1240
+ # Run application in a background thread to avoid event loop conflicts
1241
+ # This is needed because code_muse runs in an async context
1242
+ app.run(in_thread=True)
1243
+
1244
+ finally:
1245
+ # Exit alternate screen buffer once at end
1246
+ sys.stdout.write("\033[?1049l") # Exit alternate buffer
1247
+ sys.stdout.flush()
1248
+ # Reset awaiting input flag
1249
+ set_awaiting_user_input(False)
1250
+
1251
+ # Clear exit message (unless we're about to prompt for more input)
1252
+ if self.result not in ("pending_credentials", "pending_custom_model"):
1253
+ emit_info("✓ Exited model browser")
1254
+
1255
+ # Handle unsupported provider
1256
+ if self.result == "unsupported" and self.current_provider:
1257
+ reason = UNSUPPORTED_PROVIDERS.get(
1258
+ self.current_provider.id, "Not supported"
1259
+ )
1260
+ emit_error(f"Cannot add model from {self.current_provider.name}: {reason}")
1261
+ return False
1262
+
1263
+ # Handle custom model flow after TUI exits
1264
+ if self.result == "pending_custom_model" and self.pending_provider:
1265
+ # Prompt for custom model details (name and context size)
1266
+ custom_model_result = self._prompt_for_custom_model()
1267
+ if not custom_model_result:
1268
+ return False
1269
+
1270
+ model_name, context_length = custom_model_result
1271
+
1272
+ # Create a ModelInfo for the custom model
1273
+ self.pending_model = self._create_custom_model_info(
1274
+ model_name, context_length
1275
+ )
1276
+
1277
+ # Prompt for any missing credentials
1278
+ if self._prompt_for_credentials(self.pending_provider):
1279
+ # Now add the model to config
1280
+ if self._add_model_to_extra_config(
1281
+ self.pending_model, self.pending_provider
1282
+ ):
1283
+ self.result = "added"
1284
+ return True
1285
+ return False
1286
+
1287
+ # Handle pending credential flow after TUI exits
1288
+ if (
1289
+ self.result == "pending_credentials"
1290
+ and self.pending_model
1291
+ and self.pending_provider
1292
+ ):
1293
+ # Warn about non-tool-calling models
1294
+ if not self.pending_model.tool_call:
1295
+ emit_warning(
1296
+ f"⚠️ {self.pending_model.name} does NOT support tool calling!\n"
1297
+ f" This model won't be able to edit files, run commands, or use any tools.\n"
1298
+ f" It will be very limited for coding tasks."
1299
+ )
1300
+ try:
1301
+ confirm = safe_input(
1302
+ "\n Are you sure you want to add this model? (y/N): "
1303
+ ).lower()
1304
+ if confirm not in ("y", "yes"):
1305
+ emit_info("Model addition cancelled.")
1306
+ return False
1307
+ except KeyboardInterrupt, EOFError:
1308
+ emit_info("")
1309
+ return False
1310
+
1311
+ # Prompt for any missing credentials
1312
+ if self._prompt_for_credentials(self.pending_provider):
1313
+ # Now add the model to config
1314
+ if self._add_model_to_extra_config(
1315
+ self.pending_model, self.pending_provider
1316
+ ):
1317
+ self.result = "added"
1318
+ return True
1319
+ return False
1320
+
1321
+ return self.result == "added"
1322
+
1323
+
1324
+ def interactive_model_picker() -> bool:
1325
+ """Show interactive terminal UI to browse and add models.
1326
+
1327
+ Returns:
1328
+ True if a model was added, False otherwise
1329
+ """
1330
+ menu = AddModelMenu()
1331
+ return menu.run()