meltygui 0.1.0__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 (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1355 @@
1
+ """Internet Accounts — one window to manage every login the studio's
2
+ network features use (Anthropic browser sign-in — the OAuth login `ant auth
3
+ login` does, see fim_providers/anthropic_oauth.py — or a pasted API key,
4
+ GitHub Copilot device-flow sign-in, Ollama host + model placement; the Anthropic row also
5
+ shows the Claude plan's usage limits, read through Claude Code's login),
6
+ modelled on fast_dock: rows are plain
7
+ draw-list rects/text with manual hit-testing, hover boosts + clicks resolve
8
+ inside the body while the view is hovered (the wrapper repaints every frame
9
+ then), and the idle tile is a cached blit that background probes repaint
10
+ via `accounts_changed()`.
11
+
12
+ Model: `accounts` (AccountStore, a dict id → account dict) persisted to
13
+ `~/.lsd/accounts.json` (0600 — it holds secrets). An account has a `kind`
14
+ (one of the registered `@account_kind` classes) and the kind's fields.
15
+ Providers resolve credentials through `account_field(kind, id, field)` —
16
+ `ClaudeSession(account="work")`, `CopilotSession(account="home")`, … — so
17
+ a FIM profile selects an account by name and two accounts of one kind are
18
+ two live sessions. A kind's DEFAULT account has id == kind name; sessions
19
+ asking for account="default" resolve to it.
20
+
21
+ Adding a kind: subclass AccountKind, decorate with @account_kind. The window
22
+ renders whatever is registered — the kind supplies its status probe, its
23
+ editable fields, its action buttons and any extra rows (a device-code card,
24
+ the Ollama model list). A kind with a `chat_label` appears in the Chat
25
+ window's provider dropdown; its conversations come from the ChatProxy
26
+ factory registered for the kind name with `register_chat_backend`
27
+ (chat/backends.py) — the studio registers Codex's below, an external
28
+ package (melty_agents) registers Claude Code's against "anthropic".
29
+
30
+ Layout: every row measures its buttons FIRST (`strip_layout`); if the text
31
+ would be left less than min_text_width, or the strip is wider than the row,
32
+ the buttons wrap onto as many right-aligned lines as they need under the
33
+ text (`pack_buttons`; the row grows — card and model sub-rows do the same),
34
+ otherwise status text is ellipsized to what's left. Kind headers, notes and
35
+ field labels ellipsize; usage names stay complete and wrap above their
36
+ bars in narrow windows — so nothing overlaps at any window width.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import os
42
+ import stat
43
+ import threading
44
+ import time
45
+ from pathlib import Path
46
+
47
+ import meltygui_imgui as imgui
48
+ from meltygui.hdr_color import pack_color
49
+
50
+ from meltygui.core.melty import Melty
51
+ from meltygui.chat.backends import chat_backend
52
+ from meltygui.chat.backends import register_chat_backend
53
+ from meltygui.core.conversion.dict_conversion import DictConversion
54
+ from meltygui.core.windowing.glfw_utils import request_render
55
+ from meltygui.core.cache.tile_cache import add_shadow
56
+ from meltygui.core.core_render import render_func
57
+ from meltygui.core.rendering.window_decoration import window
58
+
59
+ # Where the store lives on disk - a data file, not a styling knob; shared
60
+ # by the store methods, the footer row, and the tests' monkeypatch.
61
+ ACCOUNTS_PATH = Path.home() / ".lsd" / "accounts.json"
62
+
63
+
64
+ # ──────────────────────────────────────────────────────────────────────
65
+ # Store
66
+ # ──────────────────────────────────────────────────────────────────────
67
+
68
+ from meltygui.model.account_model import AccountStore
69
+
70
+
71
+ accounts = AccountStore()
72
+
73
+ # Hotswap-safe: keep the live store object (and its file) across re-exec.
74
+ # Sits right after the fresh instance so everything below - the KINDS default
75
+ # fill and the @window registration included - binds the LIVE store, never
76
+ # the throwaway one this re-exec just built.
77
+ _previous_store = Melty.__dict__.get("_internet_accounts_store")
78
+ if _previous_store is not None and _previous_store is not accounts:
79
+ accounts = _previous_store
80
+ Melty._internet_accounts_store = accounts
81
+
82
+
83
+ def is_default(account) -> bool:
84
+ """The kind's default row (`AccountStore.default_account`) — the top row."""
85
+ return accounts.default_account(account.get("kind")) is account
86
+
87
+
88
+ def session_account_id(account) -> str:
89
+ """The `account=` value a session uses for this account — "default" for
90
+ a kind's default entry, so the UI and the providers' default profiles
91
+ pool the SAME session."""
92
+ return "default" if is_default(account) else account["id"]
93
+
94
+
95
+ def account(kind_name, account_id="default"):
96
+ """The account dict for (kind, id), or None. "default" is the kind's
97
+ default entry (id == kind name). Loads the store lazily."""
98
+ if not accounts.loaded:
99
+ accounts.load()
100
+ if account_id in ("default", None, ""):
101
+ return accounts.default_account(kind_name)
102
+ entry = accounts.get(account_id)
103
+ if entry is not None and entry.get("kind") == kind_name:
104
+ return entry
105
+ return None
106
+
107
+
108
+ def account_field(kind_name, account_id, field, default=None):
109
+ entry = account(kind_name, account_id)
110
+ if entry is None:
111
+ return default
112
+ value = entry.get(field)
113
+ return value if value not in (None, "") else default
114
+
115
+
116
+ def accounts_changed():
117
+ """Repaint the window (from any thread)."""
118
+ try:
119
+ Melty.cache.invalidate_up_by_obj(accounts, force=True)
120
+ except Exception:
121
+ pass
122
+ try:
123
+ from meltygui.completion.fim import _wake
124
+ _wake(_window_draw_state)
125
+ except Exception:
126
+ pass
127
+
128
+
129
+ _window_draw_state = None # draw_internet_accounts' draw_state — the wake target
130
+
131
+
132
+ def _drop_sessions_for(account_entry):
133
+ """Close pooled FIM sessions built on this account so the next request
134
+ re-acquires with the new credential."""
135
+ ids = {account_entry.get("id"), session_account_id(account_entry)}
136
+ try:
137
+ import meltygui.completion.fim as fim
138
+ fim.drop_sessions(lambda session: getattr(session, "account", None) in ids
139
+ and getattr(session, "KIND", None) == account_entry.get("kind"))
140
+ except Exception:
141
+ pass
142
+
143
+
144
+ # ──────────────────────────────────────────────────────────────────────────
145
+ # Kinds
146
+ # ──────────────────────────────────────────────────────────────────────────
147
+
148
+ class Field:
149
+ __slots__ = ("name", "label", "secret", "default", "placeholder", "hidden")
150
+
151
+ def __init__(self, name, label, secret=False, default="", placeholder="", hidden=False):
152
+ self.name = name
153
+ self.label = label
154
+ self.secret = secret
155
+ self.default = default
156
+ self.placeholder = placeholder
157
+ self.hidden = hidden # set by a control, not by the Edit rows
158
+
159
+
160
+ class Button:
161
+ """One action on a row. `icon` draws a square icon-only button (with
162
+ `tip` as the hover hint); `label` a text button."""
163
+ __slots__ = ("label", "on_click", "primary", "icon", "tip", "enabled", "danger")
164
+
165
+ def __init__(self, label, on_click, primary=False, icon=None, tip="", enabled=True, danger=False):
166
+ self.label = label
167
+ self.on_click = on_click
168
+ self.primary = primary
169
+ self.icon = icon
170
+ self.tip = tip
171
+ self.enabled = enabled
172
+ self.danger = danger
173
+
174
+
175
+ KINDS = {}
176
+
177
+
178
+ def account_kind(cls):
179
+ """Register an AccountKind subclass (its `name` is the kind key)."""
180
+ KINDS[cls.name] = cls()
181
+ return cls
182
+
183
+
184
+ class AccountKind:
185
+ chat_label = None
186
+
187
+ @property
188
+ def chat_available(self):
189
+ """A backend is registered for this kind (chat/backends.py)."""
190
+ return chat_backend(self.name) is not None
191
+
192
+ def chat_proxy(self, account, metadata=None, wake=None):
193
+ """The kind's conversations: the registered backend's proxy, or None
194
+ (no backend, or the backend declines for now — signing in, busy)."""
195
+ factory = chat_backend(self.name)
196
+ return factory(account, metadata, wake) if factory is not None else None
197
+
198
+ def chats(self, account, wake=None):
199
+ proxy = account.get("_chat_proxy")
200
+ if proxy is not None and getattr(proxy, "session_version", None) != 3:
201
+ self.close_chat(account)
202
+ proxy = None
203
+ if proxy is None or proxy.closed:
204
+ proxy = self.chat_proxy(account, wake=wake)
205
+ if proxy is not None:
206
+ account["_chat_proxy"] = proxy
207
+ return proxy
208
+
209
+ def close_chat(self, account):
210
+ proxy = account.pop("_chat_proxy", None)
211
+ if proxy is not None:
212
+ proxy.close()
213
+
214
+ name = "base"
215
+ label = "Account"
216
+ icon = ""
217
+ tint = (0.5, 0.5, 0.55)
218
+ fields = ()
219
+
220
+ def close(self, account):
221
+ """Release account-owned background work on removal / cleanup."""
222
+ self.close_chat(account)
223
+
224
+ def default_label(self, account_id):
225
+ return self.label if account_id == self.name else f"{self.label} ({account_id})"
226
+
227
+ def status(self, account):
228
+ """(state, text) from the cached probe; state is one of the
229
+ state_tints keys in draw_internet_accounts ("ready", "busy",
230
+ "needs_login", "warning", "error", "unknown")."""
231
+ status = account.get("_status")
232
+ if status is None:
233
+ return ("unknown", "…")
234
+ return status
235
+
236
+ def probe(self, account):
237
+ """Worker thread: return (state, text). May touch the network."""
238
+ return ("unknown", "")
239
+
240
+ def actions(self, account):
241
+ """[Button] — buttons on the row, left to right."""
242
+ return []
243
+
244
+ def sub_rows(self, account):
245
+ """Extra rows under the account: ("card", (message, [Button…])) —
246
+ a highlighted strip with its own buttons (a device code, a
247
+ browser sign-in in progress) | ("model", model dict) |
248
+ ("note", text)."""
249
+ return []
250
+
251
+
252
+ @account_kind
253
+ class AnthropicKind(AccountKind):
254
+ chat_label = "Claude Code"
255
+ name = "anthropic"
256
+ label = "Anthropic"
257
+ icon = f""
258
+ tint = (0.85, 0.55, 0.35)
259
+ fields = (Field("api_key", "API key", secret=True,
260
+ placeholder="sk-ant-… (optional — Sign in needs no key)"),
261
+ Field("profile", "Login profile", placeholder="(lsd)"),
262
+ Field("base_url", "Base URL", placeholder="(default)"),
263
+ Field("claude_code_login", "Claude Code login", default="~/.claude/.credentials.json",
264
+ placeholder="~/.claude/.credentials.json — the plan's usage limits are read through it"))
265
+
266
+ # -- Credential sources ------------------------------------------------
267
+ # Precedence, highest first: a pasted key → the browser sign-in (the
268
+ # account's SDK profile) → env vars → an active `ant auth login`
269
+ # profile. Only the default account reads the env / active profile.
270
+
271
+ @staticmethod
272
+ def _profile_present():
273
+ """An ACTIVE `ant auth login` profile a bare Anthropic() picks up on
274
+ its own. The account's own sign-in is `login_info`, not this."""
275
+ from meltygui.completion.providers.anthropic_oauth import active_profile_present
276
+ return active_profile_present()
277
+
278
+ @staticmethod
279
+ def profile_name(account) -> str:
280
+ """The SDK profile this account signs in to: its `profile` field,
281
+ else Toggles.InternetAccounts.anthropic_profile ("lsd") for the
282
+ account NAMED after the kind and "<that>-<account id>" for the
283
+ others — keyed on the id, not on default-ness, so a row promoted to
284
+ default (the kind-named one removed) keeps its profile files."""
285
+ from meltygui.core.runtime.toggles import Toggles
286
+ name = (account.get("profile") or "").strip()
287
+ if name:
288
+ return name
289
+ base = Toggles.InternetAccounts.anthropic_profile
290
+ return base if account.get("id") == account.get("kind") else f"{base}-{account['id']}"
291
+
292
+ def login_info(self, account, fresh=False):
293
+ """anthropic_oauth.read_profile() of the account's profile, cached
294
+ on the account (`_login_info`) so the per-frame button layout never
295
+ touches the disk; probes, sign-in and sign-out refresh it."""
296
+ if fresh or "_login_info" not in account:
297
+ import meltygui.completion.providers.anthropic_oauth as anthropic_oauth
298
+ account["_login_info"] = anthropic_oauth.read_profile(self.profile_name(account))
299
+ return account["_login_info"]
300
+
301
+ def client_kwargs(self, account):
302
+ """`anthropic.Anthropic(**kwargs)` for this account — the pasted
303
+ `api_key` wins, else the signed-in `profile`, else nothing (the
304
+ SDK's own env / active-profile chain); plus `base_url`. No SDK
305
+ import — shared with the FIM provider (claude.account_client_kwargs)."""
306
+ out = {}
307
+ key = account.get("api_key") or ""
308
+ info = self.login_info(account)
309
+ if info is None:
310
+ # A sign-in may have landed since the last probe (one stat, and
311
+ # this runs at session construction, never per-frame).
312
+ info = self.login_info(account, fresh=True)
313
+ if key:
314
+ out["api_key"] = key
315
+ elif info is not None:
316
+ out["profile"] = self.profile_name(account)
317
+ if account.get("base_url"):
318
+ out["base_url"] = account["base_url"]
319
+ return out
320
+
321
+ def _source(self, account):
322
+ import meltygui.completion.providers.anthropic_oauth as anthropic_oauth
323
+ key = account.get("api_key") or ""
324
+ if key:
325
+ return f"key …{key[-4:]}"
326
+ info = self.login_info(account)
327
+ if info is not None:
328
+ return anthropic_oauth.summary(info)
329
+ if is_default(account) and os.environ.get("ANTHROPIC_API_KEY"):
330
+ return "env ANTHROPIC_API_KEY"
331
+ if is_default(account) and os.environ.get("ANTHROPIC_AUTH_TOKEN"):
332
+ return "env ANTHROPIC_AUTH_TOKEN"
333
+ if is_default(account) and self._profile_present():
334
+ return "ant auth profile"
335
+ return None
336
+
337
+ def status(self, account):
338
+ flow = account.get("_login")
339
+ if flow is not None and not flow.done:
340
+ return ("busy", "waiting for the browser…")
341
+ return super().status(account)
342
+
343
+ def probe(self, account):
344
+ # Passive probe: NO network (and no `import anthropic`). Just report
345
+ # whether a credential exists - the studio should not fire a web
346
+ # request or import the SDK at startup just to show status. The
347
+ # "Test" button (below) does the one real network check on demand.
348
+ self.login_info(account, fresh=True)
349
+ for sibling in accounts.of_kind(self.name):
350
+ if sibling is not account:
351
+ self.login_info(sibling, fresh=True) # ownership of a shared Claude Code login reads their emails
352
+ self._claude_login_for(account)
353
+ source = self._source(account)
354
+ if source is None:
355
+ return ("needs_login", "not signed in")
356
+ return ("ready", f"{source} · verified" if account.get("_validated") else source)
357
+
358
+ # -- Claude plan usage (through Claude Code's login) ----------------------
359
+ # The plan's rate-limit windows (session, weekly all-models, weekly
360
+ # per-model - Fable - etc) and extra-usage spend come from GET
361
+ # /api/oauth/usage, which only answers a claude.ai token: the Console
362
+ # sign-in above is an API-org token and is refused ("Usage limits are
363
+ # not applicable to API organizations"), so the row reads Claude Code's
364
+ # OWN login file - read-only, never refreshed by the studio, a stale
365
+ # token says "open Claude Code first". See fim_providers/claude_usage.py.
366
+
367
+ def _claude_code_login_path(self, account):
368
+ return str(Path(account.get("claude_code_login") or self.fields[-1].default).expanduser())
369
+
370
+ def _claude_login_for(self, account):
371
+ """Claude Code's login for this row's file, through
372
+ claude_usage.cached_login (re-read only when the files change). When
373
+ the identity behind the file changes — a switch by the
374
+ Use-in-Claude-Code button or a `claude auth login` elsewhere — the
375
+ numbers this row cached belonged to the previous account: drop them
376
+ (`_forget_usage`), and ownership is re-decided on this very draw."""
377
+ import meltygui.completion.providers.claude_usage as claude_usage
378
+ login = claude_usage.cached_login(self._claude_code_login_path(account))
379
+ previous = account.get("_claude_login")
380
+ known = "_claude_login" in account
381
+ account["_claude_login"] = login
382
+
383
+ # An identity CHANGE = a different email (both known), or - with no
384
+ # identity file at all - a different token. Never on a momentary
385
+ # blank (a read mid-rewrite of ~/.claude.json), and never on a
386
+ # same-account token refresh: both would blank the bars and refetch.
387
+ old_email = (previous or {}).get("email") or ""
388
+ new_email = (login or {}).get("email") or ""
389
+ changed = False
390
+ if old_email and new_email:
391
+ changed = old_email.lower() != new_email.lower()
392
+ elif not old_email and not new_email:
393
+ changed = (previous or {}).get("token") != (login or {}).get("token")
394
+ if known and changed:
395
+ self._forget_usage(account)
396
+ return login
397
+
398
+ def _forget_usage(self, account):
399
+ for key in ("_usage_rows", "_usage_summary", "_usage_fetched_wall", "_usage_error"):
400
+ account.pop(key, None)
401
+ account["_usage_fetched_at"] = None
402
+ self._disarm_usage_fetch(account)
403
+
404
+ # -- last session's numbers --------------------------------------------------
405
+ # The bars persist in the window (AccountsPanelState.usage, keyed by
406
+ # account id) so a fresh process shows the previous session's numbers
407
+ # at once and the delayed fetch (usage_fetch_delay_s) updates them.
408
+
409
+ def usage_cache_entry(self, account):
410
+ """What the window persists for this row: the rows, the summary,
411
+ when they were fetched and WHOSE they are (the login's email — a
412
+ different login next session must not inherit them). None = nothing."""
413
+ rows = account.get("_usage_rows")
414
+ if not rows or not account.get("_usage_fetched_wall"):
415
+ return None
416
+ return {"rows": rows,
417
+ "summary": account.get("_usage_summary") or "",
418
+ "fetched_wall": account["_usage_fetched_wall"],
419
+ "email": ((account.get("_claude_login") or {}).get("email") or "").lower()}
420
+
421
+ def restore_usage(self, account, cached):
422
+ """A fresh account dict (boot / restart / reload) takes the persisted
423
+ numbers when they belong to the CURRENT Claude Code login (same
424
+ email; a login with no identity file is trusted). Restored numbers
425
+ are stale by definition: `_usage_fetched_at` stays None so an open
426
+ panel arms the delayed fetch. Runs once per account dict."""
427
+ if account.get("_usage_restored") or "_usage_rows" in account:
428
+ return
429
+ account["_usage_restored"] = True
430
+ if not cached or not cached.get("rows"):
431
+ return
432
+ login = self._claude_login_for(account)
433
+ if login is None:
434
+ return
435
+ login_email = (login.get("email") or "").lower()
436
+ if login_email and login_email != (cached.get("email") or ""):
437
+ return
438
+ account["_usage_rows"] = list(cached["rows"])
439
+ account["_usage_summary"] = cached.get("summary") or ""
440
+ account["_usage_fetched_wall"] = cached.get("fetched_wall")
441
+ account["_usage_fetched_at"] = None
442
+
443
+ # -- Changing Claude Code's login -------------------------------------------
444
+ # Claude Code holds ONE login per config dir; the Use-in-Claude-Code
445
+ # button runs its own `claude auth login --email <this row's email>`
446
+ # (claude_usage.ClaudeCodeLogin): the browser opens to the login page
447
+ # with the email filled in, Claude Code's loopback callback completes it,
448
+ # and its rewritten files flip the usage panel to this row on the next
449
+ # draw. The Console page's paste-a-code fallback is covered by the card's
450
+ # Paste code (clipboard → Claude Code's stdin).
451
+
452
+ def switch_claude_code(self, account):
453
+ import meltygui.completion.providers.claude_usage as claude_usage
454
+ from meltygui.core.runtime.toggles import Toggles
455
+ email = (self.login_info(account) or {}).get("email")
456
+ if not email:
457
+ return
458
+ current = account.get("_claude_switch")
459
+ if current is not None and not current.done:
460
+ current.open_in_browser()
461
+ return
462
+ path = Path(self._claude_code_login_path(account))
463
+ default_dir = Path(self.fields[-1].default).expanduser().parent
464
+ login = claude_usage.ClaudeCodeLogin(
465
+ email, executable=claude_usage.find_claude(Toggles.InternetAccounts.claude_code_bin),
466
+ config_dir=None if path.parent == default_dir else path.parent,
467
+ on_change=lambda flow: self._switch_changed(account, flow))
468
+ account["_claude_switch"] = login
469
+ account["_claude_switch_error"] = None
470
+ try:
471
+ login.start()
472
+ except Exception as error:
473
+ account["_claude_switch"] = None
474
+ account["_claude_switch_error"] = str(error)[:120]
475
+ accounts_changed()
476
+
477
+ def _switch_changed(self, account, flow):
478
+ """Worker thread: the URL arrived, or `claude auth login` finished."""
479
+ if flow.done and account.get("_claude_switch") is flow:
480
+ account["_claude_switch"] = None
481
+ if flow.ok:
482
+ # Claude Code rewrote its files: every row sharing them re-reads
483
+ # on its next draw; drop cached numbers now so nothing stale paints.
484
+ for entry in accounts.of_kind(self.name):
485
+ self._forget_usage(entry)
486
+ entry["_status"] = None
487
+ else:
488
+ account["_claude_switch_error"] = (flow.error or "sign-in failed")[:120]
489
+ accounts_changed()
490
+
491
+ def cancel_switch(self, account):
492
+ flow = account.pop("_claude_switch", None)
493
+ if flow is not None:
494
+ flow.cancel()
495
+ account["_claude_switch_error"] = None
496
+ accounts_changed()
497
+
498
+ def paste_switch_code(self, account):
499
+ """The Console page showed a code (browser couldn't reach the loopback
500
+ callback): clipboard → Claude Code's stdin."""
501
+ flow = account.get("_claude_switch")
502
+ try:
503
+ code = (imgui.get_clipboard_text() or "").strip()
504
+ except Exception:
505
+ code = ""
506
+ if flow is not None and code:
507
+ flow.submit_code(code)
508
+
509
+ def _owns_claude_login(self, account, login):
510
+ """One Claude Code login file = one claude.ai account, and every
511
+ Anthropic row points at the same default file — so rows sharing a
512
+ file must not ALL paint its numbers (work + personal rows showing
513
+ one account's bars twice). The file goes to the row whose sign-in
514
+ email is the login's account email; when none matches, to the
515
+ first row sharing that path (the default account)."""
516
+ path = self._claude_code_login_path(account)
517
+ sharing = [entry for entry in accounts.of_kind(self.name)
518
+ if self._claude_code_login_path(entry) == path]
519
+ if len(sharing) <= 1:
520
+ return True
521
+ email = (login.get("email") or "").lower()
522
+ if email:
523
+ matching = [entry for entry in sharing
524
+ if ((self.login_info(entry) or {}).get("email") or "").lower() == email]
525
+ if matching:
526
+ return matching[0] is account
527
+ return sharing[0] is account
528
+
529
+ def refresh_all(self, account):
530
+ """The Refresh button: re-read the logins and, for an open panel,
531
+ fetch now — the one caller allowed under the request floor / a
532
+ 429 back-off (a person clicked)."""
533
+ account["_usage_backoff_until"] = 0.0
534
+ if account.get("_usage_open"):
535
+ self.fetch_usage(account, force=True)
536
+ refresh(account)
537
+
538
+ def fetch_usage(self, account, force=False):
539
+ """One GET /api/oauth/usage on a worker; rows land in `_usage_rows`,
540
+ the compact summary in `_usage_summary`, an error in `_usage_error`
541
+ (a note under the bars). Rate protection: never within
542
+ usage_min_interval_s of the previous request (except `force`, the
543
+ Refresh button) and never inside a 429 back-off window."""
544
+ import meltygui.completion.providers.claude_usage as claude_usage
545
+ from meltygui.core.runtime.toggles import Toggles
546
+ if account.get("_usage_loading"):
547
+ return
548
+ now = time.monotonic()
549
+ last_request = account.get("_usage_last_request")
550
+ if not force and last_request is not None and now - last_request < Toggles.InternetAccounts.usage_min_interval_s:
551
+ account["_usage_fetched_at"] = last_request # keep the poller on the floor, not on top
552
+ return
553
+ if not force and now < account.get("_usage_backoff_until", 0.0):
554
+ account["_usage_fetched_at"] = now
555
+ return
556
+ login = self._claude_login_for(account)
557
+ account["_usage_fetched_at"] = now
558
+ self._disarm_usage_fetch(account) # a launch (Refresh) supersedes a pending delayed fetch
559
+ if login is None or login["expired"]:
560
+ account["_usage_rows"] = None
561
+ account["_usage_error"] = ("sign in to Claude Code to view usage" if login is None
562
+ else "Claude Code login expired — run claude")
563
+ accounts_changed()
564
+ return
565
+ account["_usage_last_request"] = now # stamp only when a request actually launches
566
+ account["_usage_loading"] = True
567
+ accounts_changed()
568
+
569
+ def run():
570
+ try:
571
+ rows = claude_usage.parse_usage(claude_usage.fetch_usage(login["token"]))
572
+ account["_usage_rows"] = rows
573
+ account["_usage_error"] = None
574
+ account["_usage_summary"] = claude_usage.summary(rows)
575
+ account["_usage_fetched_wall"] = time.time()
576
+ except claude_usage.UsageRateLimited as error:
577
+ # back off: the server's Retry-After, or usage_backoff_s
578
+ # doubling per repeat (reset by a successful fetch)
579
+ streak = account.get("_usage_429_streak", 0) + 1
580
+ account["_usage_429_streak"] = streak
581
+ backoff = error.retry_after or min(
582
+ Toggles.InternetAccounts.usage_backoff_s * (2 ** (streak - 1)),
583
+ Toggles.InternetAccounts.usage_backoff_max_s)
584
+ account["_usage_backoff_until"] = time.monotonic() + backoff
585
+ account["_usage_error"] = f"rate limited — next try in {int(backoff // 60)} min"
586
+ except Exception as error:
587
+ account["_usage_error"] = str(error)[:120]
588
+ else:
589
+ account["_usage_429_streak"] = 0
590
+ account["_usage_backoff_until"] = 0.0
591
+ finally:
592
+ account["_usage_loading"] = False
593
+ account["_usage_fetched_at"] = time.monotonic()
594
+ accounts_changed()
595
+
596
+ threading.Thread(target=run, daemon=True, name=f"claude-usage-{account['id']}").start()
597
+
598
+ def _arm_usage_fetch(self, account):
599
+ """An automatic fetch never fires on the spot: it waits
600
+ Toggles.InternetAccounts.usage_fetch_delay_s (a daemon Timer on
601
+ `_usage_timer`, one per account) and fires only if the panel is
602
+ still open then — so the persisted-open panel at boot shows last
603
+ session's bars and a restart within the delay costs no request.
604
+ A delay of 0 fetches at once (the tests' setting)."""
605
+ from meltygui.core.runtime.toggles import Toggles
606
+ delay = Toggles.InternetAccounts.usage_fetch_delay_s
607
+ if delay <= 0:
608
+ self.fetch_usage(account)
609
+ return
610
+ if account.get("_usage_timer") is not None:
611
+ return
612
+
613
+ def fire():
614
+ if account.get("_usage_timer") is not timer: # disarmed / re-armed meanwhile
615
+ return
616
+ account.pop("_usage_timer", None)
617
+ if account.get("_usage_open"):
618
+ self.fetch_usage(account)
619
+
620
+ timer = threading.Timer(delay, fire)
621
+ timer.daemon = True
622
+ account["_usage_timer"] = timer
623
+ timer.start()
624
+
625
+ @staticmethod
626
+ def _disarm_usage_fetch(account):
627
+ timer = account.pop("_usage_timer", None)
628
+ if timer is not None:
629
+ timer.cancel()
630
+
631
+ def _usage_rows(self, account):
632
+ """The open panel's rows; arms the delayed fetch on open and once the
633
+ numbers are older than Toggles.InternetAccounts.usage_refresh_s
634
+ (`_arm_usage_fetch`)."""
635
+ if not account.get("_usage_open"):
636
+ self._disarm_usage_fetch(account)
637
+ return []
638
+ from meltygui.core.runtime.toggles import Toggles
639
+ login = self._claude_login_for(account)
640
+ if login is not None and not self._owns_claude_login(account, login):
641
+ # Another row is the login's account (or the default row for the
642
+ # shared file): say whose numbers are, and how this row gets
643
+ # its own. Claude Code keeps one login per config dir.
644
+ return [("note", "sign in to Claude Code to view usage")]
645
+ fetched_at = account.get("_usage_fetched_at")
646
+ if not account.get("_usage_loading") and (
647
+ fetched_at is None
648
+ or time.monotonic() - fetched_at > Toggles.InternetAccounts.usage_refresh_s):
649
+ self._arm_usage_fetch(account)
650
+ rows = account.get("_usage_rows") or []
651
+ out = [("usage", row) for row in rows]
652
+ if account.get("_usage_error"):
653
+ out.append(("note", account["_usage_error"]))
654
+ elif not rows and account.get("_usage_timer") is None:
655
+ # (a pending delayed fetch shows nothing - no longer, Lukas 08-25)
656
+ out.append(("note", "loading usage…" if account.get("_usage_loading") else "no usage data"))
657
+ fetched_wall = account.get("_usage_fetched_wall")
658
+ if fetched_wall:
659
+ # "as of 21:42:10" — when these numbers were fetched, so a stale
660
+ # panel (hidden window, network trouble, last session's numbers)
661
+ # is visibly older; another day's fetch carries its date.
662
+ fetched = time.localtime(fetched_wall)
663
+ same_day = fetched[:3] == time.localtime()[:3]
664
+ stamp = "as of " + time.strftime("%H:%M:%S" if same_day else "%m-%d %H:%M", fetched)
665
+ if account.get("_usage_loading"):
666
+ stamp += " · refreshing…"
667
+ out.append(("stamp", stamp))
668
+ return out
669
+
670
+ @staticmethod
671
+ def _usage_window_visible(period):
672
+ """POLLING REMOVED (08-25: the usage endpoint rate-limits). This stub
673
+ stays one hotswap generation so a timer chain armed by the previous
674
+ code ends quietly on its next tick (it checks this before anything
675
+ else). The panel now fetches on open, on a draw that finds the data
676
+ older than usage_refresh_s, and on Refresh — never on a timer."""
677
+ return False
678
+
679
+ def validate(self, account):
680
+ """The Test button: the ONLY Anthropic web request — list one model
681
+ to confirm the credential works. Imports the SDK lazily. On a
682
+ sign-in this also exercises the SDK's own token refresh."""
683
+ source = self._source(account) or "?"
684
+ try:
685
+ import anthropic
686
+ from meltygui.completion.providers.anthropic_requests import sdk_middleware
687
+ client_kwargs = {"timeout": 15.0, "max_retries": 0,
688
+ "middleware": [sdk_middleware()]}
689
+ client_kwargs.update(self.client_kwargs(account))
690
+ client = anthropic.Anthropic(**client_kwargs)
691
+ client.models.list(limit=1)
692
+ client.close()
693
+ account["_validated"] = True
694
+ account["_status"] = ("ready", f"{source} · verified")
695
+ except ImportError:
696
+ account["_status"] = ("error", "anthropic package not installed")
697
+ except Exception as error:
698
+ account["_validated"] = False
699
+ message = getattr(error, "message", None) or str(error)
700
+ account["_status"] = ("error", f"{source} · {message[:90]}")
701
+ accounts_changed()
702
+
703
+ # -- browser sign-in ---------------------------------------------------
704
+
705
+ def sign_in(self, account):
706
+ """The Sign in button: open the Console consent page in the browser
707
+ and wait for its redirect (anthropic_oauth.LoginFlow on a worker);
708
+ clicked again while one is open it just re-opens the browser."""
709
+ import meltygui.completion.providers.anthropic_oauth as anthropic_oauth
710
+ flow = account.get("_login")
711
+ if flow is not None and not flow.done:
712
+ flow.open_in_browser()
713
+ return
714
+ flow = anthropic_oauth.LoginFlow(
715
+ self.profile_name(account), base_url=account.get("base_url") or None,
716
+ on_change=lambda flow: self._login_changed(account, flow))
717
+ account["_login"] = flow
718
+ account.pop("_validated", None)
719
+ try:
720
+ flow.start()
721
+ except Exception as error:
722
+ account["_login"] = None
723
+ account["_status"] = ("error", f"sign-in: {error}"[:90])
724
+ accounts_changed()
725
+
726
+ def _login_changed(self, account, flow):
727
+ """Worker thread: the flow finished — profile written, or an error."""
728
+ if flow.done and account.get("_login") is flow:
729
+ account["_login"] = None
730
+ if flow.error:
731
+ state = "needs_login" if "cancelled" in flow.error else "error"
732
+ account["_status"] = (state, flow.error[:90])
733
+ self.login_info(account, fresh=True)
734
+ else:
735
+ account["_status"] = None # → the passive re-probe reads the new login
736
+ self._reprobe_siblings(account)
737
+ _drop_sessions_for(account) # sessions that failed for want of a credential
738
+ accounts_changed()
739
+
740
+ def _reprobe_siblings(self, account):
741
+ """A sign-in change on one row can move a shared Claude Code login's
742
+ usage to another row — clear the siblings' status so they re-probe."""
743
+ for sibling in accounts.of_kind(self.name):
744
+ if sibling is not account:
745
+ sibling["_status"] = None
746
+
747
+ def cancel_sign_in(self, account):
748
+ flow = account.get("_login")
749
+ account["_login"] = None
750
+ if flow is not None:
751
+ flow.cancel()
752
+ account["_status"] = ("needs_login", "sign-in cancelled")
753
+ accounts_changed()
754
+
755
+ def sign_out(self, account):
756
+ """Forget the browser sign-in: removes the profile's credentials
757
+ file (its org/workspace config stays, so a re-login skips the
758
+ pickers) and drops the live sessions built on it."""
759
+ import meltygui.completion.providers.anthropic_oauth as anthropic_oauth
760
+ anthropic_oauth.sign_out(self.profile_name(account))
761
+ account.pop("_validated", None)
762
+ account["_status"] = None
763
+ self.login_info(account, fresh=True)
764
+ self._reprobe_siblings(account)
765
+ _drop_sessions_for(account)
766
+ accounts_changed()
767
+
768
+ def actions(self, account):
769
+ usage_open = bool(account.get("_usage_open"))
770
+ usage = Button(None, lambda account: _toggle(account, "_usage_open"),
771
+ icon=f"" if usage_open else f"",
772
+ tip="Claude plan usage limits (read through Claude Code's login)")
773
+ refresh_button = Button(None, self.refresh_all, icon=f"",
774
+ tip="Refresh (re-reads the logins and the usage)")
775
+ flow = account.get("_login")
776
+ if flow is not None and not flow.done:
777
+ return [usage, Button("Cancel", self.cancel_sign_in, danger=True),
778
+ Button("Edit", _toggle_edit), refresh_button]
779
+ has_key = bool(account.get("api_key"))
780
+ signed_in = self.login_info(account) is not None
781
+ switch = account.get("_claude_switch")
782
+ email = (self.login_info(account) or {}).get("email")
783
+ claude_login = self._claude_login_for(account)
784
+ use_in_claude_code = []
785
+ if email and (switch is None or switch.done) and (
786
+ claude_login is None or not self._owns_claude_login(account, claude_login)):
787
+ use_in_claude_code = [Button("Use in Claude Code", self.switch_claude_code,
788
+ tip="Sign Claude Code in as this account (replaces its current login)")]
789
+ test = Button("Test",
790
+ lambda account: _run_in_background(
791
+ account, lambda: self.validate(account), reprobe=False),
792
+ tip="Verify the credential (one web request)",
793
+ enabled=self._source(account) is not None)
794
+ if has_key:
795
+ middle = [Button("Edit", _toggle_edit), test,
796
+ Button("Clear", lambda account: accounts.set_field(account["id"], "api_key", ""),
797
+ tip="Forget the pasted key")]
798
+ elif signed_in:
799
+ middle = [Button("Sign out", self.sign_out, tip="Forget this browser sign-in"),
800
+ Button("Edit", _toggle_edit), test]
801
+ else:
802
+ middle = [Button("Sign in", self.sign_in, primary=True,
803
+ tip="Sign in with your Anthropic account (Google works) — opens your browser"),
804
+ Button("Paste key", lambda account: _paste_into(account, "api_key"),
805
+ tip="Or paste an API key from the clipboard"),
806
+ Button("Edit", _toggle_edit), test]
807
+ return [usage] + middle + use_in_claude_code + [refresh_button]
808
+
809
+ def sub_rows(self, account):
810
+ out = []
811
+ flow = account.get("_login")
812
+ if flow is not None and not flow.done and flow.url:
813
+ url = flow.url
814
+ buttons = [Button("Copy link", lambda account, url=url: _copy_text(url),
815
+ tip="Copy the sign-in link"),
816
+ Button("Open browser", lambda account, flow=flow: flow.open_in_browser(),
817
+ primary=True, tip="Open the sign-in page again")]
818
+ out.append(("card", ("finish signing in in the browser tab", buttons)))
819
+ switch = account.get("_claude_switch")
820
+ if switch is not None and not switch.done:
821
+ buttons = [Button("Paste code", self.paste_switch_code,
822
+ tip="If the page showed a code instead of finishing: copy it, then paste it here"),
823
+ Button("Open browser", lambda account, switch=switch: switch.open_in_browser(),
824
+ primary=True, enabled=bool(switch.url), tip="Open the login page again"),
825
+ Button("Cancel", self.cancel_switch, danger=True)]
826
+ out.append(("card", (f"signing Claude Code in as {switch.email} — finish in the browser", buttons)))
827
+ elif account.get("_claude_switch_error"):
828
+ out.append(("note", "Claude Code sign-in failed: " + account["_claude_switch_error"]))
829
+ out.extend(self._usage_rows(account))
830
+ return out
831
+
832
+
833
+ @register_chat_backend("codex")
834
+ def codex_chats(account, metadata=None, wake=None):
835
+ if account.get("_codex_signing_in") or account.get("_busy"):
836
+ return None
837
+ from meltygui.chat.codex_proxy import CodexChats
838
+ return CodexChats(account["id"], metadata, wake)
839
+
840
+
841
+ @account_kind
842
+ class CodexKind(AccountKind):
843
+ chat_label = "Codex"
844
+ name = "codex"
845
+ label = "Codex"
846
+ icon = f""
847
+ tint = (0.35, 0.8, 0.65)
848
+ fields = ()
849
+
850
+ def _server(self, account):
851
+ from meltygui.completion.providers.codex_accounts import AppServer
852
+ from meltygui.completion.providers.codex_accounts import account_home
853
+ from meltygui.core.runtime.toggles import Toggles
854
+ return AppServer(account_home(account["id"]),
855
+ executable=Toggles.InternetAccounts.codex_bin,
856
+ timeout=Toggles.InternetAccounts.codex_request_timeout_s)
857
+
858
+ def _read_account(self, account, server):
859
+ identity = server.request("account/read", {"refreshToken": False}).get("account")
860
+ if identity != account.get("_codex_account"):
861
+ account.pop("_usage_rows", None)
862
+ account.pop("_usage_fetched_wall", None)
863
+ account.pop("_usage_error", None)
864
+ account["_codex_account"] = identity
865
+ cached = account.pop("_codex_cached_usage", None)
866
+ if cached and identity and cached.get("identity") == identity:
867
+ account["_usage_rows"] = cached.get("rows") or []
868
+ account["_usage_fetched_wall"] = cached.get("fetched_wall")
869
+ if not identity:
870
+ return ("needs_login", "not signed in")
871
+ if identity.get("type") != "chatgpt":
872
+ return ("warning", "ChatGPT sign-in required for plan usage")
873
+ return ("ready", " · ".join(filter(None, (
874
+ identity.get("email") or "ChatGPT", identity.get("planType")))))
875
+
876
+ def probe(self, account):
877
+ with self._server(account) as server:
878
+ status = self._read_account(account, server)
879
+ if account.get("_usage_open"):
880
+ self._read_usage(account, server)
881
+ return status
882
+
883
+ def usage_cache_entry(self, account):
884
+ if account.get("_usage_fetched_wall") and account.get("_codex_account"):
885
+ return {"identity": account["_codex_account"],
886
+ "rows": account.get("_usage_rows") or [],
887
+ "fetched_wall": account["_usage_fetched_wall"]}
888
+ return account.get("_codex_cached_usage")
889
+
890
+ def restore_usage(self, account, cached):
891
+ if not account.get("_usage_restored"):
892
+ account["_usage_restored"] = True
893
+ # Display only after account/read confirms whose cached limits these are.
894
+ if cached and "_codex_account" not in account:
895
+ account["_codex_cached_usage"] = cached
896
+
897
+ def sign_in(self, account):
898
+ # A separate login flag keeps Cancel / Open browser usable while waiting.
899
+ if account.get("_codex_signing_in") or account.get("_probing") or account.get("_usage_loading"):
900
+ return
901
+ self.close_chat(account)
902
+ account["_codex_signing_in"] = True
903
+ account["_codex_cancel"] = threading.Event()
904
+ account["_status"] = ("busy", "starting sign-in…")
905
+ accounts_changed()
906
+
907
+ def run():
908
+ from meltygui.core.runtime.toggles import Toggles
909
+ try:
910
+ with self._server(account) as server:
911
+ server.cancelled = account["_codex_cancel"]
912
+ login = server.request("account/login/start", {"type": "chatgpt"})
913
+ account["_codex_login"] = login
914
+ account["_status"] = ("busy", "finish sign-in in your browser")
915
+ accounts_changed()
916
+ if not server.cancelled.is_set():
917
+ _open_url(login["authUrl"])
918
+ completed = server.wait_login(login["loginId"],
919
+ Toggles.InternetAccounts.codex_login_timeout_s)
920
+ account["_status"] = self._read_account(account, server)
921
+ if completed and account.get("_usage_open"):
922
+ self._read_usage(account, server)
923
+ except Exception as error:
924
+ account["_status"] = ("error", str(error)[:160])
925
+ finally:
926
+ account.pop("_codex_login", None)
927
+ account["_codex_signing_in"] = False
928
+ accounts_changed()
929
+
930
+ threading.Thread(target=run, daemon=True, name="codex-sign-in").start()
931
+
932
+ def close(self, account):
933
+ self.close_chat(account)
934
+ cancel = account.get("_codex_cancel")
935
+ if cancel is not None:
936
+ cancel.set()
937
+
938
+ def sign_out(self, account):
939
+ self.close_chat(account)
940
+ with self._server(account) as server:
941
+ server.request("account/logout")
942
+ account["_status"] = self._read_account(account, server)
943
+
944
+ def _read_usage(self, account, server):
945
+ from meltygui.completion.providers.codex_accounts import usage_rows
946
+ try:
947
+ account["_status"] = self._read_account(account, server)
948
+ if (account.get("_codex_account") or {}).get("type") != "chatgpt":
949
+ account["_usage_error"] = "Sign in with ChatGPT to view usage"
950
+ return
951
+ payload = server.request("account/rateLimits/read")
952
+ account["_usage_rows"] = usage_rows(payload)
953
+ account["_usage_fetched_wall"] = time.time()
954
+ account.pop("_usage_error", None)
955
+ except Exception as error:
956
+ account["_usage_error"] = "Usage unavailable: " + str(error)[:120]
957
+
958
+ def fetch_usage(self, account):
959
+ if account.get("_usage_loading") or account.get("_codex_signing_in") or account.get("_busy") or account.get("_probing"):
960
+ return
961
+ account["_usage_loading"] = True
962
+ accounts_changed()
963
+
964
+ def run():
965
+ try:
966
+ with self._server(account) as server:
967
+ self._read_usage(account, server)
968
+ except Exception as error:
969
+ account["_usage_error"] = "Usage unavailable: " + str(error)[:120]
970
+ finally:
971
+ account["_usage_loading"] = False
972
+ accounts_changed()
973
+
974
+ threading.Thread(target=run, daemon=True, name="codex-usage").start()
975
+
976
+ def toggle_usage(self, account):
977
+ _toggle(account, "_usage_open")
978
+ if account.get("_usage_open"):
979
+ self.fetch_usage(account)
980
+
981
+ def actions(self, account):
982
+ if account.get("_codex_signing_in"):
983
+ return [Button("Cancel", self.close)]
984
+ idle = not account.get("_usage_loading") and not account.get("_probing")
985
+ out = [Button(None, self.toggle_usage,
986
+ icon=f"" if account.get("_usage_open") else f"",
987
+ tip="Codex plan usage limits")]
988
+ if account.get("_codex_account"):
989
+ out.append(Button("Sign out", lambda account: _run_in_background(
990
+ account, lambda: self.sign_out(account), reprobe=False), enabled=idle))
991
+ else:
992
+ out.append(Button("Sign in", self.sign_in, primary=True, enabled=idle))
993
+ out.append(Button(None, lambda account: self.fetch_usage(account)
994
+ if account.get("_usage_open") else refresh(account), enabled=idle,
995
+ icon=f"", tip="Refresh account and usage"))
996
+ return out
997
+
998
+ def sub_rows(self, account):
999
+ out = []
1000
+ login = account.get("_codex_login")
1001
+ if login:
1002
+ out.append(("card", ("Finish signing in with ChatGPT", [
1003
+ Button("Open browser", lambda account: _open_url(login["authUrl"])),
1004
+ Button("Cancel", self.close)])))
1005
+ if account.get("_usage_open"):
1006
+ rows = account.get("_usage_rows") or []
1007
+ out.extend(("usage", row) for row in rows)
1008
+ error = account.get("_usage_error")
1009
+ if error:
1010
+ out.append(("note", error))
1011
+ elif not rows:
1012
+ out.append(("note", "loading usage…" if account.get("_usage_loading")
1013
+ else "Usage unavailable — Refresh to check"))
1014
+ fetched = account.get("_usage_fetched_wall")
1015
+ if fetched:
1016
+ stamp = "as of " + time.strftime("%m-%d %H:%M:%S", time.localtime(fetched))
1017
+ if account.get("_usage_loading"):
1018
+ stamp += " · refreshing…"
1019
+ out.append(("stamp", stamp))
1020
+ out.append(("note", "Shared with Codex desktop / CLI" if account["id"] == "codex"
1021
+ else "Separate Codex account"))
1022
+ return out
1023
+
1024
+
1025
+ @account_kind
1026
+ class CopilotKind(AccountKind):
1027
+ name = "copilot"
1028
+ label = "GitHub Copilot"
1029
+ icon = f""
1030
+ tint = (0.45, 0.6, 0.85)
1031
+ fields = (Field("config_dir", "Config dir",
1032
+ placeholder="(default ~/.config — shared with the IDE plugins)"),)
1033
+
1034
+ def _session(self, account, create=True):
1035
+ import meltygui.completion.fim as fim
1036
+ from meltygui.completion.providers.copilot import CopilotSession
1037
+ session_kwargs = {"account": session_account_id(account)}
1038
+ return fim.session_for(CopilotSession, session_kwargs, create=create)
1039
+
1040
+ def probe(self, account):
1041
+ # Passive probe: NO language-server spawn and NO web request. Node +
1042
+ # install are filesystem checks; sign-in state is read from a token
1043
+ # file on disk. The LS is spawned only when the user clicks Sign in
1044
+ # or when FIM actually asks Copilot for a completion - so opening the
1045
+ # accounts window (even at startup) costs nothing.
1046
+ import meltygui.completion.providers.copilot as copilot
1047
+ if copilot.find_node() is None:
1048
+ return ("error", "node ≥ 20.8 not found")
1049
+ if not copilot.server_installed():
1050
+ return ("needs_login", "not installed")
1051
+ # If a session is already running (FIM used it, or the user signed in),
1052
+ # trust its status instead of the on-disk file.
1053
+ try:
1054
+ session = self._session(account, create=False)
1055
+ except Exception:
1056
+ session = None
1057
+ if session is not None and session.alive():
1058
+ state, text = session.status()
1059
+ if state == "needs_login":
1060
+ return ("needs_login", f"sign in: code {text}")
1061
+ if session.user:
1062
+ return ("ready", session.user)
1063
+ if state == "error":
1064
+ return ("needs_login", text or "not signed in")
1065
+ user = copilot.cached_login_user(account.get("config_dir"))
1066
+ if user:
1067
+ return ("ready", user)
1068
+ return ("needs_login", "not signed in")
1069
+
1070
+ def actions(self, account):
1071
+ import meltygui.completion.providers.copilot as copilot
1072
+ out = []
1073
+ if not copilot.server_installed():
1074
+ out.append(Button("Install",
1075
+ lambda account: _run_in_background(account, copilot.install_server),
1076
+ primary=True))
1077
+ return out
1078
+ state = (account.get("_status") or ("unknown", ""))[0]
1079
+ if state == "ready":
1080
+ out.append(Button("Sign out", lambda account: _run_in_background(
1081
+ account, lambda: self._session(account).sign_out())))
1082
+ else:
1083
+ out.append(Button("Sign in", lambda account: _run_in_background(
1084
+ account, lambda: self._session(account).sign_in()), primary=True))
1085
+ out.append(Button("Edit", _toggle_edit))
1086
+ out.append(Button(None, refresh, icon=f"", tip="Refresh"))
1087
+ return out
1088
+
1089
+ def sub_rows(self, account):
1090
+ try:
1091
+ session = self._session(account, create=False)
1092
+ except Exception:
1093
+ session = None
1094
+ if session is not None and session.login is not None:
1095
+ code, url = session.login
1096
+ buttons = [Button("Copy code", lambda account, code=code: _copy_text(code)),
1097
+ Button("Open browser", lambda account, url=url: _open_url(url), primary=True)]
1098
+ return [("card", (f"Enter code {code} at {url}", buttons))]
1099
+ return []
1100
+
1101
+
1102
+ @account_kind
1103
+ class OllamaKind(AccountKind):
1104
+ chat_label = "Ollama"
1105
+ name = "ollama"
1106
+ label = "Ollama"
1107
+ icon = f""
1108
+ tint = (0.5, 0.75, 0.6)
1109
+ fields = (Field("host", "Host", default="http://localhost:11434"),
1110
+ Field("device", "Device", default="auto", hidden=True))
1111
+
1112
+ @staticmethod
1113
+ def _client(account):
1114
+ import httpx
1115
+ host = (account.get("host") or "http://localhost:11434").rstrip("/")
1116
+ # Short connect timeout: a down local server fails in ~1s instead of
1117
+ # hanging the probe thread. (Probes always run on a worker, never the
1118
+ # render thread - so a fast failure keeps status snappy.)
1119
+ return httpx.Client(base_url=host, timeout=httpx.Timeout(4.0, connect=1.0))
1120
+
1121
+ def probe(self, account):
1122
+ import meltygui.completion.providers.ollama as ollama
1123
+ host = (account.get("host") or "http://localhost:11434").rstrip("/")
1124
+ try:
1125
+ with self._client(account) as client:
1126
+ models = ollama.list_models(client)
1127
+ except Exception as error:
1128
+ account["_models"] = []
1129
+ return ("error", f"{host} · {str(error)[:60]}")
1130
+ account["_models"] = models
1131
+ try:
1132
+ account["_gpus"] = ollama.gpu_inventory() # best-effort GPU names for the device menu
1133
+ except Exception:
1134
+ account["_gpus"] = []
1135
+ loaded = [model for model in models if model["loaded"]]
1136
+ fim_like = [model["name"] for model in models
1137
+ if any(key in model["name"]
1138
+ for key in ("coder", "codellama", "starcoder", "codestral", "deepseek-coder"))]
1139
+ text = f"{len(models)} models"
1140
+ if loaded:
1141
+ text += f" · {len(loaded)} loaded on " + ", ".join(
1142
+ sorted({model['where'] or '?' for model in loaded}))
1143
+ if not fim_like:
1144
+ text += " · no FIM model (pull qwen2.5-coder)"
1145
+ return ("ready", text)
1146
+
1147
+ def actions(self, account):
1148
+ import meltygui.completion.providers.ollama as ollama
1149
+ models_open = bool(account.get("_models_open"))
1150
+ device = account.get("device") or "auto"
1151
+ return [Button(None, lambda account: _toggle(account, "_models_open"),
1152
+ icon=f"" if models_open else f"", tip="Models"),
1153
+ Button(f" {ollama.device_label(device, account.get('_gpus'))}",
1154
+ self._cycle_device, tip="Device models load onto (click to cycle)"),
1155
+ Button("Edit", _toggle_edit),
1156
+ Button(None, refresh, icon=f"", tip="Refresh")]
1157
+
1158
+ def _cycle_device(self, account):
1159
+ import meltygui.completion.providers.ollama as ollama
1160
+ choices = ollama.device_choices(account.get("_gpus"))
1161
+ current = account.get("device") or "auto"
1162
+ next_device = (choices[(choices.index(current) + 1) % len(choices)]
1163
+ if current in choices else choices[0])
1164
+ accounts.set_field(account["id"], "device", next_device, reprobe=False)
1165
+
1166
+ def sub_rows(self, account):
1167
+ if not account.get("_models_open"):
1168
+ return []
1169
+ models = account.get("_models")
1170
+ if models is None:
1171
+ return [("note", "loading…")]
1172
+ if not models:
1173
+ return [("note", "no models — `ollama pull qwen2.5-coder:7b`")]
1174
+ return [("model", model) for model in models]
1175
+
1176
+ def model_actions(self, account, model):
1177
+ import meltygui.completion.providers.ollama as ollama
1178
+ device = account.get("device") or "auto"
1179
+ target = ollama.device_label(device, account.get("_gpus"))
1180
+
1181
+ def load(account, name=model["name"]):
1182
+ from meltygui.core.runtime.toggles import Toggles
1183
+ _run_in_background(account, lambda: self._with_client(
1184
+ account, lambda client: ollama.load_model(
1185
+ client, name, account.get("device") or "auto", Toggles.Fim.ollama_keep_alive)))
1186
+
1187
+ def unload(account, name=model["name"]):
1188
+ _run_in_background(account, lambda: self._with_client(
1189
+ account, lambda client: ollama.unload_model(client, name)))
1190
+
1191
+ out = [Button(("Move" if model["loaded"] else "Load") + f" → {target}", load,
1192
+ primary=not model["loaded"])]
1193
+ if model["loaded"]:
1194
+ out.append(Button(None, unload, icon=f"", tip="Unload"))
1195
+ return out
1196
+
1197
+ def _with_client(self, account, fn):
1198
+ with self._client(account) as client:
1199
+ return fn(client)
1200
+
1201
+
1202
+ # ──────────────────────────────────────────────────────────────────────────
1203
+ # Actions / probes
1204
+ # ──────────────────────────────────────────────────────────────────────────
1205
+
1206
+ def refresh(account):
1207
+ """Probe one account on a worker and repaint when it answers."""
1208
+ if account.get("_probing"):
1209
+ return
1210
+ account["_probing"] = True
1211
+ kind = KINDS[account["kind"]]
1212
+
1213
+ def run():
1214
+ try:
1215
+ account["_status"] = kind.probe(account)
1216
+ except Exception as error:
1217
+ account["_status"] = ("error", str(error)[:90])
1218
+ finally:
1219
+ account["_probing"] = False
1220
+ account["_probed_at"] = time.monotonic()
1221
+ accounts_changed()
1222
+
1223
+ threading.Thread(target=run, daemon=True, name=f"acct-probe-{account['id']}").start()
1224
+
1225
+
1226
+ def _run_in_background(account, fn, reprobe=True):
1227
+ """Run `fn()` on a worker with the row's busy flag set. `reprobe` re-runs
1228
+ the passive probe afterwards (default) — pass False when `fn` already set
1229
+ the status itself (e.g. validate), so the trailing probe doesn't clobber
1230
+ it."""
1231
+ account["_busy"] = True
1232
+ accounts_changed()
1233
+
1234
+ def run():
1235
+ try:
1236
+ fn()
1237
+ except Exception as error:
1238
+ account["_status"] = ("error", str(error)[:90])
1239
+ finally:
1240
+ account["_busy"] = False
1241
+ if reprobe:
1242
+ refresh(account)
1243
+ else:
1244
+ accounts_changed()
1245
+
1246
+ threading.Thread(target=run, daemon=True, name=f"acct-action-{account['id']}").start()
1247
+
1248
+
1249
+ def _paste_into(account, field_name):
1250
+ try:
1251
+ text = (imgui.get_clipboard_text() or "").strip()
1252
+ except Exception:
1253
+ text = ""
1254
+ if text:
1255
+ accounts.set_field(account["id"], field_name, text)
1256
+ refresh(account)
1257
+
1258
+
1259
+ def _copy_text(text):
1260
+ try:
1261
+ imgui.set_clipboard_text(text)
1262
+ except Exception:
1263
+ pass
1264
+
1265
+
1266
+ def _open_url(url):
1267
+ """A sign-in page: the placed Xwayland popup (oauth_popup), which falls
1268
+ back to plain xdg-open by itself."""
1269
+ import meltygui.completion.providers.oauth_popup as oauth_popup
1270
+ oauth_popup.open_auth_popup(url)
1271
+
1272
+
1273
+ def _toggle(account, key):
1274
+ account[key] = not account.get(key)
1275
+ accounts_changed()
1276
+
1277
+
1278
+ def _toggle_edit(account):
1279
+ _toggle(account, "_edit")
1280
+
1281
+
1282
+ def _refresh_stale(account):
1283
+ """Probe an account ONCE (when its status is first unknown). No timer-
1284
+ based re-probe: the window must not fire a recurring web request every
1285
+ couple of minutes just for being open — the user refreshes on demand
1286
+ (Refresh button / a credential edit clears _status)."""
1287
+ if account.get("_status") is None and not account.get("_probing"):
1288
+ refresh(account)
1289
+
1290
+
1291
+ # ──────────────────────────────────────────────────────────────────────────
1292
+ # Window
1293
+ # ──────────────────────────────────────────────────────────────────────────
1294
+
1295
+ def _mix(style_manager, tint, value, factor, saturation):
1296
+ return style_manager.make_color_rgb(tint[0], tint[1], tint[2], value=value,
1297
+ factor=factor, saturation_scale=saturation)
1298
+
1299
+
1300
+ def _color_u32(color, alpha=1.0):
1301
+ return pack_color(color[0], color[1], color[2], alpha)
1302
+
1303
+
1304
+ def _wrap_usage_label(text, max_width):
1305
+ """Keep the complete name; wrap at spaces, or within a long model name."""
1306
+ lines = []
1307
+ while text:
1308
+ if imgui.calc_text_size(text)[0] <= max_width:
1309
+ lines.append(text)
1310
+ break
1311
+ low, high = 1, len(text)
1312
+ while low < high:
1313
+ middle = (low + high + 1) // 2
1314
+ if imgui.calc_text_size(text[:middle])[0] <= max_width:
1315
+ low = middle
1316
+ else:
1317
+ high = middle - 1
1318
+ end = text.rfind(" ", 0, low + 1)
1319
+ if end <= 0:
1320
+ end = low
1321
+ lines.append(text[:end])
1322
+ text = text[end:].lstrip()
1323
+ return lines
1324
+
1325
+
1326
+ def _ellipsize(text, max_width):
1327
+ """`text` ellipsized to `max_width` pixels in the current font."""
1328
+ if max_width <= 0:
1329
+ return ""
1330
+ if imgui.calc_text_size(text)[0] <= max_width:
1331
+ return text
1332
+ low, high = 0, len(text)
1333
+ while low < high:
1334
+ mid = (low + high + 1) // 2
1335
+ if imgui.calc_text_size(text[:mid] + "…")[0] <= max_width:
1336
+ low = mid
1337
+ else:
1338
+ high = mid - 1
1339
+ return (text[:low] + "…") if low > 0 else ""
1340
+
1341
+
1342
+ def _format_gb(size_bytes):
1343
+ return f"{size_bytes / 1e9:.1f} GB"
1344
+
1345
+
1346
+ from meltygui.core.services.account_core import _cleanup_accounts
1347
+
1348
+
1349
+ from meltygui.view.account_view import draw_internet_accounts
1350
+ draw_internet_accounts = window(input_value=accounts, tint=(0.19, 0.16, 0.14), icon=f'\uf0c2', display_name='Internet Accounts', initial={'width': 760, 'height': 460})(draw_internet_accounts)
1351
+
1352
+
1353
+ # Register the companion window on initial import and on an Accounts hotswap.
1354
+ # The import is last so its provider registry is already available.
1355
+ import meltygui.chat.chat_interface # noqa: E402,F401