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.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- 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
|