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,1703 @@
|
|
|
1
|
+
"""Chat view functions and supporting definitions."""
|
|
2
|
+
from meltygui.view.chat_decoration_view import _text_tint
|
|
3
|
+
from meltygui.view.chat_decoration_view import _tint_style
|
|
4
|
+
from bisect import bisect_right
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from meltygui.chat.messages import BashString, CommandExecution, ReasoningMessage, Reference, ToolCall
|
|
7
|
+
from meltygui.core.services.chat_core import _cleanup_chat
|
|
8
|
+
from meltygui.core.melty import Melty
|
|
9
|
+
from meltygui.core.core_render import render_func
|
|
10
|
+
from meltygui.state.chat_state import ChatInterfaceState
|
|
11
|
+
from meltygui.core.runtime.toggles import Tint, Toggles
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import colorsys
|
|
14
|
+
import meltygui_imgui as imgui
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def draw_chat_sidebar(sources, draw_state, state, width, height, cutoff=None, new_conversation=None, pane="all", file_metadata=None):
|
|
20
|
+
"""Immediate layout; visible text leaves own their cached render tiles.
|
|
21
|
+
``sources`` is ``[(account_id, proxy, kind, label)]`` (a lone ChatProxy is
|
|
22
|
+
accepted as the one source): their conversations MIX into one list,
|
|
23
|
+
newest first (Recent uses the last user message, All uses last activity). With no ``cutoff`` (the All chip) a folder appears once,
|
|
24
|
+
ranked by its newest conversation, holding all of its conversations.
|
|
25
|
+
With a ``cutoff`` (epoch seconds, `age_cutoff`) the list is strictly
|
|
26
|
+
chronological and a folder heading repeats wherever its conversations
|
|
27
|
+
resume after another folder's, so a run of headings reads as a feed;
|
|
28
|
+
conversations last active before the cutoff are out — except active
|
|
29
|
+
ones and the selected one. Several sources: each row names its provider
|
|
30
|
+
at the right. Selecting a row makes its source the primary account.
|
|
31
|
+
``new_conversation(account_id, proxy, project)`` serves every heading's
|
|
32
|
+
+ (a conversation in that folder, in the source of the run's newest
|
|
33
|
+
conversation)."""
|
|
34
|
+
from meltygui.chat.chat_interface import TRASH_ICON
|
|
35
|
+
from meltygui.view.chat_decoration_view import _button
|
|
36
|
+
from meltygui.view.chat_decoration_view import _card
|
|
37
|
+
from meltygui.view.chat_decoration_view import _caret
|
|
38
|
+
from meltygui.view.chat_decoration_view import _color
|
|
39
|
+
from meltygui.view.chat_decoration_view import _hovering
|
|
40
|
+
from meltygui.chat.chat_interface import _label_width
|
|
41
|
+
from meltygui.view.chat_decoration_view import _running_dot
|
|
42
|
+
from meltygui.chat.chat_interface import _text_layout
|
|
43
|
+
from meltygui.chat.chat_interface import _tint_slot
|
|
44
|
+
from meltygui.chat.chat_interface import _title
|
|
45
|
+
from meltygui.chat.chat_interface import _viewport
|
|
46
|
+
from meltygui.chat.chat_interface import conversation_source_tag
|
|
47
|
+
from meltygui.model.chat_model import is_active
|
|
48
|
+
from meltygui.model.chat_model import project_tint
|
|
49
|
+
from meltygui.model.chat_model import recent_chat_time
|
|
50
|
+
from meltygui.model.chat_model import sidebar_visible
|
|
51
|
+
from meltygui.model.file_metadata_model import set_row_tint
|
|
52
|
+
from meltygui.view.text_view import draw_text
|
|
53
|
+
import meltygui.core.windowing.window_api as glfw
|
|
54
|
+
|
|
55
|
+
from meltygui.chat.chat_proxy import ChatProxy
|
|
56
|
+
if isinstance(sources, ChatProxy):
|
|
57
|
+
sources = [(state.account, sources, None, "")]
|
|
58
|
+
if not hasattr(state, "folder_expanded"):
|
|
59
|
+
state.folder_expanded = {}
|
|
60
|
+
changed = False
|
|
61
|
+
archive = None
|
|
62
|
+
trash_icon = TRASH_ICON
|
|
63
|
+
trash_width = Melty.px(25)
|
|
64
|
+
row_width = max(Melty.px(80), width - Melty.px(7))
|
|
65
|
+
gap, pad = Melty.px(2), Melty.px(3)
|
|
66
|
+
memos = state.viewports.setdefault("sidebar-cards", {})
|
|
67
|
+
# The cards lay out the same until a source's conversations, the window's
|
|
68
|
+
# state (a selection, a fold, a rename), the filter minute or the width change:
|
|
69
|
+
# keep them between frames (scrolling changes none).
|
|
70
|
+
signature = (tuple((account_id, getattr(chats, "revision", None), len(chats) if chats is not None else 0)
|
|
71
|
+
for account_id, chats, _, _ in sources),
|
|
72
|
+
getattr(state, "folders_default_expanded", True), tuple(state.folder_expanded.items()), state.revision, int(cutoff // 60) if cutoff else None, row_width, Melty.ui_scale,
|
|
73
|
+
new_conversation is not None, tuple(sorted(state.selected.items())), state.account)
|
|
74
|
+
memo = memos.get(pane)
|
|
75
|
+
if memo is not None and memo[0] == signature:
|
|
76
|
+
cards = memo[1]
|
|
77
|
+
else:
|
|
78
|
+
entries = []
|
|
79
|
+
for account_id, chats, kind, _label in sources:
|
|
80
|
+
if chats is None:
|
|
81
|
+
continue
|
|
82
|
+
selected_key = state.selected.get(account_id)
|
|
83
|
+
source_tint = tuple(kind.tint) if kind is not None else (0.6, 0.6, 0.6)
|
|
84
|
+
for key, chat in chats.items():
|
|
85
|
+
if (not any(part.startswith(".") and part not in (".", "..") for part in Path(chat["project"]).parts)
|
|
86
|
+
and sidebar_visible(key, chat, cutoff, selected_key)):
|
|
87
|
+
entries.append((-(recent_chat_time(chat) if pane == "recent" else (chat.get("updated") or 0.0)), account_id, chats, kind, key, chat, source_tint))
|
|
88
|
+
entries.sort(key=lambda entry: entry[0])
|
|
89
|
+
runs = [] # [(project, [entry])], newest first
|
|
90
|
+
if cutoff is None:
|
|
91
|
+
by_project = {}
|
|
92
|
+
for entry in entries:
|
|
93
|
+
by_project.setdefault(entry[5]["project"], []).append(entry)
|
|
94
|
+
runs = list(by_project.items())
|
|
95
|
+
else:
|
|
96
|
+
for entry in entries:
|
|
97
|
+
if runs and runs[-1][0] == entry[5]["project"]:
|
|
98
|
+
runs[-1][1].append(entry)
|
|
99
|
+
else:
|
|
100
|
+
runs.append((entry[5]["project"], [entry]))
|
|
101
|
+
cards = []
|
|
102
|
+
for index, (project, rows) in enumerate(runs):
|
|
103
|
+
first_account, first_chats = rows[0][1], rows[0][2]
|
|
104
|
+
# Recent feeds repeat folders: key each run to its oldest chat,
|
|
105
|
+
# so a new conversation at its head does not move its fold state.
|
|
106
|
+
folder_key = (pane, project, rows[-1][1], rows[-1][4]) if cutoff is not None else (pane, project)
|
|
107
|
+
expanded = state.folder_expanded.get(folder_key, getattr(state, "folders_default_expanded", True))
|
|
108
|
+
# The folder's tint is its directory's in the file-meta store; an
|
|
109
|
+
# unpainted folder (and its unpainted rows) wears the source's.
|
|
110
|
+
painted = project_tint(project, file_metadata=file_metadata)
|
|
111
|
+
heading_tint = painted or rows[0][6]
|
|
112
|
+
folder_label = (Path(project).name or project) if project else "No project"
|
|
113
|
+
label, heading_height = _text_layout(state, "project:" + project, folder_label)
|
|
114
|
+
heading_height += Melty.px(2)
|
|
115
|
+
children = []
|
|
116
|
+
if expanded:
|
|
117
|
+
for _, account_id, chats, kind, key, chat, source_tint in rows:
|
|
118
|
+
text, row_height = _text_layout(state, "chat:" + account_id + ":" + key, " ".join(str(chat["title"]).split()))
|
|
119
|
+
children.append((account_id, chats, kind, key, chat.metadata, text, row_height + Melty.px(2),
|
|
120
|
+
tuple(heading_tint)))
|
|
121
|
+
card_height = pad * 2 + heading_height + sum(row[6] + gap for row in children)
|
|
122
|
+
cards.append((index, project, folder_key, label, heading_height, rows, children, card_height,
|
|
123
|
+
painted, heading_tint, first_account, first_chats))
|
|
124
|
+
memos[pane] = (signature, cards)
|
|
125
|
+
total = sum(card[7] + 5 for card in cards)
|
|
126
|
+
with _viewport(draw_state, state, "sidebar:" + pane + ":" + ":".join(source[0] for source in sources),
|
|
127
|
+
width, height, total) as (x, y, clip):
|
|
128
|
+
for (index, project, folder_key, label, heading_height, rows, children, card_height,
|
|
129
|
+
painted, heading_tint, first_account, first_chats) in cards:
|
|
130
|
+
run_id = f"{pane}:{index}:{project}"
|
|
131
|
+
if painted and _visible(y, card_height, clip):
|
|
132
|
+
_card(x, y, row_width, card_height, heading_tint, ui_scale=Melty.ui_scale, channel=Melty.get_channel() if Melty.channels_split else None) # an unpainted card has no plate
|
|
133
|
+
heading_y = y + pad
|
|
134
|
+
if _visible(heading_y, heading_height, clip):
|
|
135
|
+
expanded = state.folder_expanded.get(folder_key, getattr(state, "folders_default_expanded", True))
|
|
136
|
+
arrow_top = max(heading_y, clip[1]) if clip else heading_y
|
|
137
|
+
arrow_bottom = min(heading_y + heading_height, clip[3]) if clip else heading_y + heading_height
|
|
138
|
+
arrow_tint = heading_tint if painted else tuple(Melty.bg_color_stack[-1] if Melty.bg_color_stack else Melty.get_bg_color(-1))[:3]
|
|
139
|
+
if _caret(draw_state, "project-arrow:" + run_id, x + pad, arrow_top,
|
|
140
|
+
Melty.px(17), max(0, arrow_bottom - arrow_top), expanded,
|
|
141
|
+
arrow_tint, brightness=0.55, ui_scale=Melty.ui_scale):
|
|
142
|
+
state.folder_expanded[folder_key] = not expanded
|
|
143
|
+
changed = True
|
|
144
|
+
heading_hovered = _hovering(x, heading_y, row_width, heading_height, pointer=imgui.get_mouse_pos())
|
|
145
|
+
if file_metadata is not None:
|
|
146
|
+
changed |= _tint_slot(draw_state, "project:" + run_id, painted,
|
|
147
|
+
x + Melty.px(23), heading_y + max(0, (heading_height - Melty.px(13)) / 2),
|
|
148
|
+
heading_hovered, heading_tint,
|
|
149
|
+
lambda value, _p=project: set_row_tint(file_metadata, _p, value),
|
|
150
|
+
show_brush=heading_hovered, brush_tint=arrow_tint)
|
|
151
|
+
# The heading's +: a new conversation in this folder, in the
|
|
152
|
+
# source of the run's newest conversation.
|
|
153
|
+
plus_x = x + row_width - pad - trash_width
|
|
154
|
+
if _button(draw_state, "project-heading:" + run_id, "", x + Melty.px(46), arrow_top,
|
|
155
|
+
max(0, plus_x - x - Melty.px(46)), heading_tint,
|
|
156
|
+
height=max(0, arrow_bottom - arrow_top), background=False,
|
|
157
|
+
event="left_mouse_down", ui_scale=Melty.ui_scale):
|
|
158
|
+
state.folder_expanded[folder_key] = True
|
|
159
|
+
changed |= not expanded
|
|
160
|
+
if new_conversation is not None and _button(
|
|
161
|
+
draw_state, "new-in:" + run_id, "+", plus_x, heading_y, trash_width, heading_tint,
|
|
162
|
+
not first_chats.loading and not first_chats.error, height=heading_height, background=False, text_color=_tint_style(tuple(heading_tint)).make_color_style_value(input={
|
|
163
|
+
"value": 7.788, "saturation": 1.559, "max_value": 1.601})[:3], ui_scale=Melty.ui_scale):
|
|
164
|
+
new_conversation(first_account, first_chats, project)
|
|
165
|
+
changed = True
|
|
166
|
+
_title(label, x + Melty.px(46), heading_y,
|
|
167
|
+
plus_x - x - Melty.px(46), heading_height, arrow_tint,
|
|
168
|
+
brightness=0.65 * float(Toggles.Melty.arrow_brightness), ellipsis=True,
|
|
169
|
+
text_color=_tint_style(tuple(arrow_tint)).make_color_style_value(input={
|
|
170
|
+
"value": 7.788, "saturation": 1.559, "max_value": 1.601})[:3])
|
|
171
|
+
if not expanded and any(is_active(entry[5]) for entry in rows):
|
|
172
|
+
_running_dot(plus_x - trash_width / 2, heading_y + heading_height / 2, heading_tint, ui_scale=Melty.ui_scale, channel=Melty.get_channel() if Melty.channels_split else None)
|
|
173
|
+
child_y = heading_y + heading_height + gap
|
|
174
|
+
for account_id, chats, kind, key, child_meta, text, row_height, row_tint in children:
|
|
175
|
+
row_id = pane + ":" + account_id + ":" + key
|
|
176
|
+
rect = (x + pad, child_y, x + row_width - trash_width, child_y + row_height)
|
|
177
|
+
renaming = getattr(state, "rename", None)
|
|
178
|
+
editing = (renaming is not None and renaming["account"] == account_id
|
|
179
|
+
and renaming["key"] == key and renaming.get("pane", pane) == pane)
|
|
180
|
+
# Rows order by time: no drag reordering, and nothing registered
|
|
181
|
+
# for a row scrolled out of the list.
|
|
182
|
+
if not _visible(child_y, row_height, clip):
|
|
183
|
+
child_y += row_height + gap
|
|
184
|
+
continue
|
|
185
|
+
if draw_state.on_action("right_mouse_down", view_id="chat-menu:" + row_id, priority_delta=3,
|
|
186
|
+
rect=(x + pad, child_y, x + row_width - pad, child_y + row_height)):
|
|
187
|
+
state.chat_menu = {"account": account_id, "key": key, "pane": pane}
|
|
188
|
+
changed = True
|
|
189
|
+
tint = row_tint
|
|
190
|
+
selected = account_id == state.account and key == state.selected.get(account_id)
|
|
191
|
+
if selected:
|
|
192
|
+
_card(x + pad, child_y, row_width - pad * 2, row_height, tint, selected=True,
|
|
193
|
+
max_bg_value=0.32, shadow_offset=Toggles.Chat.selected_chat_shadow_offset, ui_scale=Melty.ui_scale, channel=Melty.get_channel() if Melty.channels_split else None)
|
|
194
|
+
# A row cut by the list's edge takes the pointer only where it
|
|
195
|
+
# shows: hit rects ignore the clip, and a full-length one would
|
|
196
|
+
# reach over the filter chips above or the button below.
|
|
197
|
+
hit_top = max(child_y, clip[1]) if clip else child_y
|
|
198
|
+
hit_bottom = min(child_y + row_height, clip[3]) if clip else child_y + row_height
|
|
199
|
+
# Select on press: CLICKED waits 250 ms for the label's rename
|
|
200
|
+
# double-click. DOWN remains immediate and enables renaming.
|
|
201
|
+
if hit_bottom - hit_top >= 1 and _button(draw_state, "chat:" + row_id, "", x + pad, hit_top,
|
|
202
|
+
row_width - pad * 2 - trash_width, tint, height=hit_bottom - hit_top,
|
|
203
|
+
background=False, event="left_mouse_down", ui_scale=Melty.ui_scale):
|
|
204
|
+
state.selected[account_id] = key
|
|
205
|
+
state.account = account_id
|
|
206
|
+
if kind is not None:
|
|
207
|
+
state.provider = kind.name
|
|
208
|
+
changed = True
|
|
209
|
+
row_rect = (x + pad, child_y, x + row_width - pad, child_y + row_height)
|
|
210
|
+
hovered = draw_state.on_action("cursor_hover", view_id="chat-hover:" + row_id,
|
|
211
|
+
rect=row_rect) is not None
|
|
212
|
+
if not is_active(chats.get(key)) and hovered and hit_bottom - hit_top >= 1 and _button(
|
|
213
|
+
draw_state, "archive:" + row_id, trash_icon, x + row_width - pad - trash_width, hit_top,
|
|
214
|
+
trash_width, tint, height=hit_bottom - hit_top, background=False, ui_scale=Melty.ui_scale):
|
|
215
|
+
archive = (chats, account_id, key)
|
|
216
|
+
if draw_state.on_action("left_mouse_double_clicked", view_id="rename:" + row_id,
|
|
217
|
+
rect=rect, priority_delta=5) is not None:
|
|
218
|
+
state.rename = {"account": account_id, "key": key, "pane": pane, "draft": text, "focus": True}
|
|
219
|
+
renaming = state.rename
|
|
220
|
+
editing = True
|
|
221
|
+
state.selected[account_id] = key
|
|
222
|
+
state.account = account_id
|
|
223
|
+
changed = True
|
|
224
|
+
title_width = row_width - pad * 2
|
|
225
|
+
right = x + row_width - pad
|
|
226
|
+
if hovered and not is_active(chats.get(key)):
|
|
227
|
+
right -= trash_width
|
|
228
|
+
if kind is not None:
|
|
229
|
+
tag = conversation_source_tag(kind, chats.get(key))
|
|
230
|
+
tag_tint = (Toggles.Chat.claude_tag_tint if kind.name == "anthropic"
|
|
231
|
+
else Toggles.Chat.codex_tag_tint if kind.name == "codex" else tuple(kind.tint))
|
|
232
|
+
tag_dot_width = Melty.px(12) if is_active(chats.get(key)) else 0
|
|
233
|
+
tag_width = _label_width(tag) + Melty.px(5) + tag_dot_width
|
|
234
|
+
right -= tag_width
|
|
235
|
+
imgui.get_window_draw_list().add_rect_filled(right, child_y, right + tag_width,
|
|
236
|
+
child_y + row_height, _color(tag_tint, 0.28), rounding=Melty.px(4))
|
|
237
|
+
if tag_dot_width:
|
|
238
|
+
_running_dot(right + Melty.px(6), child_y + row_height / 2, tint, ui_scale=Melty.ui_scale, channel=Melty.get_channel() if Melty.channels_split else None)
|
|
239
|
+
_title(tag, right + tag_dot_width + Melty.px(5), child_y,
|
|
240
|
+
tag_width - tag_dot_width - Melty.px(5), row_height,
|
|
241
|
+
tag_tint, text_color=tag_tint)
|
|
242
|
+
right -= Melty.px(5)
|
|
243
|
+
size = chats.get(key).get("size_bytes")
|
|
244
|
+
size_label = (f"{size / 1_000_000:.0f} MB" if size >= 10_000_000 else f"{size / 1_000_000:.2f} MB") if size is not None else "— MB"
|
|
245
|
+
size_width = _label_width(size_label) + Melty.px(8)
|
|
246
|
+
right -= size_width
|
|
247
|
+
_title(size_label, right, child_y, size_width, row_height, tint, brightness=0.5)
|
|
248
|
+
title_width = max(0, right - x - pad - Melty.px(5))
|
|
249
|
+
if not editing:
|
|
250
|
+
_title(text, x + pad + Melty.px(5), child_y, title_width, row_height, tint, ellipsis=True)
|
|
251
|
+
else:
|
|
252
|
+
imgui.set_cursor_screen_pos((x + pad + Melty.px(5), child_y))
|
|
253
|
+
request_focus = editing and renaming["focus"]
|
|
254
|
+
edited, value, text_ds = draw_text(renaming["draft"] if editing else text,
|
|
255
|
+
wrap=False, name="chat-row:" + row_id,
|
|
256
|
+
request_focus=request_focus, select_all_on_focus=request_focus, single_line=True,
|
|
257
|
+
return_extras=True,
|
|
258
|
+
width=title_width, height=row_height, is_tree=False,
|
|
259
|
+
editable=editing, focusable=editing, syntax_highlight=False, autocomplete=False,
|
|
260
|
+
show_header=False, show_bg=False, shadow=False, show_widgets=False,
|
|
261
|
+
show_file_header=False, show_jump_bar=False, scope_collapse=False,
|
|
262
|
+
disable_scroll=True, freeze_resize=True, use_cache=True, imgui_padding=False,
|
|
263
|
+
tint=tint, text_tint=_text_tint(tuple(tint)), fim="")
|
|
264
|
+
if editing:
|
|
265
|
+
if Melty.text_focused_ds is text_ds:
|
|
266
|
+
renaming["focused"] = True
|
|
267
|
+
renaming["focus"] = False
|
|
268
|
+
if edited:
|
|
269
|
+
renaming["draft"] = value
|
|
270
|
+
changed = True
|
|
271
|
+
keys_pressed = {event[0] for event in Melty.frame_key_events}
|
|
272
|
+
if glfw.KEY_ESCAPE in keys_pressed:
|
|
273
|
+
state.rename = None
|
|
274
|
+
changed = True
|
|
275
|
+
elif (glfw.KEY_ENTER in keys_pressed or glfw.KEY_KP_ENTER in keys_pressed
|
|
276
|
+
or renaming.get("focused", False) and Melty.text_focused_ds is not text_ds):
|
|
277
|
+
if renaming["draft"].strip():
|
|
278
|
+
chats.get(key)["title"] = renaming["draft"].strip()
|
|
279
|
+
state.rename = None
|
|
280
|
+
changed = True
|
|
281
|
+
child_y += row_height + gap
|
|
282
|
+
y += card_height + 5
|
|
283
|
+
if archive is not None:
|
|
284
|
+
chats, account_id, key = archive
|
|
285
|
+
del chats[key]
|
|
286
|
+
if state.selected.get(account_id) == key:
|
|
287
|
+
state.selected[account_id] = next(iter(chats), None)
|
|
288
|
+
if getattr(state, "rename", None) is not None and state.rename["key"] == key:
|
|
289
|
+
state.rename = None
|
|
290
|
+
changed = True
|
|
291
|
+
return changed, min(total, height)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@render_func(tint=(0.2, 0.8, 0.4), use_cache=True, show_bg=False, with_header=None,
|
|
295
|
+
with_footer=None, shadow=False, imgui_padding=False, disable_scroll=True)
|
|
296
|
+
def draw_chat_terminal(input_value, draw_state=None):
|
|
297
|
+
from meltygui.view.chat_decoration_view import _color
|
|
298
|
+
from meltygui.core.styling.fonts import Font
|
|
299
|
+
|
|
300
|
+
from meltygui.view.terminal_view import _resolve
|
|
301
|
+
grid, char_width, line_height = input_value
|
|
302
|
+
x, y = draw_state.abs_left + Melty.px(4), draw_state.abs_top + Melty.px(4)
|
|
303
|
+
draw_list = imgui.get_window_draw_list()
|
|
304
|
+
font = Melty.font_mgr.get(Font.FONTAWESOME_MONO_19) if Melty.font_mgr else None
|
|
305
|
+
if font is not None:
|
|
306
|
+
imgui.push_font(font)
|
|
307
|
+
try:
|
|
308
|
+
clip = Melty.get_clip_rect()
|
|
309
|
+
first = max(0, int((clip[1] - y) // line_height)) if clip else 0
|
|
310
|
+
last = min(len(grid), int((clip[3] - y) // line_height) + 1) if clip else len(grid)
|
|
311
|
+
for index in range(first, last):
|
|
312
|
+
row = grid[index]
|
|
313
|
+
top = y + index * line_height
|
|
314
|
+
column = 0
|
|
315
|
+
visible_columns = min(len(row), max(0, int((draw_state.width - Melty.px(8)) / char_width) + 1))
|
|
316
|
+
while column < visible_columns:
|
|
317
|
+
cell = row[column]
|
|
318
|
+
end = column + 1
|
|
319
|
+
while end < visible_columns and row[end][1:] == cell[1:]:
|
|
320
|
+
end += 1
|
|
321
|
+
foreground = _resolve(cell.fg, (0.85, 0.85, 0.85), cell.bold)
|
|
322
|
+
background = _resolve(cell.bg, (0.018, 0.022, 0.025))
|
|
323
|
+
if cell.reverse:
|
|
324
|
+
foreground, background = background, foreground
|
|
325
|
+
left = x + column * char_width
|
|
326
|
+
if cell.bg != "default" or cell.reverse:
|
|
327
|
+
draw_list.add_rect_filled(left, top, x + end * char_width, top + line_height, _color(background))
|
|
328
|
+
draw_list.add_text(left, top, _color(foreground), "".join(char.data for char in row[column:end]))
|
|
329
|
+
column = end
|
|
330
|
+
imgui.dummy(draw_state.width, len(grid) * line_height + Melty.px(8))
|
|
331
|
+
finally:
|
|
332
|
+
if font is not None:
|
|
333
|
+
imgui.pop_font()
|
|
334
|
+
return False, input_value
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def draw_chat_queue(proxy, selected, chat, draw_state, state, key, width, tint):
|
|
338
|
+
from meltygui.view.chat_decoration_view import _button
|
|
339
|
+
from meltygui.chat.chat_interface import _title
|
|
340
|
+
from meltygui.chat.chat_interface import _viewport
|
|
341
|
+
from meltygui.chat.messages import input_text
|
|
342
|
+
|
|
343
|
+
queued = chat.get("queued_messages", {})
|
|
344
|
+
if not queued:
|
|
345
|
+
return False
|
|
346
|
+
changed = False
|
|
347
|
+
x, y = imgui.get_cursor_screen_pos()
|
|
348
|
+
paused = chat.get("queue_paused", False)
|
|
349
|
+
label = f"{len(queued)} queued" + (" · paused" if paused else " · sends after this turn")
|
|
350
|
+
_title(label, x, y, max(1, width - Melty.px(90)), Melty.px(28), tint, brightness=0.7, ellipsis=True)
|
|
351
|
+
if paused and _button(draw_state, key + ":resume", "Resume", x + width - Melty.px(85), y,
|
|
352
|
+
Melty.px(85), tint, height=Melty.px(28), enabled=not chat.get("locked", False), ui_scale=Melty.ui_scale):
|
|
353
|
+
proxy.resume_queue(selected)
|
|
354
|
+
changed = True
|
|
355
|
+
imgui.set_cursor_screen_pos((x, y))
|
|
356
|
+
imgui.dummy(width, Melty.px(28))
|
|
357
|
+
with _viewport(draw_state, state, key + ":queue", width, Melty.px(28) * min(3, len(queued)),
|
|
358
|
+
Melty.px(28) * len(queued)) as (x, y, clip):
|
|
359
|
+
for index, (identifier, message) in enumerate(list(queued.items())):
|
|
360
|
+
top = y + index * Melty.px(28)
|
|
361
|
+
if not _visible(top, Melty.px(28), clip):
|
|
362
|
+
continue
|
|
363
|
+
_title(input_text(message).replace("\n", " "), x, top, max(1, width - Melty.px(175)),
|
|
364
|
+
Melty.px(28), tint, brightness=0.7, ellipsis=True)
|
|
365
|
+
if _button(draw_state, key + ":send-now:" + identifier, "Send now", x + width - Melty.px(170), top,
|
|
366
|
+
Melty.px(85), tint, not chat.get("locked", False), height=Melty.px(28), background=False, ui_scale=Melty.ui_scale):
|
|
367
|
+
changed |= proxy.send_queued(selected, identifier)
|
|
368
|
+
if _button(draw_state, key + ":remove:" + identifier, "Remove", x + width - Melty.px(80), top,
|
|
369
|
+
Melty.px(72), tint, height=Melty.px(28), background=False, ui_scale=Melty.ui_scale):
|
|
370
|
+
proxy.cancel_queued(selected, identifier)
|
|
371
|
+
changed = True
|
|
372
|
+
return changed
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def draw_messages(messages, draw_state, state, key, width, height,
|
|
376
|
+
conversation_tint=(0.4, 0.8, 0.7), revision=None, file_metadata=None):
|
|
377
|
+
"""Immediate-mode message layout; only visible text leaves use cached views.
|
|
378
|
+
|
|
379
|
+
Message subclasses describe semantics, leaf types describe rendering.
|
|
380
|
+
Resources stay inert references: no image loading, opening paths or I/O.
|
|
381
|
+
"""
|
|
382
|
+
from meltygui.view.chat_decoration_view import _button
|
|
383
|
+
from meltygui.view.chat_decoration_view import _card
|
|
384
|
+
from meltygui.view.chat_decoration_view import _caret
|
|
385
|
+
from meltygui.chat.chat_interface import _code_background
|
|
386
|
+
from meltygui.chat.chat_interface import _draw_image
|
|
387
|
+
from meltygui.chat.chat_interface import _draw_prose
|
|
388
|
+
from meltygui.chat.chat_interface import _failure_badge
|
|
389
|
+
from meltygui.chat.chat_interface import _icon_chip
|
|
390
|
+
from meltygui.chat.chat_interface import _label_width
|
|
391
|
+
from meltygui.chat.chat_interface import _prose_metrics
|
|
392
|
+
from meltygui.chat.chat_interface import _terminal_layout
|
|
393
|
+
from meltygui.chat.chat_interface import _text_layout
|
|
394
|
+
from meltygui.chat.chat_interface import _title
|
|
395
|
+
from meltygui.chat.chat_interface import _viewport
|
|
396
|
+
from meltygui.chat.chat_interface import image_box
|
|
397
|
+
from meltygui.chat.chat_interface import image_cache
|
|
398
|
+
from meltygui.view.chat_decoration_view import prose_offset
|
|
399
|
+
from meltygui.chat.chat_interface import transcript_entries
|
|
400
|
+
from meltygui.chat.messages import AssistantMessage
|
|
401
|
+
from meltygui.chat.messages import BashString
|
|
402
|
+
from meltygui.chat.messages import CodeString
|
|
403
|
+
from meltygui.chat.messages import CommandExecution
|
|
404
|
+
from meltygui.chat.messages import FileTags
|
|
405
|
+
from meltygui.chat.messages import ImageReference
|
|
406
|
+
from meltygui.chat.messages import Message
|
|
407
|
+
from meltygui.chat.messages import PythonString
|
|
408
|
+
from meltygui.chat.messages import ReasoningMessage
|
|
409
|
+
from meltygui.chat.messages import Reference
|
|
410
|
+
from meltygui.chat.messages import ToolCall
|
|
411
|
+
from meltygui.chat.messages import ToolOutput
|
|
412
|
+
from meltygui.chat.messages import UserMessage
|
|
413
|
+
from meltygui.view.text_view import draw_text
|
|
414
|
+
import meltygui.core.windowing.window_api as glfw
|
|
415
|
+
|
|
416
|
+
if not hasattr(state, "message_expanded"):
|
|
417
|
+
state.message_expanded = {}
|
|
418
|
+
if not hasattr(state, "output_expanded"):
|
|
419
|
+
state.output_expanded = {}
|
|
420
|
+
if not hasattr(state, "image_sizes"):
|
|
421
|
+
state.image_sizes = {}
|
|
422
|
+
# User and assistant prose sit inset in their span.
|
|
423
|
+
# [tint=(0.95, 0.6, 0.25)]
|
|
424
|
+
user_inset = Melty.px(12)
|
|
425
|
+
# [tint=(0.95, 0.6, 0.25)]
|
|
426
|
+
user_pad = Melty.px(6)
|
|
427
|
+
rows = []
|
|
428
|
+
viewport_width = max(Melty.px(60), width - Melty.px(7))
|
|
429
|
+
row_width = min(viewport_width, Melty.px(Toggles.Chat.transcript_max_width))
|
|
430
|
+
# Resize: wrapping every leaf per frame is O(transcript), and re-wrapped
|
|
431
|
+
# heights above the viewport shove the scroll around. While the width is
|
|
432
|
+
# moving only VISIBLE prose re-wraps (off-screen leaves keep their last
|
|
433
|
+
# wrap), and the top visible row is pinned to its screen position through
|
|
434
|
+
# the gesture; the frame the width settles re-wraps everything and pins again.
|
|
435
|
+
view = state.viewports.setdefault(key, {"offset": 0.0, "follow": True})
|
|
436
|
+
prose_index, prose_texts = {}, [] # every prose leaf in order: the selection's coordinates
|
|
437
|
+
width_moving = view.get("width") not in (None, row_width)
|
|
438
|
+
settling = view.get("unsettled", False) and not width_moving
|
|
439
|
+
view["width"], view["unsettled"] = row_width, width_moving
|
|
440
|
+
row_memos = view.setdefault("rows", {}) # per-row layouts (text_layouts holds visible leaf wraps)
|
|
441
|
+
viewport_top = view.get("offset", 0.0)
|
|
442
|
+
layout_y = 0
|
|
443
|
+
# User bubbles align to the right of the centered transcript, with a
|
|
444
|
+
# narrower cap and a small left inset even when the window is narrow.
|
|
445
|
+
# [tint=(0.95, 0.6, 0.25)]
|
|
446
|
+
chat_indent = max(Melty.px(50), row_width - Melty.px(Toggles.Chat.user_message_max_width))
|
|
447
|
+
# Code blocks darken the window's PAINTED fill: `Melty.bg_color_stack`
|
|
448
|
+
# top, what the wrapper's draw_bg actually returned (`bg_stack` holds the
|
|
449
|
+
# recipe, `draw_state.bg_color` the nested recipe; both hold the wrong shade).
|
|
450
|
+
# The factors and shadow offsets live in Toggles.Chat.
|
|
451
|
+
bash_darken = Toggles.Chat.bash_darken
|
|
452
|
+
python_darken = Toggles.Chat.python_darken
|
|
453
|
+
bash_shadow = Toggles.Chat.bash_shadow_offset
|
|
454
|
+
# Rows: [icon chip][content]. Every non-user row's content — command
|
|
455
|
+
# previews, file tags and their wrapped rows, terminals, thinking text,
|
|
456
|
+
# assistant prose — sits in ONE column at icon_column; the chips hang in
|
|
457
|
+
# the gutter to its left. Hovering an icon row swaps its glyph for the
|
|
458
|
+
# expand caret (no caret column of its own); an expanded row's icon and
|
|
459
|
+
# Failed badge share its first content line. Previews are dimmed to recede.
|
|
460
|
+
# [tint=(0.95, 0.6, 0.25)]
|
|
461
|
+
icon_column = Melty.px(26)
|
|
462
|
+
icon_brightness = Toggles.Chat.icon_brightness
|
|
463
|
+
command_text_brightness = Toggles.Chat.command_text_brightness
|
|
464
|
+
# A terminal block never grows wider than this; long lines clip at its edge.
|
|
465
|
+
# [tint=(0.95, 0.6, 0.25)]
|
|
466
|
+
terminal_max_width = Melty.px(Toggles.Chat.terminal_max_width)
|
|
467
|
+
window_color = tuple(Melty.bg_color_stack[-1] if Melty.bg_color_stack else Melty.get_bg_color(-1))[:3]
|
|
468
|
+
header_height = max(Melty.px(23), imgui.get_text_line_height() + Melty.px(4))
|
|
469
|
+
# The layout pass - every message, visible or not - is the transcript's
|
|
470
|
+
# cost; it only changes with the content (the proxy's revision, the
|
|
471
|
+
# message count), the width, or an expand / collapse. Otherwise the
|
|
472
|
+
# previous frame's rows serve (the draw pass below is per visible row).
|
|
473
|
+
pictures = image_cache()
|
|
474
|
+
pictures.watch(draw_state)
|
|
475
|
+
signature = (row_width, chat_indent, terminal_max_width, revision, len(messages), id(messages), pictures.generation,
|
|
476
|
+
getattr(state, "concise", False), frozenset(getattr(state, "action_groups", {}).items()),
|
|
477
|
+
hash(frozenset(k for k, v in state.message_expanded.items() if v)),
|
|
478
|
+
hash(frozenset(k for k, v in state.output_expanded.items() if v)))
|
|
479
|
+
cached = view.get("layout")
|
|
480
|
+
if cached is not None and cached[0] == signature and not width_moving and not settling and not view.get("relayout"):
|
|
481
|
+
rows, prose_index, prose_texts = cached[1], cached[2], cached[3]
|
|
482
|
+
else:
|
|
483
|
+
previous_user = False
|
|
484
|
+
for message_id, message in transcript_entries(messages, state, key):
|
|
485
|
+
if not isinstance(message, Message):
|
|
486
|
+
continue # older cached sessions are replaced by the provider factory
|
|
487
|
+
if isinstance(message, ReasoningMessage) and not any(
|
|
488
|
+
isinstance(value, str) and value.strip() for _, value, _ in _message_leaves(message["content"])):
|
|
489
|
+
continue
|
|
490
|
+
row_key = key + ":" + message_id
|
|
491
|
+
user = isinstance(message, UserMessage)
|
|
492
|
+
leading_space = Melty.px(20) if rows and (user or previous_user) else 0
|
|
493
|
+
previous_user = user
|
|
494
|
+
if message.get("kind") == "actionGroup":
|
|
495
|
+
full_height = header_height + Melty.px(4) + leading_space
|
|
496
|
+
rows.append((row_key, message, [], full_height, False, False, [], header_height,
|
|
497
|
+
row_width, False, True, "", leading_space))
|
|
498
|
+
layout_y += full_height
|
|
499
|
+
continue
|
|
500
|
+
terminal = isinstance(message, CommandExecution)
|
|
501
|
+
has_header = not isinstance(message, (UserMessage, AssistantMessage))
|
|
502
|
+
collapsible = has_header and isinstance(message, (ToolCall, ReasoningMessage))
|
|
503
|
+
# Every collapsible row starts collapsed; the caret / file tags open it.
|
|
504
|
+
expanded = not collapsible or state.message_expanded.get(row_key, False)
|
|
505
|
+
# A collapsed row of a finished tool call lays out the same every
|
|
506
|
+
# frame: keep its row between frames (most of an old transcript).
|
|
507
|
+
row_signature = None
|
|
508
|
+
if collapsible and not expanded and message.get("status") in ("completed", "failed"):
|
|
509
|
+
row_signature = (id(message), leading_space, message.get("status"), row_width, chat_indent, header_height,
|
|
510
|
+
terminal_max_width, len(message["content"]), id(message.get("summary")))
|
|
511
|
+
elif (not has_header and message.get("status") != "running" and not width_moving
|
|
512
|
+
and not any(isinstance(value, ImageReference) for value in message["content"].values())):
|
|
513
|
+
# Finished assistant: its text is the same object until set_text replaces it.
|
|
514
|
+
row_signature = (id(message), leading_space, id(getattr(message, "source_text", None)), message.get("status"),
|
|
515
|
+
row_width, chat_indent, header_height, terminal_max_width, len(message["content"]))
|
|
516
|
+
if row_signature is not None:
|
|
517
|
+
memo = row_memos.get(row_key)
|
|
518
|
+
if memo is not None and memo[0] == row_signature:
|
|
519
|
+
row = memo[1]
|
|
520
|
+
if not has_header:
|
|
521
|
+
for path, display, *_ in row[2]: # all prose leaves keep their selection slots
|
|
522
|
+
if isinstance(display, str) and not isinstance(display, CodeString):
|
|
523
|
+
prose_index[(row_key, path)] = len(prose_texts)
|
|
524
|
+
prose_texts.append(str(display))
|
|
525
|
+
rows.append(row)
|
|
526
|
+
layout_y += row[3]
|
|
527
|
+
continue
|
|
528
|
+
summary = message.get("summary")
|
|
529
|
+
files = {filename: counts for filename, counts in summary.items()
|
|
530
|
+
if counts.get("access", "write") == "write"} if isinstance(summary, FileTags) else {}
|
|
531
|
+
leaves = []
|
|
532
|
+
show_more = False
|
|
533
|
+
content_width = 0
|
|
534
|
+
if expanded and terminal:
|
|
535
|
+
display, leaf_height = _terminal_layout(state, row_key, message, row_width)
|
|
536
|
+
leaves.append((("terminal",), display, leaf_height, 0, ""))
|
|
537
|
+
memo = state.text_layouts[(row_key, "terminal")]
|
|
538
|
+
show_more, content_width = memo[3], memo[4]
|
|
539
|
+
elif expanded:
|
|
540
|
+
content = message["content"] or message["details"]
|
|
541
|
+
for path, value, depth in _message_leaves(content):
|
|
542
|
+
if isinstance(message, ReasoningMessage) and isinstance(value, str) and not value.strip():
|
|
543
|
+
continue
|
|
544
|
+
indent = min(depth - 1, 4) * Melty.px(10) if isinstance(message, ToolCall) else 0
|
|
545
|
+
indent = 0 if isinstance(message, ReasoningMessage) else max(0, indent) # its icon column indents it
|
|
546
|
+
prose = isinstance(message, (UserMessage, AssistantMessage))
|
|
547
|
+
# Assistant prose starts at the row edge so it lines up with the tool rows'
|
|
548
|
+
# arrow; user prose keeps its inset inside the card.
|
|
549
|
+
indent = user_inset if isinstance(message, UserMessage) else 0 if prose else indent
|
|
550
|
+
image_entry = image_cache().entry(value) if isinstance(value, ImageReference) else None
|
|
551
|
+
if isinstance(value, ImageReference):
|
|
552
|
+
# A picture: its bounding box plus the caption line under it
|
|
553
|
+
# (a payload is not measured or treated as text).
|
|
554
|
+
# The same span the draw pass hands _draw_image (or leaf_width).
|
|
555
|
+
span = ((row_width - chat_indent if isinstance(message, UserMessage) else row_width) - indent
|
|
556
|
+
- (0 if isinstance(message, UserMessage) else icon_column)
|
|
557
|
+
- (user_inset if isinstance(message, UserMessage) else 0))
|
|
558
|
+
if isinstance(message, AssistantMessage):
|
|
559
|
+
span = min(span, terminal_max_width)
|
|
560
|
+
image_width, image_height = image_box(
|
|
561
|
+
image_entry, max(Melty.px(40), span), state.image_sizes.get(row_key + ":image:" + repr(path)))
|
|
562
|
+
display, leaf_height = value, image_height + header_height
|
|
563
|
+
elif isinstance(value, str):
|
|
564
|
+
# Only plain prose wraps; code, tool output and everything else keeps its lines.
|
|
565
|
+
wraps = prose and not isinstance(value, CodeString)
|
|
566
|
+
off_screen = layout_y > viewport_top + height or layout_y + Melty.px(2000) < viewport_top
|
|
567
|
+
display, leaf_height = _text_layout(state, (row_key, path), value,
|
|
568
|
+
wrap_width=(row_width - chat_indent - 2 * user_inset if isinstance(message, UserMessage)
|
|
569
|
+
else min(row_width - icon_column, terminal_max_width)) if wraps else None,
|
|
570
|
+
keep=width_moving and off_screen)
|
|
571
|
+
if wraps:
|
|
572
|
+
prose_index[(row_key, path)] = len(prose_texts)
|
|
573
|
+
prose_texts.append(str(display))
|
|
574
|
+
else:
|
|
575
|
+
display, leaf_height = value, header_height
|
|
576
|
+
# Tool field labels and language captions are drawn directly.
|
|
577
|
+
caption = (" / ".join(p for p in path if not p.isdecimal())
|
|
578
|
+
if isinstance(message, ToolCall) else
|
|
579
|
+
getattr(value, "language", "") if isinstance(value, CodeString) else "")
|
|
580
|
+
if caption:
|
|
581
|
+
leaf_height += header_height
|
|
582
|
+
leaves.append((path, display, leaf_height, indent, caption))
|
|
583
|
+
measured = (state.text_layouts[(row_key, path)][3] if isinstance(value, str) else
|
|
584
|
+
# A picture sits past the icon column: the card must grow around it
|
|
585
|
+
image_width + (0 if isinstance(message, UserMessage) else icon_column)
|
|
586
|
+
if isinstance(value, ImageReference) else imgui.calc_text_size(str(value)).x)
|
|
587
|
+
content_width = max(content_width, indent + measured + Melty.px(8),
|
|
588
|
+
indent + _label_width(caption) + Melty.px(8))
|
|
589
|
+
tags = []
|
|
590
|
+
icon = _message_icon(message)
|
|
591
|
+
content_x = 0 if isinstance(message, UserMessage) else icon_column
|
|
592
|
+
tags_x = content_x
|
|
593
|
+
tag_x, tag_y = tags_x, 0
|
|
594
|
+
available_width = (min(row_width, terminal_max_width) if terminal else row_width) - (Melty.px(100) if _message_failed(message) else 0)
|
|
595
|
+
if files:
|
|
596
|
+
for filename, counts in files.items():
|
|
597
|
+
count_label = (f" +{counts['added']} -{counts['removed']}"
|
|
598
|
+
if counts.get("added") is not None else "")
|
|
599
|
+
tag_width = min(max(Melty.px(30), available_width - Melty.px(20)),
|
|
600
|
+
imgui.calc_text_size(Path(filename).name + count_label).x + Melty.px(16))
|
|
601
|
+
if tag_x > tags_x and tag_x + tag_width > available_width:
|
|
602
|
+
tag_x = tags_x # wrapped rows line up after the icon and caret
|
|
603
|
+
tag_y += header_height + Melty.px(2)
|
|
604
|
+
tags.append((filename, counts, tag_x, tag_y, tag_width))
|
|
605
|
+
tag_x += tag_width + Melty.px(3)
|
|
606
|
+
summary_height = (tag_y + header_height if tags else
|
|
607
|
+
0 if isinstance(message, ReasoningMessage) and expanded else
|
|
608
|
+
0 if icon and expanded and leaves else
|
|
609
|
+
header_height if has_header else
|
|
610
|
+
user_pad if isinstance(message, UserMessage) else 0)
|
|
611
|
+
gap = 0 if isinstance(message, UserMessage) else Melty.px(4)
|
|
612
|
+
full_height = (summary_height + sum(leaf[2] for leaf in leaves) + gap
|
|
613
|
+
+ (user_pad if isinstance(message, UserMessage) else 0)
|
|
614
|
+
+ (header_height if show_more else 0))
|
|
615
|
+
fitted_width = row_width
|
|
616
|
+
if isinstance(message, UserMessage):
|
|
617
|
+
# Keep the prose renderer's 4px inset plus trailing glyph margin
|
|
618
|
+
# inside the leaf clip, as well as the bubble's outer padding.
|
|
619
|
+
fitted_width = min(row_width - chat_indent,
|
|
620
|
+
max(Melty.px(40), content_width + user_inset))
|
|
621
|
+
if isinstance(message, ToolCall):
|
|
622
|
+
header_width = (max(tag[2] + tag[4] for tag in tags) if tags else
|
|
623
|
+
tags_x + _label_width(_message_label(message, expanded)))
|
|
624
|
+
header_width += Melty.px(104) if _message_failed(message) else Melty.px(8)
|
|
625
|
+
fitted_width = min(row_width, max(content_width, header_width, Melty.px(110) if show_more else 0))
|
|
626
|
+
if terminal:
|
|
627
|
+
fitted_width = min(fitted_width, terminal_max_width) # collapsed labels clip at the cap too
|
|
628
|
+
full_height += leading_space
|
|
629
|
+
row = (row_key, message, leaves, full_height, collapsible, expanded, tags, summary_height, fitted_width, show_more, has_header, icon, leading_space)
|
|
630
|
+
if row_signature is not None and not (width_moving or settling):
|
|
631
|
+
# (a row wrapped mid-resize keeps stale off-screen wraps: never memoised)
|
|
632
|
+
row_memos[row_key] = (row_signature, row)
|
|
633
|
+
rows.append(row)
|
|
634
|
+
layout_y += full_height
|
|
635
|
+
view["layout"] = (signature, rows, prose_index, prose_texts)
|
|
636
|
+
view["row_geometry"] = _row_geometry(rows)
|
|
637
|
+
geometry = view["row_geometry"]
|
|
638
|
+
ends = geometry[1]
|
|
639
|
+
total = ends[-1] if ends else 0.0
|
|
640
|
+
# A row the reader just expanded / collapsed / showed more of re-laid out
|
|
641
|
+
# this frame: hold their row, and drop follow so a tall block opening near
|
|
642
|
+
# the bottom doesn't fling the viewport off its end.
|
|
643
|
+
relayout = view.pop("relayout", False)
|
|
644
|
+
if relayout:
|
|
645
|
+
view["follow"] = False
|
|
646
|
+
_hold_scroll_anchor(view, rows, height, width_moving or settling or relayout, geometry)
|
|
647
|
+
changed = False
|
|
648
|
+
selecting = False # a prose leaf took this frame's selection
|
|
649
|
+
with _viewport(draw_state, state, key, width, height, total, follow=True) as (x, y, clip):
|
|
650
|
+
x += max(0, (viewport_width - row_width) / 2)
|
|
651
|
+
# The viewport returns the scrolled origin. Skip entire off-screen rows
|
|
652
|
+
# before touching their leaves, tints, file metadata or event handlers.
|
|
653
|
+
first = bisect_right(ends, clip[1] - y) if clip is not None else 0
|
|
654
|
+
y += ends[first - 1] if first else 0.0
|
|
655
|
+
for row_index in range(first, len(rows)):
|
|
656
|
+
if clip is not None and y >= clip[3]:
|
|
657
|
+
break
|
|
658
|
+
row_key, message, leaves, full_height, collapsible, expanded, tags, summary_height, fitted_width, show_more, has_header, icon, leading_space = rows[row_index]
|
|
659
|
+
y += leading_space
|
|
660
|
+
full_height -= leading_space
|
|
661
|
+
if message.get("kind") == "actionGroup":
|
|
662
|
+
if not hasattr(state, "action_groups"):
|
|
663
|
+
state.action_groups = {}
|
|
664
|
+
expanded = state.action_groups.get(row_key, False)
|
|
665
|
+
label = ("▾ " if expanded else "▸ ") + message["details"]["label"]
|
|
666
|
+
if _button(draw_state, row_key, "", x, y, row_width, conversation_tint,
|
|
667
|
+
height=header_height, background=False, ui_scale=Melty.ui_scale):
|
|
668
|
+
state.action_groups[row_key] = not expanded
|
|
669
|
+
view["relayout"] = True
|
|
670
|
+
changed = True
|
|
671
|
+
_title(label, x, y, row_width, header_height, conversation_tint, brightness=0.65, ellipsis=True)
|
|
672
|
+
y += full_height
|
|
673
|
+
continue
|
|
674
|
+
content_x = 0 if isinstance(message, UserMessage) else icon_column
|
|
675
|
+
tint = conversation_tint
|
|
676
|
+
# User messages carry the chat tint; assistant prose stays unboxed.
|
|
677
|
+
underlying = window_color
|
|
678
|
+
prose = isinstance(message, (UserMessage, AssistantMessage))
|
|
679
|
+
side = row_width - fitted_width if isinstance(message, UserMessage) else 0
|
|
680
|
+
row_span = fitted_width if isinstance(message, UserMessage) else row_width
|
|
681
|
+
if isinstance(message, UserMessage) and _visible(y, full_height, clip):
|
|
682
|
+
underlying = _card(x + side, y, row_span, full_height, tint,
|
|
683
|
+
max_bg_value=Toggles.Chat.user_message_bg_value, ui_scale=Melty.ui_scale, channel=Melty.get_channel() if Melty.channels_split else None) or underlying
|
|
684
|
+
if isinstance(message, ToolCall):
|
|
685
|
+
underlying = tuple(c * (bash_darken if isinstance(message, CommandExecution) else python_darken)
|
|
686
|
+
for c in underlying[:3])
|
|
687
|
+
# The header row carries only its icon chip; the block background
|
|
688
|
+
# starts under it, covering the expanded content.
|
|
689
|
+
if expanded and full_height > summary_height + Melty.px(4) and _visible(y, full_height, clip):
|
|
690
|
+
_code_background(x + content_x, y + summary_height, fitted_width - content_x,
|
|
691
|
+
full_height - summary_height - Melty.px(4),
|
|
692
|
+
underlying, shadow=bash_shadow if isinstance(message, CommandExecution) else None)
|
|
693
|
+
if tags:
|
|
694
|
+
from meltygui.view.file_view import draw_changed_file_header
|
|
695
|
+
from meltygui.models.file_meta import FileMeta
|
|
696
|
+
metadata = file_metadata
|
|
697
|
+
painted = FileMeta.painted_tint if metadata else None
|
|
698
|
+
for filename, counts, tag_x, tag_y, tag_width in tags:
|
|
699
|
+
file_y = y + tag_y
|
|
700
|
+
if _visible(file_y, header_height, clip):
|
|
701
|
+
file_path = str(Path(message["details"].get("cwd") or "") / filename)
|
|
702
|
+
file_tint = (painted(metadata.get(file_path)) if painted else None) or underlying
|
|
703
|
+
imgui.set_cursor_screen_pos((x + tag_x, file_y))
|
|
704
|
+
if draw_changed_file_header(filename, file_tint, draw_state,
|
|
705
|
+
view_id=row_key + ":file:" + filename, width=tag_width, height=header_height,
|
|
706
|
+
added=counts["added"], removed=counts["removed"], active=True,
|
|
707
|
+
prefix="", shadow_offset=Toggles.Chat.file_tag_shadow_offset):
|
|
708
|
+
state.message_expanded[row_key] = not expanded
|
|
709
|
+
view["relayout"] = True
|
|
710
|
+
changed = True
|
|
711
|
+
if (summary_height and has_header or collapsible) and _visible(y, header_height, clip):
|
|
712
|
+
header_x = x + content_x
|
|
713
|
+
# An icon row shows its caret IN the icon while the pointer is over
|
|
714
|
+
# the row. priority_delta 3 = the wrapper level: a lower delta is
|
|
715
|
+
# pruned by the enclosing wrapper's own blocker and never delivers.
|
|
716
|
+
row_hovered = not icon or draw_state.on_action(
|
|
717
|
+
"cursor_hover", view_id=row_key + ":hover", priority_delta=3,
|
|
718
|
+
rect=(x, y, x + fitted_width, y + full_height)) is not None
|
|
719
|
+
if icon:
|
|
720
|
+
chip_color = (underlying if isinstance(message, ToolCall)
|
|
721
|
+
else tuple(c * bash_darken for c in window_color))
|
|
722
|
+
glyph_x = _icon_chip(icon, x, y, icon_column - Melty.px(2), header_height, chip_color)
|
|
723
|
+
if not (collapsible and row_hovered):
|
|
724
|
+
_title(icon, glyph_x, y, icon_column, header_height, chip_color, icon_brightness)
|
|
725
|
+
if collapsible and row_hovered:
|
|
726
|
+
if _caret(draw_state, row_key + ":expand", x, y, icon_column - Melty.px(2), header_height,
|
|
727
|
+
expanded, chip_color if icon else tint, icon_brightness if icon else 1.0, ui_scale=Melty.ui_scale):
|
|
728
|
+
state.message_expanded[row_key] = not expanded
|
|
729
|
+
view["relayout"] = True
|
|
730
|
+
changed = True
|
|
731
|
+
failed = _message_failed(message)
|
|
732
|
+
if not tags and not (isinstance(message, ReasoningMessage) and expanded):
|
|
733
|
+
label = _message_label(message, expanded)
|
|
734
|
+
if icon and label.startswith(icon):
|
|
735
|
+
label = label[len(icon):].lstrip() # the icon is already on the row
|
|
736
|
+
if label:
|
|
737
|
+
# +4 for the same inner inset prose, tag text and terminal output sit at.
|
|
738
|
+
_title(label, header_x + Melty.px(4), y, fitted_width - (header_x - x) - Melty.px(4) - (Melty.px(100) if failed else 0),
|
|
739
|
+
header_height, underlying if isinstance(message, ToolCall) else tint,
|
|
740
|
+
command_text_brightness if icon else 1.0)
|
|
741
|
+
if failed:
|
|
742
|
+
# Collapsed: the header line reserved room at its end. Expanded:
|
|
743
|
+
# the block's bottom-right corner, on the Show more / less line.
|
|
744
|
+
badge_y = y if summary_height else y + full_height - Melty.px(4) - header_height
|
|
745
|
+
_failure_badge(x + fitted_width - Melty.px(96), badge_y, Melty.px(92), header_height)
|
|
746
|
+
leaf_y = y + summary_height
|
|
747
|
+
for path, value, leaf_height, indent, caption in leaves:
|
|
748
|
+
if _visible(leaf_y, leaf_height, clip):
|
|
749
|
+
text_y = leaf_y
|
|
750
|
+
left = x + side + indent + content_x
|
|
751
|
+
leaf_width = row_span - indent - content_x - (user_inset if isinstance(message, UserMessage) else 0)
|
|
752
|
+
if isinstance(message, AssistantMessage):
|
|
753
|
+
leaf_width = min(leaf_width, terminal_max_width) # assistant prose caps like a terminal
|
|
754
|
+
if not isinstance(message, ToolCall) and isinstance(value, (PythonString, BashString, ToolOutput)):
|
|
755
|
+
is_bash = isinstance(value, (BashString, ToolOutput))
|
|
756
|
+
_code_background(left, leaf_y, leaf_width, leaf_height,
|
|
757
|
+
tuple(c * (bash_darken if is_bash else python_darken) for c in underlying[:3]),
|
|
758
|
+
shadow=bash_shadow if is_bash else None)
|
|
759
|
+
if isinstance(value, BashString):
|
|
760
|
+
caption = "$ bash"
|
|
761
|
+
if caption:
|
|
762
|
+
_title(caption, left, text_y, leaf_width, header_height, tint)
|
|
763
|
+
text_y += header_height
|
|
764
|
+
if path == ("terminal",):
|
|
765
|
+
imgui.set_cursor_screen_pos((x + content_x, text_y))
|
|
766
|
+
draw_chat_terminal(value, name=row_key + ":terminal", width=fitted_width - content_x, height=leaf_height)
|
|
767
|
+
elif isinstance(value, ImageReference):
|
|
768
|
+
image_name = row_key + ":image:" + repr(path)
|
|
769
|
+
# A user bubble contains its image; resizing can still grow
|
|
770
|
+
# into the conversation's full user-message span.
|
|
771
|
+
if isinstance(message, UserMessage):
|
|
772
|
+
leaf_width = row_width - chat_indent - indent - user_inset
|
|
773
|
+
previous_size = image_box(image_cache().entry(value), leaf_width,
|
|
774
|
+
state.image_sizes.get(image_name))
|
|
775
|
+
size = _draw_image(value, left, text_y, leaf_width,
|
|
776
|
+
leaf_height - header_height * (2 if caption else 1), header_height, tint,
|
|
777
|
+
name=image_name, size=state.image_sizes.get(image_name))
|
|
778
|
+
if size is not None and size != state.image_sizes.get(image_name):
|
|
779
|
+
state.image_sizes[image_name] = size
|
|
780
|
+
if size != previous_size:
|
|
781
|
+
view["relayout"] = True
|
|
782
|
+
changed = True
|
|
783
|
+
elif isinstance(value, Reference):
|
|
784
|
+
# Never paint data URLs / encoded image data as text.
|
|
785
|
+
name = value.get("name") or value.get("path") or ""
|
|
786
|
+
_title(value.label + (" · " + str(name) if name else "") + " (preview pending)",
|
|
787
|
+
left, text_y, leaf_width, header_height, tint)
|
|
788
|
+
elif isinstance(value, str) and prose and not isinstance(value, CodeString):
|
|
789
|
+
# Selection: a drag on a leaf starts it, the drag's
|
|
790
|
+
# head follows the pointer across leaves, Ctrl+C copies
|
|
791
|
+
# (below). All draw-list: no editor per leaf.
|
|
792
|
+
index = prose_index.get((row_key, path))
|
|
793
|
+
selection = view.get("selection")
|
|
794
|
+
span = None
|
|
795
|
+
if index is not None:
|
|
796
|
+
line_px, char_w = _prose_metrics()
|
|
797
|
+
leaf_rect = (left, text_y, left + leaf_width, text_y + leaf_height)
|
|
798
|
+
if draw_state.on_action("left_mouse_down", view_id=row_key + ":select:" + repr(path),
|
|
799
|
+
rect=leaf_rect, priority_delta=3) is not None:
|
|
800
|
+
offset = prose_offset(value, left, text_y, imgui.get_mouse_pos(), line_px, char_w, ui_scale=Melty.ui_scale)
|
|
801
|
+
selection = view["selection"] = {"anchor": (index, offset), "head": (index, offset),
|
|
802
|
+
"dragging": True}
|
|
803
|
+
selecting = True
|
|
804
|
+
changed = True
|
|
805
|
+
elif selection and selection.get("dragging") and imgui.is_mouse_down(0):
|
|
806
|
+
mouse = imgui.get_mouse_pos()
|
|
807
|
+
if text_y <= mouse[1] < text_y + leaf_height:
|
|
808
|
+
head = (index, prose_offset(value, left, text_y, mouse, line_px, char_w, ui_scale=Melty.ui_scale))
|
|
809
|
+
if head != tuple(selection["head"]):
|
|
810
|
+
selection["head"] = head
|
|
811
|
+
changed = True
|
|
812
|
+
if selection:
|
|
813
|
+
span = selection_slice(selection, index, len(str(value)))
|
|
814
|
+
_draw_prose(value, left, text_y, leaf_width, leaf_height, tint, clip, selected=span)
|
|
815
|
+
elif isinstance(value, str):
|
|
816
|
+
imgui.set_cursor_screen_pos((left, text_y))
|
|
817
|
+
draw_text(value, wrap=False, name=row_key + ":" + repr(path),
|
|
818
|
+
width=leaf_width,
|
|
819
|
+
height=leaf_height - (header_height if caption else 0),
|
|
820
|
+
editable=False, syntax_highlight=isinstance(value, (PythonString, BashString)),
|
|
821
|
+
syntax_language="bash" if isinstance(value, BashString) else "python",
|
|
822
|
+
autocomplete=False, show_widgets=False, show_header=False, with_footer=None,
|
|
823
|
+
show_file_header=False, show_jump_bar=False, scope_collapse=False,
|
|
824
|
+
disable_scroll=True, freeze_resize=True, imgui_padding=False,
|
|
825
|
+
use_cache=True, tint=tint, text_tint=_text_tint(tuple(tint)),
|
|
826
|
+
is_tree=False, show_bg=False, shadow=False, bg_offset=-1, fim="")
|
|
827
|
+
else:
|
|
828
|
+
_title(str(value), left, text_y, leaf_width, header_height, tint)
|
|
829
|
+
leaf_y += leaf_height
|
|
830
|
+
if show_more and _visible(leaf_y, header_height, clip):
|
|
831
|
+
show_all = state.output_expanded.get(row_key, False)
|
|
832
|
+
if _button(draw_state, row_key + ":output-more", "Show less" if show_all else "Show more",
|
|
833
|
+
x + Melty.px(4), leaf_y, Melty.px(102), underlying,
|
|
834
|
+
height=header_height, background=False, ui_scale=Melty.ui_scale):
|
|
835
|
+
state.output_expanded[row_key] = not show_all
|
|
836
|
+
changed = True
|
|
837
|
+
y += full_height
|
|
838
|
+
selection = view.get("selection")
|
|
839
|
+
if selection is not None:
|
|
840
|
+
if selection.get("dragging"):
|
|
841
|
+
if imgui.is_mouse_down(0):
|
|
842
|
+
changed = True # keep frames coming while the drag runs
|
|
843
|
+
else:
|
|
844
|
+
selection["dragging"] = False
|
|
845
|
+
if tuple(selection["anchor"]) == tuple(selection["head"]):
|
|
846
|
+
view["selection"] = None # a press without a drag selects nothing
|
|
847
|
+
changed = True
|
|
848
|
+
elif imgui.is_mouse_clicked(0) and not selecting:
|
|
849
|
+
view["selection"] = None # a click elsewhere clears it
|
|
850
|
+
changed = True
|
|
851
|
+
elif imgui.get_io().key_ctrl and any(k == glfw.KEY_C for k, _ in Melty.frame_key_events):
|
|
852
|
+
text = selection_text(selection, prose_texts)
|
|
853
|
+
if text:
|
|
854
|
+
imgui.set_clipboard_text(text)
|
|
855
|
+
return changed
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def draw_chat_requests(requests, draw_state, state, key, width, height, tint):
|
|
859
|
+
"""Pending approvals stay in a bounded scroll area above the composer."""
|
|
860
|
+
from meltygui.view.chat_decoration_view import _button
|
|
861
|
+
from meltygui.chat.chat_interface import _draw_prose
|
|
862
|
+
from meltygui.chat.chat_interface import _text_layout
|
|
863
|
+
from meltygui.chat.chat_interface import _title
|
|
864
|
+
from meltygui.chat.chat_interface import _viewport
|
|
865
|
+
from meltygui.view.text_view import draw_text
|
|
866
|
+
|
|
867
|
+
line_height = imgui.get_text_line_height() * 1.2
|
|
868
|
+
layouts = []
|
|
869
|
+
total = 0
|
|
870
|
+
for request_id, request in requests.items():
|
|
871
|
+
if "answer" in request:
|
|
872
|
+
continue
|
|
873
|
+
text, text_height = _text_layout(state, (key, request_id), request.get("text", "Tool permission"), wrap_width=width)
|
|
874
|
+
questions = request.get("data", {}).get("questions", []) if request["kind"] != "approval" else []
|
|
875
|
+
layouts.append((request_id, request, text, text_height, questions))
|
|
876
|
+
total += text_height + len(questions) * Melty.px(76) + Melty.px(36)
|
|
877
|
+
changed = False
|
|
878
|
+
with _viewport(draw_state, state, key, width, height, total) as (x, y, clip):
|
|
879
|
+
for request_id, request, text, text_height, questions in layouts:
|
|
880
|
+
_draw_prose(text, x, y, width - Melty.px(7), text_height, tint, clip)
|
|
881
|
+
y += text_height
|
|
882
|
+
answers = state.answers.setdefault(str(request_id), {})
|
|
883
|
+
for question in questions:
|
|
884
|
+
_title(question["question"], x, y, width, Melty.px(24), tint, ellipsis=True)
|
|
885
|
+
y += Melty.px(26)
|
|
886
|
+
imgui.set_cursor_screen_pos((x, y))
|
|
887
|
+
edited, value = draw_text(answers.get(question["id"], ""), name=key + ":" + question["id"],
|
|
888
|
+
width=width - Melty.px(7), height=50, wrap=False, syntax_highlight=False,
|
|
889
|
+
show_header=False, fim="", is_tree=False)
|
|
890
|
+
if edited:
|
|
891
|
+
answers[question["id"]] = value
|
|
892
|
+
changed = True
|
|
893
|
+
y += Melty.px(50)
|
|
894
|
+
decisions = ("accept", "decline") if request["kind"] == "approval" else ("answer",)
|
|
895
|
+
for index, decision in enumerate(decisions):
|
|
896
|
+
top, bottom = max(y, clip[1]), min(y + Melty.px(30), clip[3])
|
|
897
|
+
if bottom > top and _button(draw_state, f"answer:{request_id}:{decision}", decision.title(),
|
|
898
|
+
x + index * Melty.px(100), top, Melty.px(95), tint, height=bottom - top, ui_scale=Melty.ui_scale):
|
|
899
|
+
request["answer"] = ({"decision": decision} if decision != "answer" else
|
|
900
|
+
{"answers": {identifier: {"answers": [value]} for identifier, value in answers.items()}})
|
|
901
|
+
changed = True
|
|
902
|
+
y += Melty.px(36)
|
|
903
|
+
return changed
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def draw_conversation_title(chat, draw_state, state, selected, x, y, width, height, tint):
|
|
907
|
+
"""Double-click the header title to rename the same chat shown in the lists."""
|
|
908
|
+
from meltygui.chat.chat_interface import _title
|
|
909
|
+
from meltygui.view.text_view import draw_text
|
|
910
|
+
import meltygui.core.windowing.window_api as glfw
|
|
911
|
+
|
|
912
|
+
changed = False
|
|
913
|
+
renaming = state.rename
|
|
914
|
+
editing = (renaming is not None and renaming.get("pane") == "header"
|
|
915
|
+
and renaming["account"] == state.account and renaming["key"] == selected)
|
|
916
|
+
if not editing and draw_state.on_action(
|
|
917
|
+
"left_mouse_double_clicked", view_id="rename:header:" + state.account + ":" + selected,
|
|
918
|
+
rect=(x, y, x + width, y + height), priority_delta=5) is not None:
|
|
919
|
+
state.rename = renaming = {"account": state.account, "key": selected, "pane": "header",
|
|
920
|
+
"draft": chat["title"], "focus": True}
|
|
921
|
+
editing = changed = True
|
|
922
|
+
if not editing:
|
|
923
|
+
_title(chat["title"], x, y, width, height, tint, ellipsis=True)
|
|
924
|
+
return changed
|
|
925
|
+
imgui.set_cursor_screen_pos((x, y))
|
|
926
|
+
request_focus = renaming["focus"]
|
|
927
|
+
edited, value, text_ds = draw_text(renaming["draft"],
|
|
928
|
+
name="chat-title:" + state.account + ":" + selected, wrap=False,
|
|
929
|
+
request_focus=request_focus, select_all_on_focus=request_focus, single_line=True,
|
|
930
|
+
return_extras=True, width=width, height=height, is_tree=False,
|
|
931
|
+
editable=True, focusable=True, syntax_highlight=False, autocomplete=False,
|
|
932
|
+
show_header=False, show_bg=False, shadow=False, show_widgets=False,
|
|
933
|
+
show_file_header=False, show_jump_bar=False, scope_collapse=False,
|
|
934
|
+
disable_scroll=True, freeze_resize=True, use_cache=True, imgui_padding=False,
|
|
935
|
+
tint=tint, text_tint=_text_tint(tuple(tint)), fim="")
|
|
936
|
+
focused = Melty.text_focused_ds is text_ds
|
|
937
|
+
if focused:
|
|
938
|
+
renaming["focused"] = True
|
|
939
|
+
renaming["focus"] = False
|
|
940
|
+
if edited:
|
|
941
|
+
renaming["draft"] = value
|
|
942
|
+
changed = True
|
|
943
|
+
keys_pressed = {event[0] for event in Melty.frame_key_events} if focused else set()
|
|
944
|
+
if glfw.KEY_ESCAPE in keys_pressed:
|
|
945
|
+
state.rename = None
|
|
946
|
+
changed = True
|
|
947
|
+
elif (glfw.KEY_ENTER in keys_pressed or glfw.KEY_KP_ENTER in keys_pressed
|
|
948
|
+
or renaming.get("focused", False) and not focused):
|
|
949
|
+
if renaming["draft"].strip():
|
|
950
|
+
chat["title"] = renaming["draft"].strip()
|
|
951
|
+
state.rename = None
|
|
952
|
+
changed = True
|
|
953
|
+
return changed
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
@render_func(tint=(0.35, 0.55, 0.75), auto_resize=False, use_cache=True,
|
|
957
|
+
disable_scroll=True, imgui_padding=False, show_header=False, show_bg=False)
|
|
958
|
+
def draw_chat_navigation(input_value, draw_state=None, state: ChatInterfaceState = None,
|
|
959
|
+
row_edges=None, new_conversation=None, file_metadata=None, **kwargs):
|
|
960
|
+
"""Conversation lists and Internet Accounts share a resizable left column."""
|
|
961
|
+
from meltygui.chat.chat_interface import AGE_FILTERS
|
|
962
|
+
from meltygui.view.chat_decoration_view import _button
|
|
963
|
+
from meltygui.view.chat_decoration_view import _caret
|
|
964
|
+
from meltygui.chat.chat_interface import _label_width
|
|
965
|
+
from meltygui.chat.chat_interface import _title
|
|
966
|
+
from meltygui.model.chat_model import age_cutoff
|
|
967
|
+
from meltygui.chat.chat_interface import navigation_heading_control
|
|
968
|
+
from meltygui.chat.chat_interface import navigation_row_sizes
|
|
969
|
+
from meltygui.core.layout.column_core import RowLayout
|
|
970
|
+
import meltygui.accounts.internet_accounts as internet_accounts
|
|
971
|
+
|
|
972
|
+
changed = False
|
|
973
|
+
if not hasattr(state, "sections_expanded"):
|
|
974
|
+
state.sections_expanded = {"recent": True, "all": True, "accounts": True}
|
|
975
|
+
panes = ("accounts", "all", "recent")
|
|
976
|
+
opened = [state.sections_expanded.get(pane, True) for pane in panes]
|
|
977
|
+
heading_height = Melty.px(26)
|
|
978
|
+
tint = Toggles.Chat.navigation_tint
|
|
979
|
+
def section_heading(pane, x, y, width):
|
|
980
|
+
control_changed, control_width = navigation_heading_control(
|
|
981
|
+
pane, draw_state, state, x, y, width, heading_height)
|
|
982
|
+
width = max(0, width - control_width)
|
|
983
|
+
expanded = state.sections_expanded.get(pane, True)
|
|
984
|
+
toggled = _caret(draw_state, "section:" + pane, x, y, Melty.px(18), heading_height, expanded, tint, ui_scale=Melty.ui_scale)
|
|
985
|
+
label = {"all": "All chats", "recent": "Recent chats", "accounts": "Internet Accounts"}[pane]
|
|
986
|
+
_title(label, x + Melty.px(20), y, max(0, width - Melty.px(20)), heading_height, tint, brightness=0.7)
|
|
987
|
+
header_clicked = _button(draw_state, "section-label:" + pane, "", x + Melty.px(20), y,
|
|
988
|
+
max(0, width - Melty.px(20)), tint, height=heading_height,
|
|
989
|
+
background=False, shadow=False, event="left_mouse_down", ui_scale=Melty.ui_scale)
|
|
990
|
+
if toggled:
|
|
991
|
+
state.sections_expanded[pane] = not expanded
|
|
992
|
+
elif header_clicked:
|
|
993
|
+
state.sections_expanded[pane] = True
|
|
994
|
+
return control_changed or toggled or (header_clicked and not expanded)
|
|
995
|
+
window = draw_state.parent_window or draw_state
|
|
996
|
+
top = imgui.get_cursor_screen_pos()[1] - window.abs_top
|
|
997
|
+
minimum = heading_height + 8 # RowLayout's top and bottom padding.
|
|
998
|
+
row_heights = navigation_row_sizes(state, row_edges, opened, top, draw_state.height, minimum)
|
|
999
|
+
# Leave unused space below the headings when every section is closed.
|
|
1000
|
+
# Otherwise the last capped row would have to fill the whole column.
|
|
1001
|
+
extent = draw_state.height if any(opened) else minimum * len(panes)
|
|
1002
|
+
rows = RowLayout(draw_state, 3, row_edges=row_edges,
|
|
1003
|
+
top_edge={"y": top}, bottom_edge={"y": top + extent},
|
|
1004
|
+
row_heights=row_heights,
|
|
1005
|
+
row_mins=[minimum] * 3,
|
|
1006
|
+
row_maxes=[None if expanded else minimum for expanded in opened],
|
|
1007
|
+
persist=True, resizable=True,
|
|
1008
|
+
padding=4, padding_x=0, border_color=None)
|
|
1009
|
+
for index, pane in enumerate(panes):
|
|
1010
|
+
with rows.cell(index) as height:
|
|
1011
|
+
width = rows.inner_width()
|
|
1012
|
+
x, top = imgui.get_cursor_screen_pos()
|
|
1013
|
+
changed |= section_heading(pane, x, top, width)
|
|
1014
|
+
if not opened[index]:
|
|
1015
|
+
continue
|
|
1016
|
+
y = top + heading_height
|
|
1017
|
+
if pane == "accounts":
|
|
1018
|
+
imgui.set_cursor_screen_pos((x, y))
|
|
1019
|
+
from meltygui.view.account_view import draw_internet_accounts
|
|
1020
|
+
edited, _ = draw_internet_accounts(
|
|
1021
|
+
internet_accounts.accounts, name="chat-internet-accounts",
|
|
1022
|
+
width=width, height=max(0, height - heading_height),
|
|
1023
|
+
show_header=False, show_name=False, show_bg=False, shadow=False,
|
|
1024
|
+
# This embedded view borrows the chat view's accounts;
|
|
1025
|
+
# collapsing it should not close their running backends.
|
|
1026
|
+
on_cleanup=None)
|
|
1027
|
+
changed |= edited
|
|
1028
|
+
continue
|
|
1029
|
+
if pane == "recent":
|
|
1030
|
+
chip_x, chip_height = x, Melty.px(22)
|
|
1031
|
+
hours_selected = getattr(state, "age_hours", 0) or 24
|
|
1032
|
+
for label, hours in AGE_FILTERS:
|
|
1033
|
+
if not hours:
|
|
1034
|
+
continue
|
|
1035
|
+
chip_width = _label_width(label) + Melty.px(16)
|
|
1036
|
+
if chip_x > x and chip_x + chip_width > x + width:
|
|
1037
|
+
chip_x, y = x, y + chip_height + Melty.px(3)
|
|
1038
|
+
if _button(draw_state, "age:" + label, label, chip_x, y, chip_width, tint,
|
|
1039
|
+
height=chip_height, selected=hours == hours_selected, shadow=False,
|
|
1040
|
+
background=hours == hours_selected, ui_scale=Melty.ui_scale):
|
|
1041
|
+
state.age_hours = hours
|
|
1042
|
+
changed = True
|
|
1043
|
+
chip_x += chip_width + Melty.px(4)
|
|
1044
|
+
y += chip_height + Melty.px(5)
|
|
1045
|
+
imgui.set_cursor_screen_pos((x, y))
|
|
1046
|
+
edited, _ = draw_chat_sidebar(input_value, draw_state, state, width,
|
|
1047
|
+
max(0, height - (y - top)),
|
|
1048
|
+
cutoff=age_cutoff(getattr(state, "age_hours", 0) or 24) if pane == "recent" else None,
|
|
1049
|
+
new_conversation=new_conversation, pane=pane, file_metadata=file_metadata)
|
|
1050
|
+
changed |= edited
|
|
1051
|
+
rows.finish()
|
|
1052
|
+
return changed, input_value
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def draw_effort_slider(draw_state, key, value, levels, x, y, width, height, tint):
|
|
1056
|
+
"""A discrete slider using the owning view's replayable input actions."""
|
|
1057
|
+
from meltygui.view.chat_decoration_view import _color
|
|
1058
|
+
from meltygui.chat.chat_interface import _title
|
|
1059
|
+
|
|
1060
|
+
if not levels or value not in levels:
|
|
1061
|
+
_title("Effort: " + ("Unavailable" if not levels else "Loading…"), x, y, width, height,
|
|
1062
|
+
tint, brightness=0.6, ellipsis=True)
|
|
1063
|
+
return False, value
|
|
1064
|
+
selected = levels.index(value)
|
|
1065
|
+
left, right = x + Melty.px(8), x + width - Melty.px(8)
|
|
1066
|
+
rect = (x, y, x + width, y + height)
|
|
1067
|
+
pressed = draw_state.on_action("left_mouse_down", view_id=key, rect=rect, priority_delta=4)
|
|
1068
|
+
dragged = draw_state.on_action("left_mouse_drag", view_id=key + ":drag", rect=rect, priority_delta=4)
|
|
1069
|
+
changed = False
|
|
1070
|
+
if len(levels) > 1 and (pressed is not None or dragged is not None):
|
|
1071
|
+
fraction = max(0, min(1, (imgui.get_mouse_pos()[0] - left) / max(1, right - left)))
|
|
1072
|
+
index = round(fraction * (len(levels) - 1))
|
|
1073
|
+
changed = index != selected
|
|
1074
|
+
selected = index
|
|
1075
|
+
draw_list = imgui.get_window_draw_list()
|
|
1076
|
+
if Melty.channels_split:
|
|
1077
|
+
draw_list.channels_set_current(Melty.get_channel())
|
|
1078
|
+
track_y = y + height - Melty.px(5)
|
|
1079
|
+
color = _text_tint(tuple(tint))
|
|
1080
|
+
draw_list.add_line(left, track_y, right, track_y, _color(color, 0.3), Melty.px(2))
|
|
1081
|
+
for index in range(len(levels)):
|
|
1082
|
+
tick_x = left + (right - left) * index / max(1, len(levels) - 1)
|
|
1083
|
+
draw_list.add_circle_filled(tick_x, track_y, Melty.px(2), _color(color, 0.4), 12)
|
|
1084
|
+
knob_x = left + (right - left) * selected / max(1, len(levels) - 1)
|
|
1085
|
+
draw_list.add_circle_filled(knob_x, track_y, Melty.px(4), _color(color), 16)
|
|
1086
|
+
_title("Effort: " + levels[selected].title(), x + Melty.px(4), y,
|
|
1087
|
+
width - Melty.px(8), height - Melty.px(9), tint, brightness=0.8, ellipsis=True)
|
|
1088
|
+
return changed, levels[selected]
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
@render_func(auto_resize=False, min_width=700, min_height=500, show_bg=False, shadow=False, selectable=False,
|
|
1092
|
+
on_cleanup=_cleanup_chat, use_cache=True, disable_scroll=True, imgui_padding=False, indent_size=0)
|
|
1093
|
+
def draw_chat_interface(input_value=None, draw_state=None, bg_offset=-2, state: ChatInterfaceState = None,
|
|
1094
|
+
column_edges=None, new_project=None, default_project=None, file_metadata=None,
|
|
1095
|
+
ctrl_shift_equal_down=False, ctrl_shift_minus_down=False, **kwargs):
|
|
1096
|
+
"""The chat window. A new conversation runs in `new_project` when given
|
|
1097
|
+
(an app started for one project), else in the selected conversation's
|
|
1098
|
+
project, else `default_project` (an app's cwd; the studio: its checkout)."""
|
|
1099
|
+
from meltygui.chat.chat_interface import REFRESH_S
|
|
1100
|
+
from meltygui.view.chat_decoration_view import _button
|
|
1101
|
+
from meltygui.view.chat_decoration_view import _color
|
|
1102
|
+
from meltygui.chat.chat_interface import _draw_prose
|
|
1103
|
+
from meltygui.chat.chat_interface import _label_width
|
|
1104
|
+
from meltygui.view.chat_decoration_view import _running_dot
|
|
1105
|
+
from meltygui.chat.chat_interface import _text_layout
|
|
1106
|
+
from meltygui.chat.chat_interface import _title
|
|
1107
|
+
from meltygui.chat.chat_interface import apply_folder_shortcuts
|
|
1108
|
+
from meltygui.chat.chat_interface import chat_activity
|
|
1109
|
+
from meltygui.chat.chat_interface import chat_context_menu_items
|
|
1110
|
+
from meltygui.chat.chat_interface import chat_effort_levels
|
|
1111
|
+
from meltygui.chat.chat_interface import chat_models
|
|
1112
|
+
from meltygui.chat.chat_interface import chat_project_choices
|
|
1113
|
+
from meltygui.chat.chat_interface import chat_sources
|
|
1114
|
+
from meltygui.model.chat_model import conversation_folder_tint
|
|
1115
|
+
from meltygui.model.chat_model import is_active
|
|
1116
|
+
from meltygui.chat.chat_interface import is_new_chat
|
|
1117
|
+
from meltygui.chat.chat_interface import pick_source
|
|
1118
|
+
from meltygui.model.chat_model import project_tint
|
|
1119
|
+
from meltygui.chat.chat_interface import source_label
|
|
1120
|
+
from meltygui.chat.chat_interface import switch_new_chat_project
|
|
1121
|
+
from meltygui.chat.chat_interface import switch_new_chat_source
|
|
1122
|
+
from meltygui.chat.messages import user_message
|
|
1123
|
+
from meltygui.view.text_view import draw_text
|
|
1124
|
+
from meltygui.core.layout.column_core import ColumnLayout
|
|
1125
|
+
import meltygui.accounts.internet_accounts as internet_accounts
|
|
1126
|
+
import meltygui.core.windowing.window_api as glfw
|
|
1127
|
+
|
|
1128
|
+
# Ephemeral layout state also adopts already-open windows on hotswap.
|
|
1129
|
+
if not hasattr(state, "viewports"):
|
|
1130
|
+
state.viewports = {}
|
|
1131
|
+
state.text_layouts = {}
|
|
1132
|
+
folder_events = []
|
|
1133
|
+
if ctrl_shift_equal_down:
|
|
1134
|
+
folder_events.append((glfw.KEY_EQUAL, glfw.MOD_CONTROL | glfw.MOD_SHIFT))
|
|
1135
|
+
if ctrl_shift_minus_down:
|
|
1136
|
+
folder_events.append((glfw.KEY_MINUS, glfw.MOD_CONTROL | glfw.MOD_SHIFT))
|
|
1137
|
+
folders_changed = apply_folder_shortcuts(state, folder_events)
|
|
1138
|
+
accounts = internet_accounts.accounts
|
|
1139
|
+
if not accounts.loaded:
|
|
1140
|
+
accounts.load()
|
|
1141
|
+
accounts.ensure_kinds()
|
|
1142
|
+
sources = chat_sources(accounts)
|
|
1143
|
+
if not sources:
|
|
1144
|
+
return False, input_value
|
|
1145
|
+
kinds = {account_id: kind for account_id, kind in sources}
|
|
1146
|
+
if state.account not in kinds:
|
|
1147
|
+
state.account = next((account_id for account_id, kind in sources if kind.chat_available), sources[0][0])
|
|
1148
|
+
shown = [account_id for account_id in (getattr(state, "sources", None) or [state.account]) if account_id in kinds]
|
|
1149
|
+
if not shown:
|
|
1150
|
+
shown = [state.account]
|
|
1151
|
+
if state.account not in shown:
|
|
1152
|
+
state.account = shown[0]
|
|
1153
|
+
state.sources = shown
|
|
1154
|
+
kind = kinds[state.account]
|
|
1155
|
+
state.provider = kind.name
|
|
1156
|
+
account = accounts[state.account]
|
|
1157
|
+
changed = folders_changed
|
|
1158
|
+
|
|
1159
|
+
def wake():
|
|
1160
|
+
# Like Fast Dock's external-change edge: the worker has queued new data.
|
|
1161
|
+
from meltygui.core.windowing.glfw_utils import request_render
|
|
1162
|
+
if Melty.cache is not None:
|
|
1163
|
+
Melty.cache.invalidate_up_by_obj(state, force=True)
|
|
1164
|
+
if getattr(draw_state, "_tile_id", None) is not None:
|
|
1165
|
+
Melty.cache.invalidate_up(draw_state._tile_id, force=True, max_depth=8)
|
|
1166
|
+
request_render()
|
|
1167
|
+
|
|
1168
|
+
# A session per shown source (None: a registered placeholder, kept blank).
|
|
1169
|
+
proxies = {account_id: kinds[account_id].chats(accounts[account_id], wake=wake) for account_id in shown}
|
|
1170
|
+
proxy = proxies[state.account]
|
|
1171
|
+
for shown_proxy in proxies.values():
|
|
1172
|
+
if shown_proxy is not None:
|
|
1173
|
+
shown_proxy.wake = wake
|
|
1174
|
+
# Drain every open session, shown or not: a hidden session still streams.
|
|
1175
|
+
for other in accounts.values():
|
|
1176
|
+
other_proxy = other.get("_chat_proxy")
|
|
1177
|
+
if other_proxy is not None and not other_proxy.closed:
|
|
1178
|
+
# Only the displayed transcript needs eager history refreshes.
|
|
1179
|
+
# Source tabs can leave several proxies alive in this window.
|
|
1180
|
+
other_proxy.active_key = (state.selected.get(state.account)
|
|
1181
|
+
if other_proxy is proxy else None)
|
|
1182
|
+
changed |= other_proxy.drain()
|
|
1183
|
+
other_proxy.reconcile() # collect edits made in another view before applying metadata
|
|
1184
|
+
x, y = imgui.get_cursor_screen_pos()
|
|
1185
|
+
tint = kind.tint
|
|
1186
|
+
notice = "" if proxy is None else (proxy.error or ("Loading conversations…" if proxy.loading else ""))
|
|
1187
|
+
model_error = getattr(proxy, "models_error", None)
|
|
1188
|
+
if model_error:
|
|
1189
|
+
notice = (notice + "\n" if notice else "") + "Could not load models: " + model_error
|
|
1190
|
+
if notice:
|
|
1191
|
+
for line in notice.split("\n"):
|
|
1192
|
+
imgui.get_window_draw_list().add_text(*imgui.get_cursor_screen_pos(), _color((0.95, 0.65, 0.45)), line)
|
|
1193
|
+
imgui.dummy(1, imgui.get_text_line_height())
|
|
1194
|
+
if proxy.error or model_error:
|
|
1195
|
+
x, y = imgui.get_cursor_screen_pos()
|
|
1196
|
+
if _button(draw_state, "reconnect-chat", "Reconnect", x, y, Melty.px(110), tint, ui_scale=Melty.ui_scale):
|
|
1197
|
+
kind.close_chat(account)
|
|
1198
|
+
return True, input_value
|
|
1199
|
+
imgui.dummy(1, Melty.px(30))
|
|
1200
|
+
selected = state.selected.get(state.account)
|
|
1201
|
+
if proxy is not None and selected not in proxy:
|
|
1202
|
+
selected = next(iter(proxy), None)
|
|
1203
|
+
state.selected[state.account] = selected
|
|
1204
|
+
body_top = imgui.get_cursor_screen_pos()[1]
|
|
1205
|
+
body_bottom = draw_state.abs_top + (draw_state.height or Melty.px(760)) - Melty.px(16)
|
|
1206
|
+
body_height = max(Melty.px(180), body_bottom - body_top)
|
|
1207
|
+
columns = ColumnLayout(draw_state, 2, column_edges=column_edges,
|
|
1208
|
+
column_widths=[260, None], column_mins=[180, 400],
|
|
1209
|
+
padding=Melty.px(Toggles.Chat.column_gap), padding_y=0, border_color=None)
|
|
1210
|
+
with columns.cell(0, height=body_height) as width:
|
|
1211
|
+
x, y = imgui.get_cursor_screen_pos()
|
|
1212
|
+
top = y
|
|
1213
|
+
# The source bar: a tab per chat account, in its provider's tint, the
|
|
1214
|
+
# shown ones lifted. Click: that source; Shift+click: toggle it into
|
|
1215
|
+
# / out of the shown set. Tabs wrap in a narrow column.
|
|
1216
|
+
tab_height, tab_x = Melty.px(24), x
|
|
1217
|
+
for account_id, source_kind in sources:
|
|
1218
|
+
label = source_label(account_id, source_kind, accounts)
|
|
1219
|
+
tab_width = _label_width(label) + Melty.px(18)
|
|
1220
|
+
if tab_x > x and tab_x + tab_width > x + width:
|
|
1221
|
+
tab_x, y = x, y + tab_height + Melty.px(3)
|
|
1222
|
+
if _button(draw_state, "source:" + account_id, label, tab_x, y, tab_width, source_kind.tint,
|
|
1223
|
+
enabled=source_kind.chat_available, height=tab_height, selected=account_id in shown,
|
|
1224
|
+
shadow=False, ui_scale=Melty.ui_scale):
|
|
1225
|
+
pick_source(state, account_id, {a: k.name for a, k in sources}, additive=imgui.get_io().key_shift)
|
|
1226
|
+
changed = True
|
|
1227
|
+
# The sessions for this frame were opened above with the old
|
|
1228
|
+
# set: the next frame (asked for now) draws the new one.
|
|
1229
|
+
from meltygui.core.windowing.glfw_utils import request_render
|
|
1230
|
+
request_render()
|
|
1231
|
+
tab_x += tab_width + Melty.px(4)
|
|
1232
|
+
y += tab_height + Melty.px(Toggles.Chat.header_margin)
|
|
1233
|
+
shown_sources = [(account_id, proxies[account_id], kinds[account_id],
|
|
1234
|
+
source_label(account_id, kinds[account_id], accounts)) for account_id in shown]
|
|
1235
|
+
|
|
1236
|
+
def start_conversation(account_id, chats, project=None):
|
|
1237
|
+
"""A fresh conversation in that source: in `project` (a heading's +),
|
|
1238
|
+
else `new_project` when the app was started for one, else its
|
|
1239
|
+
selected conversation's project, else `default_project`."""
|
|
1240
|
+
from meltygui.core.runtime.paths import application_root
|
|
1241
|
+
key = str(uuid.uuid4())
|
|
1242
|
+
current = chats.get(state.selected.get(account_id))
|
|
1243
|
+
project = (project or new_project or (current["project"] if current else None)
|
|
1244
|
+
or state.projects.get(account_id) or default_project or str(application_root()))
|
|
1245
|
+
created = time.time()
|
|
1246
|
+
chats[key] = {"title": "New conversation", "project": project,
|
|
1247
|
+
"created_at": created, "updated": created}
|
|
1248
|
+
state.selected[account_id] = key
|
|
1249
|
+
state.account = account_id
|
|
1250
|
+
state.provider = kinds[account_id].name
|
|
1251
|
+
|
|
1252
|
+
# Every few seconds each shown backend looks for sessions written
|
|
1253
|
+
# elsewhere (a terminal's Claude Code): their rows light up too.
|
|
1254
|
+
now = time.time()
|
|
1255
|
+
for shown_proxy in proxies.values():
|
|
1256
|
+
if shown_proxy is not None and now - getattr(shown_proxy, "refreshed", 0.0) > REFRESH_S:
|
|
1257
|
+
shown_proxy.refreshed = now
|
|
1258
|
+
shown_proxy.refresh()
|
|
1259
|
+
imgui.set_cursor_screen_pos((x, y))
|
|
1260
|
+
edited, _ = draw_chat_navigation(shown_sources, name="chat-navigation",
|
|
1261
|
+
state=state, new_conversation=start_conversation,
|
|
1262
|
+
context_menu=chat_context_menu_items(state, shown_sources),
|
|
1263
|
+
width=width, height=max(0, body_height - (y - top) - Melty.px(35)),
|
|
1264
|
+
tint=Toggles.Chat.navigation_tint, file_metadata=file_metadata)
|
|
1265
|
+
changed |= edited
|
|
1266
|
+
y = top + body_height - Melty.px(30)
|
|
1267
|
+
# The New conversation button follows the list directly (a list taller
|
|
1268
|
+
# than the column scrolls and the button sits at the column's foot);
|
|
1269
|
+
# it starts one in the primary folder (a heading's + picks its folder).
|
|
1270
|
+
imgui.set_cursor_screen_pos((x, y))
|
|
1271
|
+
if _button(draw_state, "new-chat", "+ New conversation", x, y, min(width, Melty.px(190)), tint,
|
|
1272
|
+
proxy is not None and not proxy.loading and not proxy.error, ui_scale=Melty.ui_scale):
|
|
1273
|
+
start_conversation(state.account, proxy)
|
|
1274
|
+
changed = True
|
|
1275
|
+
imgui.dummy(width, Melty.px(25))
|
|
1276
|
+
# Sidebar selection takes effect in the same frame.
|
|
1277
|
+
selected = state.selected.get(state.account)
|
|
1278
|
+
proxy = proxies.get(state.account)
|
|
1279
|
+
kind = kinds[state.account]
|
|
1280
|
+
account = accounts[state.account]
|
|
1281
|
+
tint = kind.tint
|
|
1282
|
+
with columns.cell(1, height=body_height) as width:
|
|
1283
|
+
if proxy is not None and selected in proxy:
|
|
1284
|
+
chat = proxy[selected]
|
|
1285
|
+
meta = chat.metadata
|
|
1286
|
+
chat_tint = tuple(project_tint(chat["project"], file_metadata=file_metadata) or kind.tint)
|
|
1287
|
+
x, y = imgui.get_cursor_screen_pos()
|
|
1288
|
+
changed |= draw_conversation_title(chat, draw_state, state, selected, x, y,
|
|
1289
|
+
max(1, width - Melty.px(100)), Melty.px(28), chat_tint)
|
|
1290
|
+
concise = getattr(state, "concise", False)
|
|
1291
|
+
if _button(draw_state, "concise-view", "✓ Concise" if concise else "Concise",
|
|
1292
|
+
x + width - Melty.px(95), y, Melty.px(95), chat_tint,
|
|
1293
|
+
height=Melty.px(28), background=False, ui_scale=Melty.ui_scale):
|
|
1294
|
+
state.concise = not concise
|
|
1295
|
+
changed = True
|
|
1296
|
+
imgui.set_cursor_screen_pos((x, y))
|
|
1297
|
+
imgui.dummy(width, Melty.px(28))
|
|
1298
|
+
x, y = imgui.get_cursor_screen_pos()
|
|
1299
|
+
project_label, project_height = _text_layout(state, "directory:" + selected,
|
|
1300
|
+
"\uf07b " + (chat["project"] or "No working directory"), wrap_width=max(1, width))
|
|
1301
|
+
folder_tint = tuple(conversation_folder_tint(chat["project"], file_metadata=file_metadata) or chat_tint)
|
|
1302
|
+
_title(project_label, x, y, width, project_height, folder_tint,
|
|
1303
|
+
text_color=folder_tint)
|
|
1304
|
+
imgui.set_cursor_screen_pos((x, y))
|
|
1305
|
+
imgui.dummy(width, project_height + Melty.px(6))
|
|
1306
|
+
x, y = imgui.get_cursor_screen_pos()
|
|
1307
|
+
transcript_key = "history:" + state.account + ":" + selected
|
|
1308
|
+
# Reserve the composer before laying out the history. Scrolling
|
|
1309
|
+
# changes only message coordinates inside this history region.
|
|
1310
|
+
active = is_active(chat)
|
|
1311
|
+
project_editable = is_new_chat(chat) and not chat.get("external_busy") and not chat.get("locked")
|
|
1312
|
+
working = chat["running"] or bool(chat.turn_id) or "send" in chat.inflight
|
|
1313
|
+
action_extra_height = Melty.px(36) if working else 0
|
|
1314
|
+
effort_extra_height = Melty.px(36) if project_editable and kind.name in ("codex", "anthropic") else 0
|
|
1315
|
+
controls_extra_height = action_extra_height + effort_extra_height
|
|
1316
|
+
queue_count = len(chat.get("queued_messages", {}))
|
|
1317
|
+
queue_height = Melty.px(28) * (1 + min(3, queue_count)) if queue_count else 0
|
|
1318
|
+
requests_height = min(Melty.px(240), body_height * 0.35) if chat["requests"] else 0
|
|
1319
|
+
status_message = chat.error or ("Loading conversation…" if chat.loading else "")
|
|
1320
|
+
if chat.get("locked") and not status_message:
|
|
1321
|
+
from meltygui.chat.writer_locks import lock_message
|
|
1322
|
+
status_message = lock_message(chat.get("lock_owner"))
|
|
1323
|
+
locked = chat.get("locked", False)
|
|
1324
|
+
status_pad = Melty.px(12) if locked and status_message else 0
|
|
1325
|
+
status_icon = Melty.px(24) if status_pad else 0
|
|
1326
|
+
status_text, status_height = _text_layout(state, transcript_key + ":status", status_message,
|
|
1327
|
+
wrap_width=max(1, width - 2 * status_pad - status_icon)) if status_message else ("", 0)
|
|
1328
|
+
status_height += 2 * status_pad
|
|
1329
|
+
history_height = max(Melty.px(40), body_bottom - imgui.get_cursor_screen_pos()[1]
|
|
1330
|
+
- requests_height - status_height - queue_height - controls_extra_height - Melty.px(120 + (26 if active else 0)))
|
|
1331
|
+
changed |= draw_messages(chat["messages"], draw_state, state, transcript_key,
|
|
1332
|
+
width, history_height, conversation_tint=chat_tint, revision=proxy.revision, file_metadata=file_metadata)
|
|
1333
|
+
if active:
|
|
1334
|
+
x, y = imgui.get_cursor_screen_pos()
|
|
1335
|
+
_running_dot(x + Melty.px(10), y + Melty.px(12), chat_tint, ui_scale=Melty.ui_scale, channel=Melty.get_channel() if Melty.channels_split else None)
|
|
1336
|
+
_title(chat_activity(chat, kind.chat_label), x + Melty.px(22), y, width - Melty.px(22), Melty.px(24),
|
|
1337
|
+
chat_tint, brightness=0.8, ellipsis=True)
|
|
1338
|
+
imgui.set_cursor_screen_pos((x, y))
|
|
1339
|
+
imgui.dummy(1, Melty.px(26))
|
|
1340
|
+
if status_message:
|
|
1341
|
+
x, y = imgui.get_cursor_screen_pos()
|
|
1342
|
+
if locked:
|
|
1343
|
+
imgui.get_window_draw_list().add_rect_filled(x, y, x + width, y + status_height,
|
|
1344
|
+
_color((0.5, 0.12, 0.12), 0.5), rounding=Melty.px(7))
|
|
1345
|
+
_title("\uf023", x + status_pad, y + status_pad, status_icon,
|
|
1346
|
+
Melty.px(23), (0.95, 0.55, 0.5))
|
|
1347
|
+
_draw_prose(status_text, x + status_pad + status_icon, y + status_pad,
|
|
1348
|
+
width - 2 * status_pad - status_icon, status_height - 2 * status_pad,
|
|
1349
|
+
(0.95, 0.7, 0.65) if locked else (0.95, 0.65, 0.4))
|
|
1350
|
+
imgui.dummy(1, status_height)
|
|
1351
|
+
if requests_height:
|
|
1352
|
+
changed |= draw_chat_requests(chat["requests"], draw_state, state,
|
|
1353
|
+
transcript_key + ":requests", width, requests_height, chat_tint)
|
|
1354
|
+
changed |= draw_chat_queue(proxy, selected, chat, draw_state, state, transcript_key, width, chat_tint)
|
|
1355
|
+
draft_key = state.account + ":" + selected
|
|
1356
|
+
recovered = chat.pop("recovered_draft", None)
|
|
1357
|
+
if recovered:
|
|
1358
|
+
current = state.drafts.get(draft_key, "")
|
|
1359
|
+
state.drafts[draft_key] = recovered + ("\n\n" + current if current else "")
|
|
1360
|
+
changed = True
|
|
1361
|
+
locked = chat.get("locked", False)
|
|
1362
|
+
draft = state.drafts.get(draft_key, "")
|
|
1363
|
+
draft_generation = getattr(state, "draft_generations", {}).get(draft_key, 0)
|
|
1364
|
+
edited, draft = draw_text(draft, name="Message:" + selected + ":" + str(draft_generation),
|
|
1365
|
+
height=85, width=width, wrap=False, syntax_highlight=False, line_numbers=None, fim="", show_header=False,
|
|
1366
|
+
use_cache=True, is_tree=False, shadow=False, bg_offset=-1)
|
|
1367
|
+
if edited:
|
|
1368
|
+
state.drafts[draft_key] = draft
|
|
1369
|
+
changed = True
|
|
1370
|
+
x, y = imgui.get_cursor_screen_pos()
|
|
1371
|
+
can_send = bool(draft.strip()) and chat.loaded and not locked and not chat.get("external_busy") and (not chat.error or chat.get("retryable_error"))
|
|
1372
|
+
send = _button(draw_state, "send", "Send", x, y, Melty.px(85), chat_tint,
|
|
1373
|
+
can_send, height=Melty.px(30), shadow=False, ui_scale=Melty.ui_scale)
|
|
1374
|
+
queue = working and _button(draw_state, "queue", "Queue", x + Melty.px(95), y,
|
|
1375
|
+
Melty.px(85), chat_tint, can_send, height=Melty.px(30), shadow=False, ui_scale=Melty.ui_scale)
|
|
1376
|
+
if send or queue:
|
|
1377
|
+
if proxy.queue_message(selected, user_message(draft), interrupt=bool(send)):
|
|
1378
|
+
state.drafts[draft_key] = ""
|
|
1379
|
+
if not hasattr(state, "draft_generations"):
|
|
1380
|
+
state.draft_generations = {}
|
|
1381
|
+
state.draft_generations[draft_key] = draft_generation + 1
|
|
1382
|
+
changed = True
|
|
1383
|
+
if working and _button(draw_state, "stop", "Stop", x + Melty.px(190), y,
|
|
1384
|
+
Melty.px(85), chat_tint, not locked,
|
|
1385
|
+
height=Melty.px(30), shadow=False, ui_scale=Melty.ui_scale):
|
|
1386
|
+
proxy.stop_chat(selected)
|
|
1387
|
+
changed = True
|
|
1388
|
+
buttons_y = y
|
|
1389
|
+
y += action_extra_height
|
|
1390
|
+
from meltygui.view.dropdown_view import draw_dropdown
|
|
1391
|
+
permissions = {"Ask permission": "ask", "Full access": "full"}
|
|
1392
|
+
if not getattr(proxy, "inherits_defaults", False) and is_new_chat(chat) and meta.get("model") in (None, "", "default"):
|
|
1393
|
+
default_model = getattr(proxy, "default_model", None)
|
|
1394
|
+
if default_model:
|
|
1395
|
+
meta["model"] = default_model
|
|
1396
|
+
changed = True
|
|
1397
|
+
inherits_defaults = getattr(proxy, "inherits_defaults", False)
|
|
1398
|
+
defaults = proxy.defaults_for(chat["project"]) if inherits_defaults else {}
|
|
1399
|
+
models = chat_models(kind, proxy, meta.get("model", ""))
|
|
1400
|
+
if inherits_defaults:
|
|
1401
|
+
default_model = defaults.get("model") or getattr(proxy, "default_model", None)
|
|
1402
|
+
models = {"Default · " + (default_model or "Codex model"): "default", **models}
|
|
1403
|
+
access = ("Full access" if defaults.get("sandbox_mode") == "danger-full-access"
|
|
1404
|
+
and defaults.get("approval_policy") == "never" else "Configured access")
|
|
1405
|
+
permissions = {"Default · " + access: "default", **permissions}
|
|
1406
|
+
new_chat = is_new_chat(chat)
|
|
1407
|
+
if new_chat:
|
|
1408
|
+
# New drafts may choose any available source, including ones
|
|
1409
|
+
# whose conversations are currently hidden in the sidebar.
|
|
1410
|
+
grouped_models = {}
|
|
1411
|
+
for source_id, source_kind in sources:
|
|
1412
|
+
if not source_kind.chat_available:
|
|
1413
|
+
continue
|
|
1414
|
+
if source_id not in proxies:
|
|
1415
|
+
proxies[source_id] = source_kind.chats(accounts[source_id], wake=wake)
|
|
1416
|
+
source_proxy = proxies[source_id]
|
|
1417
|
+
if source_proxy is None:
|
|
1418
|
+
continue
|
|
1419
|
+
options = chat_models(source_kind, source_proxy,
|
|
1420
|
+
meta.get("model", "") if source_id == state.account else "")
|
|
1421
|
+
if getattr(source_proxy, "inherits_defaults", False):
|
|
1422
|
+
source_defaults = source_proxy.defaults_for(chat["project"])
|
|
1423
|
+
default_model = source_defaults.get("model") or getattr(source_proxy, "default_model", None)
|
|
1424
|
+
options = {"Default · " + (default_model or "Codex model"): "default", **options}
|
|
1425
|
+
grouped_models[source_label(source_id, source_kind, accounts)] = {
|
|
1426
|
+
label: (source_id, model) for label, model in options.items()}
|
|
1427
|
+
model_choices = grouped_models
|
|
1428
|
+
else:
|
|
1429
|
+
model_choices = models
|
|
1430
|
+
from meltygui.chat.codex_settings import effective_settings
|
|
1431
|
+
from meltygui.chat.codex_settings import fast_service_tier
|
|
1432
|
+
effective = effective_settings(chat, defaults, getattr(proxy, "default_model", None)) if kind.name == "codex" else {}
|
|
1433
|
+
fast_tier = fast_service_tier(proxy, effective.get("model")) if kind.name == "codex" else None
|
|
1434
|
+
fast_width = Melty.px(80) if fast_tier else 0
|
|
1435
|
+
has_effort = kind.name in ("codex", "anthropic")
|
|
1436
|
+
effort_width = Melty.px(150) + fast_width if has_effort and not project_editable else 0
|
|
1437
|
+
controls_start = 0 if working else Melty.px(95)
|
|
1438
|
+
control_x = x + controls_start
|
|
1439
|
+
control_count = 3 if project_editable else 2
|
|
1440
|
+
control_slot = max(Melty.px(12), (width - controls_start - effort_width) / control_count - Melty.px(6))
|
|
1441
|
+
for index, (field, choices, default) in enumerate((("permissions", permissions, "ask"), ("model", model_choices, ""))):
|
|
1442
|
+
current = meta.get(field, "default" if inherits_defaults else default)
|
|
1443
|
+
if inherits_defaults and (not current or field == "model" and not meta.get("model_explicit")):
|
|
1444
|
+
current = "default"
|
|
1445
|
+
if field == "model" and kind.name == "codex" and chat.get("codex_settings"):
|
|
1446
|
+
current = effective.get("model") or current
|
|
1447
|
+
labels = models if field == "model" else permissions
|
|
1448
|
+
label = next((label for label, value in labels.items() if value == current),
|
|
1449
|
+
current if current and current != "default" else "Loading models…")
|
|
1450
|
+
slot = min(_label_width(label) + Melty.px(46), Melty.px(240),
|
|
1451
|
+
control_slot)
|
|
1452
|
+
imgui.set_cursor_screen_pos((control_x, y))
|
|
1453
|
+
edited, value = draw_dropdown(label, collection=choices, display_label=label,
|
|
1454
|
+
name=field + ":" + draft_key, width=slot - Melty.px(4), height=Melty.px(30), trigger_height=Melty.px(30),
|
|
1455
|
+
show_header=False, shadow=False, tint=chat_tint,
|
|
1456
|
+
text_pad=8)
|
|
1457
|
+
control_x += slot + Melty.px(6)
|
|
1458
|
+
if edited:
|
|
1459
|
+
if field == "model" and new_chat:
|
|
1460
|
+
source_id, model = value
|
|
1461
|
+
changed |= switch_new_chat_source(state, proxies, kinds, selected, source_id, model)
|
|
1462
|
+
else:
|
|
1463
|
+
meta[field] = value
|
|
1464
|
+
meta[field + "_selected_at"] = time.time()
|
|
1465
|
+
if field == "model":
|
|
1466
|
+
meta["model_explicit"] = True
|
|
1467
|
+
changed = True
|
|
1468
|
+
if project_editable:
|
|
1469
|
+
current_chat = proxies[state.account][state.selected[state.account]]
|
|
1470
|
+
project = current_chat["project"]
|
|
1471
|
+
label = "\uf07b " + (Path(project).name or project or "Working directory")
|
|
1472
|
+
imgui.set_cursor_screen_pos((control_x, y))
|
|
1473
|
+
project_choices = chat_project_choices(state, proxies, project, new_project, default_project)
|
|
1474
|
+
edited, value = draw_dropdown(project,
|
|
1475
|
+
collection={"\uf07b " + label: value for label, value in project_choices.items()},
|
|
1476
|
+
row_tints={path: project_tint(path, file_metadata=file_metadata) for path in project_choices},
|
|
1477
|
+
display_label=label, name="project:" + draft_key,
|
|
1478
|
+
width=max(1, x + width - control_x), height=Melty.px(30), trigger_height=Melty.px(30),
|
|
1479
|
+
show_header=False, shadow=False, tint=tuple(project_tint(project, file_metadata=file_metadata) or kind.tint), text_pad=8)
|
|
1480
|
+
if edited:
|
|
1481
|
+
changed |= switch_new_chat_project(state, proxies, value)
|
|
1482
|
+
if has_effort:
|
|
1483
|
+
if project_editable:
|
|
1484
|
+
control_x = x + Melty.px(95)
|
|
1485
|
+
model = meta.get("model")
|
|
1486
|
+
if model in (None, "", "default") or inherits_defaults and not meta.get("model_explicit"):
|
|
1487
|
+
model = defaults.get("model") or getattr(proxy, "default_model", None)
|
|
1488
|
+
if kind.name == "codex":
|
|
1489
|
+
model = effective.get("model") or model
|
|
1490
|
+
if fast_tier:
|
|
1491
|
+
tier = effective.get("service_tier", getattr(proxy, "model_default_service_tiers", {}).get(model))
|
|
1492
|
+
fast = tier == fast_tier or tier in ("fast", "priority") and fast_tier in ("fast", "priority")
|
|
1493
|
+
if _button(draw_state, "fast:" + draft_key, "✓ Fast" if fast else "Fast",
|
|
1494
|
+
control_x, y + effort_extra_height, fast_width - Melty.px(6), chat_tint,
|
|
1495
|
+
selected=fast, height=Melty.px(30), shadow=False, ui_scale=Melty.ui_scale):
|
|
1496
|
+
meta["service_tier"] = None if fast else fast_tier
|
|
1497
|
+
meta["service_tier_selected_at"] = time.time()
|
|
1498
|
+
changed = True
|
|
1499
|
+
control_x += fast_width
|
|
1500
|
+
levels = chat_effort_levels(kind, proxy, model)
|
|
1501
|
+
effort = effective.get("effort") if kind.name == "codex" else meta.get("effort")
|
|
1502
|
+
if effort in (None, "", "default") or effort not in levels:
|
|
1503
|
+
if kind.name == "codex":
|
|
1504
|
+
effort = defaults.get("model_reasoning_effort") or getattr(proxy, "model_default_efforts", {}).get(model)
|
|
1505
|
+
else:
|
|
1506
|
+
effort = proxy.default_effort_for(chat["project"], model)
|
|
1507
|
+
if effort and effort not in levels and levels:
|
|
1508
|
+
order = ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
|
1509
|
+
eligible = [level for level in levels if level in order and effort in order
|
|
1510
|
+
and order.index(level) <= order.index(effort)]
|
|
1511
|
+
effort = eligible[-1] if eligible else levels[0]
|
|
1512
|
+
edited, effort = draw_effort_slider(draw_state, "effort:" + draft_key,
|
|
1513
|
+
effort, levels, control_x, y + effort_extra_height,
|
|
1514
|
+
max(Melty.px(80), x + width - control_x), Melty.px(30), chat_tint)
|
|
1515
|
+
if edited:
|
|
1516
|
+
meta["effort"] = effort
|
|
1517
|
+
meta["effort_selected_at"] = time.time()
|
|
1518
|
+
changed = True
|
|
1519
|
+
imgui.set_cursor_screen_pos((x, buttons_y))
|
|
1520
|
+
imgui.dummy(1, Melty.px(30) + controls_extra_height)
|
|
1521
|
+
columns.finish()
|
|
1522
|
+
if proxy is not None:
|
|
1523
|
+
proxy.reconcile()
|
|
1524
|
+
if changed:
|
|
1525
|
+
state.revision += 1
|
|
1526
|
+
# A click's effect (a row expanded, a filter picked) lays out on the
|
|
1527
|
+
# NEXT frame; ask for it now instead of waiting for the next input.
|
|
1528
|
+
from meltygui.core.windowing.glfw_utils import request_render
|
|
1529
|
+
request_render()
|
|
1530
|
+
return changed, input_value
|
|
1531
|
+
if changed:
|
|
1532
|
+
state.revision += 1
|
|
1533
|
+
return changed, input_value
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def _soft_wrap(text, columns):
|
|
1537
|
+
"""Word-wrap each line to `columns` monospace cells; indentation and blank lines survive."""
|
|
1538
|
+
if columns <= 0:
|
|
1539
|
+
return text
|
|
1540
|
+
out = []
|
|
1541
|
+
for line in text.split("\n"):
|
|
1542
|
+
stripped = line.lstrip(" \t")
|
|
1543
|
+
lead = line[:len(line) - len(stripped)]
|
|
1544
|
+
room = max(1, columns - len(lead.expandtabs(4)))
|
|
1545
|
+
while len(stripped) > room:
|
|
1546
|
+
cut = stripped.rfind(" ", 0, room + 1)
|
|
1547
|
+
if cut <= 0:
|
|
1548
|
+
cut = room # one word wider than the row: break it
|
|
1549
|
+
out.append(lead + stripped[:cut].rstrip())
|
|
1550
|
+
stripped = stripped[cut:].lstrip(" ")
|
|
1551
|
+
out.append(lead + stripped)
|
|
1552
|
+
return "\n".join(out)
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def _scroll_position(view, content_height, height, wheel=0, drag_fraction=None):
|
|
1556
|
+
maximum = max(0.0, content_height - height)
|
|
1557
|
+
offset = view.get("offset", 0.0)
|
|
1558
|
+
if wheel:
|
|
1559
|
+
speed = min(Toggles.ScrollSettings.scroll_speed,
|
|
1560
|
+
Toggles.ScrollSettings.max_increment_fraction * height)
|
|
1561
|
+
offset -= wheel * speed
|
|
1562
|
+
view["follow"] = False
|
|
1563
|
+
if drag_fraction is not None:
|
|
1564
|
+
offset = maximum * drag_fraction
|
|
1565
|
+
view["follow"] = False
|
|
1566
|
+
offset = max(0.0, min(offset, maximum))
|
|
1567
|
+
if view.get("follow"):
|
|
1568
|
+
offset = maximum
|
|
1569
|
+
elif (wheel < 0 or drag_fraction is not None) and maximum > 0 and offset >= maximum - 1:
|
|
1570
|
+
view["follow"] = True
|
|
1571
|
+
view["offset"] = offset
|
|
1572
|
+
return offset
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
def _visible(y, height, clip):
|
|
1576
|
+
return clip is None or y + height > clip[1] and y < clip[3]
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def selection_slice(selection, index, length):
|
|
1580
|
+
"""The (lo, hi) character span of prose leaf `index` inside a transcript
|
|
1581
|
+
selection ({anchor: (leaf, offset), head: (leaf, offset)}), None when the
|
|
1582
|
+
leaf is outside it or the selection is empty."""
|
|
1583
|
+
(first, first_offset), (last, last_offset) = sorted([tuple(selection["anchor"]), tuple(selection["head"])])
|
|
1584
|
+
if (first, first_offset) == (last, last_offset) or not first <= index <= last:
|
|
1585
|
+
return None
|
|
1586
|
+
lo = first_offset if index == first else 0
|
|
1587
|
+
hi = last_offset if index == last else length
|
|
1588
|
+
return (lo, hi) if hi > lo else None
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def selection_text(selection, texts):
|
|
1592
|
+
"""The selected prose, leaves joined by a blank line."""
|
|
1593
|
+
parts = []
|
|
1594
|
+
for index, text in enumerate(texts):
|
|
1595
|
+
span = selection_slice(selection, index, len(text))
|
|
1596
|
+
if span is not None:
|
|
1597
|
+
parts.append(str(text)[span[0]:span[1]])
|
|
1598
|
+
return "\n\n".join(parts)
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
def _message_leaves(value, path=(), depth=0):
|
|
1602
|
+
"""Keep nested arguments/results inspectable without a renderer per dict."""
|
|
1603
|
+
if isinstance(value, Reference):
|
|
1604
|
+
yield path, value, depth
|
|
1605
|
+
elif isinstance(value, dict):
|
|
1606
|
+
for key, child in value.items():
|
|
1607
|
+
yield from _message_leaves(child, path + (str(key),), depth + 1)
|
|
1608
|
+
elif isinstance(value, (list, tuple)):
|
|
1609
|
+
for index, child in enumerate(value):
|
|
1610
|
+
yield from _message_leaves(child, path + (str(index),), depth + 1)
|
|
1611
|
+
elif value is not None:
|
|
1612
|
+
yield path, value, depth
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def _message_preview(message):
|
|
1616
|
+
"""The first line a collapsed row would otherwise hide, markdown emphasis stripped."""
|
|
1617
|
+
if isinstance(message, CommandExecution):
|
|
1618
|
+
source = str(message["content"].get("command", ""))
|
|
1619
|
+
elif isinstance(message, ReasoningMessage):
|
|
1620
|
+
source = "\n".join(str(value) for _, value, _ in _message_leaves(message["content"])
|
|
1621
|
+
if isinstance(value, str))
|
|
1622
|
+
else:
|
|
1623
|
+
return ""
|
|
1624
|
+
for line in source.splitlines():
|
|
1625
|
+
line = line.strip().strip("*_#`").strip()
|
|
1626
|
+
if line:
|
|
1627
|
+
return line
|
|
1628
|
+
return ""
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
def _message_icon(message):
|
|
1632
|
+
"""The glyph that leads a row: pencil for writes, terminal for bash, brain for thinking."""
|
|
1633
|
+
bash_icon = f""
|
|
1634
|
+
write_icon = f"" # pencil-alt: the shipped face is FontAwesome 5, no f040 pencil
|
|
1635
|
+
thinking_icon = f""
|
|
1636
|
+
if isinstance(message, ReasoningMessage):
|
|
1637
|
+
return thinking_icon
|
|
1638
|
+
if not isinstance(message, ToolCall):
|
|
1639
|
+
return ""
|
|
1640
|
+
files = message.get("summary", {})
|
|
1641
|
+
if any(entry.get("access", "write") == "write" for entry in files.values()):
|
|
1642
|
+
return write_icon
|
|
1643
|
+
if isinstance(message["content"].get("command"), BashString):
|
|
1644
|
+
return bash_icon
|
|
1645
|
+
return ""
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
def _message_label(message, expanded):
|
|
1649
|
+
label = message.label
|
|
1650
|
+
if isinstance(message, ReasoningMessage):
|
|
1651
|
+
return "" if expanded else _message_preview(message)
|
|
1652
|
+
if isinstance(message, ToolCall):
|
|
1653
|
+
icon = _message_icon(message)
|
|
1654
|
+
if icon:
|
|
1655
|
+
label = icon
|
|
1656
|
+
# Read-only file references still leave a summary dict behind; only a
|
|
1657
|
+
# WRITE row (tags shown) drops the first-line preview.
|
|
1658
|
+
writes = any(entry.get("access", "write") == "write" for entry in message.get("summary", {}).values())
|
|
1659
|
+
preview = "" if expanded or writes else _message_preview(message)
|
|
1660
|
+
if preview:
|
|
1661
|
+
label += " " + preview
|
|
1662
|
+
else:
|
|
1663
|
+
name = message["details"].get("tool") or message["details"].get("name")
|
|
1664
|
+
if name:
|
|
1665
|
+
label += " · " + str(name)
|
|
1666
|
+
return label
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def _message_failed(message):
|
|
1670
|
+
details = message["details"]
|
|
1671
|
+
return (message.get("status") in ("failed", "declined", "error")
|
|
1672
|
+
or details.get("exitCode") not in (None, 0)
|
|
1673
|
+
or bool(details.get("error")))
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
def _hold_scroll_anchor(view, rows, height, restore, geometry=None):
|
|
1677
|
+
"""Pin the top visible row to its screen position across relayouts.
|
|
1678
|
+
|
|
1679
|
+
Each frame records (row key, pixels of that row above the viewport top);
|
|
1680
|
+
a frame whose heights moved (`restore`) re-derives the offset from it, so
|
|
1681
|
+
the reader's row stays put while rows above it re-wrap. Follow mode wins.
|
|
1682
|
+
"""
|
|
1683
|
+
if geometry is None:
|
|
1684
|
+
geometry = _row_geometry(rows)
|
|
1685
|
+
tops, ends = geometry
|
|
1686
|
+
total = ends[-1] if ends else 0.0
|
|
1687
|
+
anchor = view.get("anchor")
|
|
1688
|
+
if restore and anchor is not None and not view.get("follow") and anchor[0] in tops:
|
|
1689
|
+
view["offset"] = max(0.0, min(tops[anchor[0]] + anchor[1], max(0.0, total - height)))
|
|
1690
|
+
offset = view.get("offset", 0.0)
|
|
1691
|
+
index = bisect_right(ends, offset)
|
|
1692
|
+
view["anchor"] = ((rows[index][0], offset - tops[rows[index][0]])
|
|
1693
|
+
if index < len(rows) else None)
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
def _row_geometry(rows):
|
|
1697
|
+
"""Index the transcript once per layout, for scrolling and visible drawing."""
|
|
1698
|
+
tops, ends, y = {}, [], 0.0
|
|
1699
|
+
for row in rows:
|
|
1700
|
+
tops[row[0]] = y
|
|
1701
|
+
y += row[3]
|
|
1702
|
+
ends.append(y)
|
|
1703
|
+
return tops, ends
|