meltygui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,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