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,1232 @@
1
+ """FIM (fill-in-the-middle) code completion: the contract, the registries and
2
+ the per-editor broker. `draw_text` talks ONLY to `FimState`; providers talk
3
+ ONLY to `FimRequest`/`FimResult`; nothing in the editor knows which backend
4
+ answered.
5
+
6
+ draw_text ──► FimState (per draw_state, injected like GLState)
7
+ │ debounce · key dedup · cancel · chunked buffer · prefetch
8
+
9
+ @fim_provider function (stateless, one per backend)
10
+ │ session kwargs split off its FimSession.__init__
11
+
12
+ FimSession (per connection config: LS process, HTTP client, login)
13
+ pooled on Melty, REFCOUNTED by editors, idle-closed
14
+
15
+ Three lifetimes, deliberately separate:
16
+ * FimSession — one per (provider, connection config). Two editors on the
17
+ same config share one; "copilot-work" and "copilot-home"
18
+ are two. Never a singleton.
19
+ * FimState — one per editor draw_state: which profile, the in-flight
20
+ request, the chunked ghost buffer, the pinned context.
21
+ * FimRequest — one per keystroke burst.
22
+
23
+ Chunked acceptance: the provider always fetches a LONG completion
24
+ (`Toggles.Fim.max_tokens`, streamed); the editor only ever SHOWS one chunk of
25
+ it (`Toggles.Fim.chunk_lines` newline-terminated segments). Tab splices the
26
+ visible chunk and the next chunk becomes the ghost instantly — no request.
27
+ When the buffer runs low and the stream has ended, a continuation request
28
+ is prefetched from the virtual caret so arbitrarily long completions stay
29
+ snappy, and a small chunk gives the user a steering decision every few
30
+ lines instead of after a 40-line dump.
31
+
32
+ Coordinates: the broker's virtual document lives in BUFFER coordinates (the
33
+ editor span, what `draw_text` edits); a request is built in FILE coordinates
34
+ (`EditorView.file_head` + buffer + `file_tail`, PENDING truth) so providers
35
+ see the whole file.
36
+
37
+ Registries live on `Melty` (`_fim_providers`, `_fim_profiles`,
38
+ `_fim_context_sources`, `_fim_sessions`) so hotswap's registry reconcile
39
+ (`_FUNC_REGISTRY_NAMES` in file_converters) keeps them pointing at the live
40
+ functions after a recompile re-runs the decorators.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import inspect
45
+ import re
46
+ import threading
47
+ import time
48
+ import traceback
49
+ import weakref
50
+ from dataclasses import dataclass, field
51
+ from typing import Any, Callable
52
+
53
+
54
+ # ──────────────────────────────────────────────────────────────────────────
55
+ # Registries (on Melty - see module docstring). Accessed lazily so this
56
+ # module imports headless (tests) without dragging imgui in.
57
+ # ──────────────────────────────────────────────────────────────────────────
58
+
59
+ def _melty():
60
+ from meltygui.core.melty import Melty
61
+ return Melty
62
+
63
+
64
+ def _reg(name):
65
+ m = _melty()
66
+ reg = getattr(m, name, None)
67
+ if reg is None:
68
+ reg = {}
69
+ setattr(m, name, reg)
70
+ return reg
71
+
72
+
73
+ def providers() -> dict:
74
+ return _reg("_fim_providers")
75
+
76
+
77
+ def profiles() -> dict:
78
+ return _reg("_fim_profiles")
79
+
80
+
81
+ def context_sources() -> dict:
82
+ return _reg("_fim_context_sources")
83
+
84
+
85
+ def _sessions() -> dict:
86
+ """The live session pool — kept on `sys` (not Melty) so it SURVIVES an
87
+ in-process restart (the server purges every `src.*` module and
88
+ re-imports; `sys` does not), keeping Copilot language-server processes
89
+ and HTTP clients alive so a restart never re-spawns or re-logs-in.
90
+
91
+ The pool's `session_key` is (module string, qualname string, kwargs),
92
+ all stable across a re-exec, so the re-imported session classes resolve
93
+ to the same cached instances. A cached instance pins its BIRTH module
94
+ generation through its class (and the Copilot reader thread) — bounded
95
+ to one generation because sessions are created once and reused, which
96
+ is the unavoidable cost of holding a live subprocess across restarts.
97
+ Real process exit: the Copilot LS self-terminates on parent-PID death
98
+ (`processId` in initialize), and HTTP clients hold nothing that leaks,
99
+ so no atexit closer is registered (that would pin gen-1 Melty)."""
100
+ import sys
101
+ pool = getattr(sys, "_lsd_fim_sessions", None)
102
+ if pool is None:
103
+ pool = sys._lsd_fim_sessions = {}
104
+ return pool
105
+
106
+
107
+ def _session_alive(sess) -> bool:
108
+ """A pooled session is reusable unless its `alive()` says otherwise (a
109
+ Copilot LS that exited, an HTTP client closed)."""
110
+ try:
111
+ chk = getattr(sess, "alive", None)
112
+ return bool(chk()) if callable(chk) else True
113
+ except Exception:
114
+ return False
115
+
116
+
117
+ # ──────────────────────────────────────────────────────────────────────────
118
+ # Contract
119
+ # ──────────────────────────────────────────────────────────────────────────
120
+
121
+ TIERS = ("stable", "run", "volatile")
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class ContextItem:
126
+ """One piece of context the model sees. `tier` decides WHERE in the
127
+ prompt it goes (stable → run → volatile, cache breakpoints after the
128
+ first two); `score` decides membership only — never order, a wobbling
129
+ order would re-byte the cached prefix."""
130
+ kind: str # definition | signature | enclosing | caller | outline | runtime_types | live_values | call_stack | error
131
+ key: tuple # identity for dedupe/diff
132
+ text: str # final rendered text
133
+ tier: str = "stable" # stable | run | volatile
134
+ score: float = 1.0
135
+ path: str | None = None
136
+ line: int | None = None # 1-based file line of the item's start
137
+ end: int | None = None
138
+ version: Any = None # an invalidation token (pending gen / run gen)
139
+ data: Any = None # kind-specific: definition → its signature line (degrade target);
140
+ # live_values → {0-based buffer line: summary}
141
+
142
+ @property
143
+ def tokens(self) -> int:
144
+ return max(1, len(self.text) // 4)
145
+
146
+
147
+ @dataclass
148
+ class FimContext:
149
+ """The fitted, deterministically ordered context for one editor."""
150
+ items: list = field(default_factory=list)
151
+ key: tuple = ()
152
+
153
+ def tier(self, name) -> list:
154
+ return [it for it in self.items if it.tier == name]
155
+
156
+ def tokens(self) -> int:
157
+ return sum(it.tokens for it in self.items)
158
+
159
+ def render(self, tiers=("stable",), header="# {path}:{line}-{end}") -> str:
160
+ """Plain-text rendering of the given tiers, in stored order, each
161
+ item under a provenance header when it has one."""
162
+ out = []
163
+ for it in self.items:
164
+ if it.tier not in tiers or it.kind == "live_values":
165
+ continue
166
+ if it.path and it.line:
167
+ out.append(header.format(path=it.path, line=it.line,
168
+ end=it.end if it.end else it.line))
169
+ out.append(it.text.rstrip("\n"))
170
+ out.append("")
171
+ return "\n".join(out).rstrip("\n")
172
+
173
+ def line_annotations(self) -> dict:
174
+ """{0-based buffer line: summary} merged from every `live_values`
175
+ item — providers splice these into the prefix as trailing comments."""
176
+ ann = {}
177
+ for it in self.items:
178
+ if it.kind == "live_values" and isinstance(it.data, dict):
179
+ ann.update(it.data)
180
+ return ann
181
+
182
+
183
+ @dataclass
184
+ class EditorView:
185
+ """What the editor knows about itself — the input to context sources
186
+ and the file-coordinate frame for requests. Built per editor body run,
187
+ so everything O(file) is lazy: `file_head`/`file_tail` (the PENDING
188
+ file text before/after the buffer) are computed on first access, i.e.
189
+ at submit time, and `fn` (the live function object / store owner) is
190
+ resolved when context is assembled."""
191
+ path: str | None
192
+ text: str # editor text (the span)
193
+ cursor: int # caret offset into `text`
194
+ address: Any = None # the span's Address (path/start/end)
195
+ fn: Any = None
196
+ language: str = "python"
197
+ version: Any = None # pending version of `path`
198
+ _head: str | None = None
199
+ _tail: str | None = None
200
+
201
+ def _split(self):
202
+ from meltygui.completion.fim_context import file_head_tail
203
+ self._head, self._tail = file_head_tail(self.text, self.address)
204
+
205
+ @property
206
+ def file_head(self) -> str:
207
+ if self._head is None:
208
+ self._split()
209
+ return self._head
210
+
211
+ @property
212
+ def file_tail(self) -> str:
213
+ if self._tail is None:
214
+ self._split()
215
+ return self._tail
216
+
217
+ @property
218
+ def span_start(self) -> int:
219
+ """0-based file line the buffer starts at."""
220
+ start = getattr(self.address, "start", None) if self.address is not None else None
221
+ return int(start) if isinstance(start, int) and start >= 0 else 0
222
+
223
+ @property
224
+ def caret_line(self) -> int:
225
+ return self.text.count("\n", 0, self.cursor)
226
+
227
+ def resolve_fn(self):
228
+ if self.fn is None and self.path is not None:
229
+ from meltygui.completion.fim_context import span_function
230
+ self.fn = span_function(self.path, self.span_start)
231
+ return self.fn
232
+
233
+
234
+ @dataclass
235
+ class FimRequest:
236
+ """One completion request in FILE coordinates: `text` is the whole file
237
+ (pending truth) so a provider can window it however it likes;
238
+ `prefix`/`suffix` are the convenience split at `cursor`."""
239
+ path: str | None
240
+ text: str
241
+ cursor: int
242
+ language: str
243
+ version: Any
244
+ context: FimContext
245
+ max_tokens: int
246
+ cancelled: threading.Event
247
+ emit: Callable[[str], None] # emit the FULL text produced so far (streaming)
248
+ span_start: int = 0 # buffer line 0 == file line span_start (for requests)
249
+
250
+ @property
251
+ def prefix(self) -> str:
252
+ return self.text[:self.cursor]
253
+
254
+ @property
255
+ def suffix(self) -> str:
256
+ return self.text[self.cursor:]
257
+
258
+ def annotated_prefix(self, prefix=None, marker=" # ← ") -> str:
259
+ """`prefix` (default: the request's) with the context's live-value
260
+ summaries appended to their lines as trailing comments — what the
261
+ live view shows a human, shown to the model. The caret's own
262
+ (partial) line is never annotated."""
263
+ if prefix is None:
264
+ prefix = self.prefix
265
+ ann = self.context.line_annotations() if self.context is not None else {}
266
+ if not ann:
267
+ return prefix
268
+ lines = prefix.split("\n")
269
+ for bl, summary in ann.items():
270
+ fl = bl + self.span_start
271
+ if 0 <= fl < len(lines) - 1 and lines[fl].strip():
272
+ lines[fl] = lines[fl].rstrip() + marker + summary
273
+ return "\n".join(lines)
274
+
275
+
276
+ @dataclass
277
+ class FimResult:
278
+ text: str # insert at cursor
279
+ replace_to: int = 0 # consume this many suffix chars (for text edits)
280
+ alternatives: tuple = () # other candidates' full text
281
+ token: Any = None # arbitrary, handed back to the provider's _on_accept
282
+ provider: str = ""
283
+ truncated: bool = False # the model hit the token limit (more to come) or
284
+ # finished on a stop token. Only a truncated
285
+ # completion is auto-continued when the buffer
286
+ # drains - its natural stop is the end.
287
+
288
+
289
+ # ──────────────────────────────────────────────────────────────────────────
290
+ # Sessions
291
+ # ──────────────────────────────────────────────────────────────────────────
292
+
293
+ class FimSession:
294
+ """Base class for a live connection (LS subprocess, HTTP client, login).
295
+ Subclass `__init__(self, **config)` — its parameter NAMES are what
296
+ `fim_profile(...)` kwargs get routed here (everything else goes to the
297
+ provider function per request). Must be safe for concurrent `complete`
298
+ calls from several editors' worker threads."""
299
+ _refs = 0
300
+ _idle_since = 0.0
301
+ _key = None
302
+ _profile = ""
303
+
304
+ def status(self):
305
+ """("ready",) | ("needs_login", user_code, url) | ("error", msg)"""
306
+ return ("ready",)
307
+
308
+ def alive(self):
309
+ """False when the session can no longer serve requests (a crashed
310
+ subprocess) and must be recreated on the next acquire. HTTP-client
311
+ sessions stay alive; process-backed ones override this."""
312
+ return True
313
+
314
+ def close(self):
315
+ pass
316
+
317
+ def restart(self):
318
+ self.close()
319
+
320
+
321
+ def _session_param_names(session_cls) -> set:
322
+ if session_cls is None:
323
+ return set()
324
+ try:
325
+ sig = inspect.signature(session_cls.__init__)
326
+ except (TypeError, ValueError):
327
+ return set()
328
+ return {n for n, p in sig.parameters.items()
329
+ if n != "self" and p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)}
330
+
331
+
332
+ def split_kwargs(provider_fn, kwargs: dict):
333
+ """(session_kwargs, request_kwargs) for a profile's kwargs."""
334
+ names = _session_param_names(getattr(provider_fn, "_fim_session_cls", None))
335
+ s = {k: v for k, v in kwargs.items() if k in names}
336
+ r = {k: v for k, v in kwargs.items() if k not in names}
337
+ return s, r
338
+
339
+
340
+ def session_key(session_cls, session_kwargs: dict) -> tuple:
341
+ return (session_cls.__module__, session_cls.__qualname__,
342
+ tuple(sorted(session_kwargs.items())))
343
+
344
+
345
+ _pool_lock = threading.Lock()
346
+
347
+
348
+ def acquire_session(profile_name: str):
349
+ """The pooled session for `profile_name` (+1 ref), or None for a
350
+ provider without sessions. Raises on construction failure. Called from
351
+ worker threads (constructing a session may import an SDK or spawn a
352
+ process — never on the render thread); the pool is locked."""
353
+ with _pool_lock:
354
+ return _acquire_session_locked(profile_name)
355
+
356
+
357
+ def _acquire_session_locked(profile_name: str):
358
+ prof = profiles().get(profile_name)
359
+ if prof is None:
360
+ raise KeyError(f"unknown FIM profile {profile_name!r} "
361
+ f"(known: {sorted(profiles())})")
362
+ fn = providers().get(prof.provider)
363
+ if fn is None:
364
+ raise KeyError(f"profile {profile_name!r}: unknown provider {prof.provider!r}")
365
+ cls = getattr(fn, "_fim_session_cls", None)
366
+ if cls is None:
367
+ return None
368
+ skw, _ = split_kwargs(fn, dict(prof.kwargs))
369
+ key = session_key(cls, skw)
370
+ pool = _sessions()
371
+ sess = pool.get(key)
372
+ if sess is not None and not _session_alive(sess):
373
+ pool.pop(key, None)
374
+ try:
375
+ sess.close()
376
+ except Exception:
377
+ pass
378
+ sess = None
379
+ if sess is None:
380
+ sess = cls(**skw)
381
+ sess._key = key
382
+ sess._profile = profile_name
383
+ sess._refs = 0
384
+ pool[key] = sess
385
+ sess._refs += 1
386
+ return sess
387
+
388
+
389
+ def session_for(session_cls, session_kwargs: dict, create=True):
390
+ """The pooled session for an explicit (class, kwargs) — the SAME key a
391
+ profile with those session kwargs resolves to, so e.g. the Internet
392
+ Accounts window and the copilot provider share one LS process. No
393
+ refcount is taken (the idle sweep reclaims it). `create=False` only
394
+ peeks."""
395
+ key = session_key(session_cls, session_kwargs)
396
+ with _pool_lock:
397
+ pool = _sessions()
398
+ sess = pool.get(key)
399
+ if sess is not None and not _session_alive(sess):
400
+ pool.pop(key, None)
401
+ try:
402
+ sess.close()
403
+ except Exception:
404
+ pass
405
+ sess = None
406
+ if sess is None and create:
407
+ sess = session_cls(**session_kwargs)
408
+ sess._key = key
409
+ sess._profile = ""
410
+ sess._refs = 0
411
+ sess._idle_since = time.monotonic()
412
+ pool[key] = sess
413
+ return sess
414
+
415
+
416
+ def drop_sessions(pred):
417
+ """Close every pooled session `pred(sess)` selects and make the editors
418
+ holding it re-acquire (a credential changed). Returns the count."""
419
+ pool = _sessions()
420
+ dropped = []
421
+ with _pool_lock:
422
+ for key, sess in list(pool.items()):
423
+ try:
424
+ hit = pred(sess)
425
+ except Exception:
426
+ hit = False
427
+ if hit:
428
+ pool.pop(key, None)
429
+ dropped.append(sess)
430
+ for sess in dropped:
431
+ for st in list(_live_states):
432
+ if st.session is sess:
433
+ st.session = None
434
+ st._session_resolved = False
435
+ st._session_error = None
436
+ try:
437
+ sess.close()
438
+ except Exception as e:
439
+ print(f"[fim] session close failed: {e}")
440
+ return len(dropped)
441
+
442
+
443
+ def release_session(sess):
444
+ if sess is None:
445
+ return
446
+ sess._refs = max(0, sess._refs - 1)
447
+ if sess._refs == 0:
448
+ sess._idle_since = time.monotonic()
449
+
450
+
451
+ def sweep_sessions(now=None, idle_s=None):
452
+ """Close zero-ref sessions idle longer than `idle_s`
453
+ (`Toggles.Fim.session_idle_s`). Returns the number closed."""
454
+ if idle_s is None:
455
+ from meltygui.core.runtime.toggles import Toggles
456
+ idle_s = Toggles.Fim.session_idle_s
457
+ now = time.monotonic() if now is None else now
458
+ pool = _sessions()
459
+ n = 0
460
+ for key, sess in list(pool.items()):
461
+ if sess._refs <= 0 and now - sess._idle_since >= idle_s:
462
+ pool.pop(key, None)
463
+ try:
464
+ sess.close()
465
+ except Exception as e:
466
+ print(f"[fim] session close failed: {e}")
467
+ n += 1
468
+ return n
469
+
470
+
471
+ def restart_session(profile_name: str):
472
+ """Drop the pooled session for a profile (editors re-acquire lazily) —
473
+ the escape hatch after hotswapping a session class's __init__."""
474
+ pool = _sessions()
475
+ for key, sess in list(pool.items()):
476
+ if sess._profile == profile_name:
477
+ pool.pop(key, None)
478
+ try:
479
+ sess.close()
480
+ except Exception as e:
481
+ print(f"[fim] session close failed: {e}")
482
+ for st in list(_live_states):
483
+ if st.profile_name == profile_name:
484
+ st.session = None
485
+ st._session_resolved = False
486
+ st._session_error = None
487
+
488
+
489
+ def detach_sessions_for_restart():
490
+ """Keep every pooled session alive across an in-process restart: zero
491
+ its refcount (the current generation's FimStates are about to be
492
+ dropped with their module) and stamp it idle-now, so the next
493
+ generation re-acquires it within the idle window rather than
494
+ re-spawning. Closes nothing."""
495
+ now = time.monotonic()
496
+ for sess in list(_sessions().values()):
497
+ sess._refs = 0
498
+ sess._idle_since = now
499
+
500
+
501
+ def shutdown_all_sessions():
502
+ """Close and drop EVERY pooled session (real teardown). Not used on the
503
+ restart path — see FimState.shutdown_all — only where a genuine full
504
+ close is wanted."""
505
+ pool = _sessions()
506
+ for key, sess in list(pool.items()):
507
+ pool.pop(key, None)
508
+ try:
509
+ sess.close()
510
+ except Exception:
511
+ pass
512
+
513
+
514
+ # ──────────────────────────────────────────────────────────────────────────
515
+ # Decorators
516
+ # ──────────────────────────────────────────────────────────────────────────
517
+
518
+ @dataclass(frozen=True)
519
+ class FimProfile:
520
+ name: str
521
+ provider: str # provider NAME (hotswap-stable; resolved at runtime)
522
+ kwargs: tuple # sorted (k, v) pairs
523
+
524
+
525
+ def fim_provider(name: str, session=None):
526
+ """Register a provider function `fn(req: FimRequest, session, **params)
527
+ -> FimResult`. `session` is the FimSession subclass whose instances the
528
+ broker hands in (None for stateless providers). Params on the function's
529
+ signature are the per-request knobs; a profile overrides them by name.
530
+ Optional hooks by attribute: `fn._on_shown(req, result)`,
531
+ `fn._on_accept(result, accepted_chars)`.
532
+ Also registers a default profile under the provider's own name."""
533
+ def deco(fn):
534
+ fn._fim_name = name
535
+ fn._fim_session_cls = session
536
+ providers()[name] = fn
537
+ profiles().setdefault(name, FimProfile(name, name, ()))
538
+ return fn
539
+ return deco
540
+
541
+
542
+ def fim_profile(name: str, provider, **kwargs):
543
+ """A named configuration of a provider: `fim_profile("copilot-work",
544
+ copilot_fim, config_dir=...)`. Kwargs naming the session class's __init__
545
+ params select/construct the session; the rest override the provider
546
+ function's per-request params."""
547
+ pname = provider if isinstance(provider, str) else getattr(provider, "_fim_name", None)
548
+ if not pname:
549
+ raise ValueError(f"fim_profile({name!r}): provider must be a name or a @fim_provider")
550
+ prof = FimProfile(name, pname, tuple(sorted(kwargs.items())))
551
+ profiles()[name] = prof
552
+ return prof
553
+
554
+
555
+ def fim_context_source(kind: str, tier: str = "stable", order: int = 100):
556
+ """Register `fn(view: EditorView) -> Iterable[ContextItem]`. `order` is
557
+ the deterministic position of this source's items within its tier."""
558
+ def deco(fn):
559
+ fn._fim_kind = kind
560
+ fn._fim_tier = tier
561
+ fn._fim_order = order
562
+ context_sources()[kind] = fn
563
+ return fn
564
+ return deco
565
+
566
+
567
+ def profile_names() -> list:
568
+ return sorted(profiles())
569
+
570
+
571
+ # ──────────────────────────────────────────────────────────────────────────
572
+ # Chunking
573
+ # ──────────────────────────────────────────────────────────────────────────
574
+
575
+ def _segments(buffer: str) -> list:
576
+ return buffer.splitlines(keepends=True)
577
+
578
+
579
+ _CURSOR_TAIL_RE = re.compile(r"\n[ \t]+$")
580
+
581
+
582
+ def split_chunk(buffer: str, chunk_lines: int, complete_only: bool = False) -> str:
583
+ """The first chunk of `buffer`: any leading blank segments (the model
584
+ moving to a new line) plus up to `chunk_lines` content segments — and
585
+ NEVER a trailing newline: the chunk's line end stays in the buffer, so
586
+ accepting leaves the caret at the end of what was inserted and the
587
+ NEXT chunk begins with "\\n" + the model's own indentation (the model
588
+ decides where the caret goes). With `complete_only` (stream still
589
+ running) an unterminated last segment is held back. A whitespace-only
590
+ remainder is a chunk only when it is "\\n" + indentation (a final
591
+ caret placement) and the stream is done."""
592
+ parts = _segments(buffer)
593
+ if not parts:
594
+ return ""
595
+ n = len(parts)
596
+ i = 0
597
+ while i < n and parts[i].strip() == "":
598
+ i += 1
599
+ if i == n:
600
+ if not complete_only and _CURSOR_TAIL_RE.search(buffer):
601
+ return buffer
602
+ return ""
603
+ taken = 0
604
+ while i < n and taken < max(1, chunk_lines):
605
+ i += 1
606
+ taken += 1
607
+ if complete_only and i == n and not parts[-1].endswith("\n"):
608
+ i -= 1
609
+ chunk = "".join(parts[:i])
610
+ if chunk.endswith("\n"):
611
+ chunk = chunk[:-1]
612
+ return chunk if chunk.strip() else ""
613
+
614
+
615
+ def strip_cursor_tail(buffer: str) -> str:
616
+ """Everything in `buffer` worth inserting at once (accept-all): a bare
617
+ trailing newline run is dropped, "\\n" + indentation is kept."""
618
+ if _CURSOR_TAIL_RE.search(buffer):
619
+ return buffer
620
+ return buffer.rstrip("\n")
621
+
622
+
623
+ def content_lines(buffer: str) -> int:
624
+ return sum(1 for p in _segments(buffer) if p.strip())
625
+
626
+
627
+ def at_line_end(text: str, cursor: int) -> bool:
628
+ """True when nothing but whitespace follows the caret on its own line —
629
+ the FIM trigger condition (don't generate in the MIDDLE of a line).
630
+ Trailing spaces and the rest of the file below are ignored."""
631
+ rest = text[cursor:]
632
+ nl = rest.find("\n")
633
+ line_rest = rest if nl < 0 else rest[:nl]
634
+ return line_rest.strip() == ""
635
+
636
+
637
+ _WORD_RE = re.compile(r"[^\S\n]*(?:\n[^\S\n]*|[A-Za-z0-9_]+|[^\sA-Za-z0-9_]+)")
638
+
639
+
640
+ def first_word(chunk: str) -> str:
641
+ m = _WORD_RE.match(chunk)
642
+ return m.group(0) if m else chunk[:1]
643
+
644
+
645
+ # ──────────────────────────────────────────────────────────────────────────
646
+ # Per-editor state (injected: `fim_state: FimState = None` on draw_text)
647
+ # ──────────────────────────────────────────────────────────────────────────
648
+
649
+ @dataclass
650
+ class Ghost:
651
+ text: str # the visible chunk ("" while the first chunk streams in)
652
+ more_lines: int = 0 # content lines buffered beyond the chunk
653
+ pending: bool = False # a request is in flight
654
+ profile: str = ""
655
+
656
+
657
+ _live_states = weakref.WeakSet()
658
+
659
+
660
+ def _wake(ds):
661
+ """Wake the render loop once from a worker thread (tile invalidate +
662
+ glfw event) — the loop is parked in wait_events, and request_render
663
+ no-ops off the GL thread. Mirrors text_editor._wake_on_future."""
664
+ try:
665
+ tile = getattr(ds, "_tile_id", None) if ds is not None else None
666
+ if tile is not None:
667
+ _melty().cache.invalidate(tile)
668
+ except Exception:
669
+ pass
670
+ try:
671
+ import meltygui.core.windowing.glfw_utils as glfw_utils
672
+ glfw_utils._needs_render.set()
673
+ except Exception:
674
+ pass
675
+ try:
676
+ if getattr(_melty(), "vis", None) is not None: # a window exists (not headless)
677
+ import meltygui.core.windowing.window_api as glfw
678
+ glfw.post_empty_event()
679
+ except Exception:
680
+ pass
681
+
682
+
683
+ class FimState:
684
+ """Broker + ghost buffer for ONE editor. Constructed by render_func's
685
+ injected-state path (no-arg), bound to its draw_state via `_owner_ds`.
686
+
687
+ Virtual document model (buffer coordinates): `_vtext` = prefix-at-
688
+ request + everything the provider produced; `_vcursor` = how far into
689
+ it the real buffer has caught up (accept / prefix-consume move it
690
+ forward). The ghost buffer is `_vtext[_vcursor:]`. `_vsuffix` is the
691
+ suffix the completion was made for — any change to it invalidates the
692
+ generation."""
693
+ _owner_ds = None
694
+
695
+ def __init__(self):
696
+ self.profile_name = ""
697
+ self.session = None
698
+ self.error = None
699
+ self._lock = threading.RLock()
700
+ self._gen = 0 # increments on every invalidation
701
+ self._vtext = ""
702
+ self._vcursor = 0
703
+ self._vsuffix = None # None = no active generation
704
+ self._streaming = False
705
+ self._inflight = None # FimRequest in flight
706
+ self._inflight_origin = 0 # virtual offset the text started at
707
+ self._thread = None
708
+ self._timer = None
709
+ self._key = None # last scheduled request key
710
+ self._dismissed_key = None
711
+ self._exhausted = False # continuation returned nothing
712
+ self._last_wake = 0.0
713
+ self._wake_lines = 0
714
+ self._last_sweep = 0.0
715
+ self._session_profile = None # profile the session slot was resolved for
716
+ self._session_error = None
717
+ self._session_err_time = 0.0
718
+ self.alternatives = ()
719
+ self.context = None
720
+ self.context_key = None
721
+ self.context_time = 0.0
722
+ self.last_result = None
723
+ self.stats = {"requests": 0, "accepted_chunks": 0, "invalidations": 0}
724
+ _live_states.add(self)
725
+
726
+ def __reduce__(self):
727
+ return (FimState, ())
728
+
729
+ # ── profile / session ─────────────────────────────────────────────
730
+ def _ensure_profile(self, profile_name):
731
+ """Render thread: switch profiles (drop the old session ref and any
732
+ generation made with it). The new session is acquired lazily on the
733
+ worker thread by `_session_for_work`."""
734
+ if profile_name == self._session_profile:
735
+ return
736
+ release_session(self.session)
737
+ self.session = None
738
+ self._session_resolved = False
739
+ self.profile_name = profile_name
740
+ self._session_profile = profile_name
741
+ self._session_error = None
742
+ self._session_err_time = 0.0
743
+ self._key = None # re-request at the current site
744
+ if self.active or self._timer is not None:
745
+ self.invalidate("profile")
746
+
747
+ _session_resolved = False
748
+
749
+ def _session_for_work(self):
750
+ """Worker thread: the profile's pooled session (None for a
751
+ sessionless provider). A failed construction is re-raised for 5 s
752
+ without retrying the constructor."""
753
+ if self._session_resolved:
754
+ return self.session
755
+ if (self._session_error is not None
756
+ and time.monotonic() - self._session_err_time < 5.0):
757
+ raise RuntimeError(self._session_error)
758
+ try:
759
+ sess = acquire_session(self.profile_name)
760
+ except Exception as e:
761
+ self._session_error = str(e)
762
+ self._session_err_time = time.monotonic()
763
+ raise
764
+ self._session_error = None
765
+ self.session = sess
766
+ self._session_resolved = True
767
+ return sess
768
+
769
+ def _provider(self):
770
+ prof = profiles().get(self.profile_name)
771
+ if prof is None:
772
+ return None, {}
773
+ fn = providers().get(prof.provider)
774
+ if fn is None:
775
+ return None, {}
776
+ _, rkw = split_kwargs(fn, dict(prof.kwargs))
777
+ return fn, rkw
778
+
779
+ # ── buffer ─────────────────────────────────────────────────────────
780
+ @property
781
+ def buffer(self) -> str:
782
+ with self._lock:
783
+ return self._vtext[self._vcursor:] if self._vsuffix is not None else ""
784
+
785
+ @property
786
+ def active(self) -> bool:
787
+ return self._vsuffix is not None
788
+
789
+ def invalidate(self, reason=""):
790
+ """Drop the generation: cancel in-flight work, clear the buffer."""
791
+ with self._lock:
792
+ self._gen += 1
793
+ if self._inflight is not None:
794
+ self._inflight.cancelled.set()
795
+ self._inflight = None
796
+ self._vtext = ""
797
+ self._vcursor = 0
798
+ self._vsuffix = None
799
+ self._streaming = False
800
+ self._exhausted = False
801
+ self.alternatives = ()
802
+ self.stats["invalidations"] += 1
803
+ t = self._timer
804
+ self._timer = None
805
+ self._armed = None
806
+ if t is not None:
807
+ t.cancel()
808
+
809
+ def dismiss(self):
810
+ """Esc: drop the buffer and don't re-request at this site (the
811
+ caret's CURRENT site, which accepts may have moved past the last
812
+ scheduled one) until the caret moves on."""
813
+ self._dismissed_key = self._cur_key
814
+ self.invalidate("dismiss")
815
+
816
+ _cur_key = None
817
+
818
+ def _reconcile(self, text, cursor):
819
+ """Advance the virtual cursor when the real buffer typed/accepted
820
+ exactly what the ghost predicted; invalidate on any divergence."""
821
+ with self._lock:
822
+ if self._vsuffix is None:
823
+ return
824
+ prefix = text[:cursor]
825
+ if text[cursor:] != self._vsuffix:
826
+ self.invalidate("suffix")
827
+ return
828
+ n = len(prefix)
829
+ if n < self._vcursor:
830
+ self.invalidate("backspace")
831
+ return
832
+ if n > len(self._vtext):
833
+ # typed past anything produced so far (exhausted, or
834
+ # ahead of a stream) - a fresh request from here is cheap
835
+ self.invalidate("overrun")
836
+ return
837
+ if self._vtext[:n] != prefix:
838
+ self.invalidate("diverged")
839
+ return
840
+ self._vcursor = n
841
+
842
+ # ── scheduling ──────────────────────────────────────────────────
843
+ @staticmethod
844
+ def _request_key(text, cursor):
845
+ return (cursor, len(text), text[max(0, cursor - 48):cursor], text[cursor:cursor + 48])
846
+
847
+ def poll(self, text, cursor, *, view: EditorView, profile: str = "",
848
+ enabled=True, ds=None, now=None, typed=True) -> Ghost | None:
849
+ """Called from the editor body after the key handlers. Reconciles
850
+ the buffer with the real text, schedules/continues requests, and
851
+ returns what to draw (or None). `typed` = the buffer changed this
852
+ frame: only typing arms a fresh request — a caret move never does,
853
+ and one during the debounce cancels the armed request."""
854
+ from meltygui.core.runtime.toggles import Toggles
855
+ if ds is not None:
856
+ self._owner_ds = ds
857
+ if not enabled or not Toggles.Fim.enabled:
858
+ if self.active or self._timer is not None:
859
+ self.invalidate("disabled")
860
+ return None
861
+ now = time.monotonic() if now is None else now
862
+ self._ensure_profile(profile or Toggles.Fim.profile)
863
+ self._reconcile(text, cursor)
864
+ key = self._request_key(text, cursor)
865
+ self._cur_key = key
866
+ if key != self._dismissed_key:
867
+ self._dismissed_key = None
868
+ chunk_lines = max(1, Toggles.Fim.chunk_lines)
869
+ with self._lock:
870
+ chunk = self._chunk(chunk_lines)
871
+ ahead = content_lines(self.buffer[len(chunk):])
872
+ active = self.active
873
+ need_more = (active and not self._streaming and self._inflight is None
874
+ and not self._exhausted and Toggles.Fim.prefetch
875
+ and ahead < chunk_lines)
876
+ pending = self._inflight is not None
877
+ if not active and key != self._key and self._armed is not None:
878
+ # the site moved during the debounce: drop the armed request
879
+ self._armed = None
880
+ t = self._timer
881
+ self._timer = None
882
+ if t is not None:
883
+ t.cancel()
884
+ if not active:
885
+ if key != self._key:
886
+ self._key = key
887
+ # Only generate on typing, at a site the user hasn't seen,
888
+ # and (when only_at_line_end is on) with nothing but space
889
+ # after the cursor on this line - no mid-line completions.
890
+ if (typed and self._dismissed_key != key
891
+ and (not Toggles.Fim.only_at_line_end or at_line_end(text, cursor))):
892
+ self._schedule(text, cursor, view, Toggles.Fim.debounce_s, now)
893
+ elif self._fire_armed(text, cursor, view, now):
894
+ pending = True
895
+ elif need_more:
896
+ self._key = key
897
+ self._schedule(None, None, view, 0.0, now, continuation=True)
898
+ pending = True
899
+ if now - self._last_sweep > 30.0:
900
+ self._last_sweep = now
901
+ try:
902
+ sweep_sessions(now)
903
+ except Exception:
904
+ pass
905
+ if not chunk:
906
+ return Ghost("", 0, True, self.profile_name) if pending else None
907
+ return Ghost(chunk, ahead, pending, self.profile_name)
908
+
909
+ def _chunk(self, chunk_lines):
910
+ buf = self.buffer
911
+ if not buf:
912
+ return ""
913
+ # Hold back an unterminated final line ONLY while it is genuinely
914
+ # still streaming in - i.e. the in-flight request appends at (or
915
+ # before) the shown buffer's start (the initial request:
916
+ # _inflight_origin == _vcursor). A CONTINUATION appends beyond the
917
+ # already-shown buffer (_inflight_origin > _vcursor), so the visible
918
+ # first chunk is settled text and must never be held back: doing so
919
+ # blinked the ghost out for each continuation round trip (the
920
+ # appear → disappear → reappear flicker).
921
+ streaming_tail = self._streaming and self._inflight_origin <= self._vcursor
922
+ return split_chunk(buf, chunk_lines, complete_only=streaming_tail)
923
+
924
+ def _refresh_context(self, view, now):
925
+ if self.context is not None and not self._context_stale(view, now):
926
+ return
927
+ try:
928
+ self.context = assemble_context(view)
929
+ self.context_key = self.context.key
930
+ self.context_time = now
931
+ except Exception as e:
932
+ if self.context is None:
933
+ self.context = FimContext()
934
+ self.error = f"context: {e}"
935
+
936
+ def _schedule(self, text, cursor, view, delay, now, continuation=False):
937
+ """Arm (or fire) a request. With a delay the request is only ARMED:
938
+ a timer wakes the render loop when the debounce elapses and the
939
+ next `poll` (render thread — the roster is not thread-safe) does
940
+ the context assembly + submit. So a caret-move burst costs nothing
941
+ but timer churn; the heavy work runs once, when the user pauses.
942
+ Continuations (delay 0) assemble + submit right here."""
943
+ with self._lock:
944
+ t = self._timer
945
+ self._timer = None
946
+ if continuation:
947
+ if self._vsuffix is None:
948
+ return
949
+ text = self._vtext + self._vsuffix
950
+ cursor = len(self._vtext)
951
+ gen = self._gen
952
+ self._armed = None
953
+ if t is not None:
954
+ t.cancel()
955
+ if delay <= 0:
956
+ self._submit(text, cursor, view, gen, continuation)
957
+ return
958
+ self._armed = (gen, now + delay)
959
+ ds = self._owner_ds
960
+
961
+ def fire():
962
+ self._timer = None
963
+ _wake(ds)
964
+
965
+ timer = threading.Timer(delay, fire)
966
+ timer.daemon = True
967
+ self._timer = timer
968
+ timer.start()
969
+
970
+ _armed = None # (gen, due) of a debounced fresh request awaiting its poll
971
+
972
+ def _fire_armed(self, text, cursor, view, now):
973
+ """Submit the armed request if its debounce has elapsed (called
974
+ from poll with the CURRENT buffer — same key, so the same site)."""
975
+ armed = self._armed
976
+ if armed is None or self._timer is not None:
977
+ return False
978
+ gen, due = armed
979
+ if now < due:
980
+ return False
981
+ self._armed = None
982
+ if gen != self._gen:
983
+ return False
984
+ self._submit(text, cursor, view, gen, False)
985
+ return True
986
+
987
+ def _context_stale(self, view, now):
988
+ from meltygui.core.runtime.toggles import Toggles
989
+ try:
990
+ key = context_key_for(view)
991
+ except Exception:
992
+ return False
993
+ if key != self.context_key:
994
+ return True
995
+ return now - self.context_time > Toggles.Fim.stable_refresh_s
996
+
997
+ def _submit(self, text, cursor, view, gen, continuation):
998
+ """Start the worker for a request over the virtual buffer
999
+ (`text`/`cursor` in buffer coordinates)."""
1000
+ from meltygui.core.runtime.toggles import Toggles
1001
+ fn, rkw = self._provider()
1002
+ if fn is None:
1003
+ self.error = f"no provider for profile {self.profile_name!r}"
1004
+ return
1005
+ self._refresh_context(view, time.monotonic())
1006
+ head = view.file_head if view is not None else ""
1007
+ tail = view.file_tail if view is not None else ""
1008
+ with self._lock:
1009
+ if gen != self._gen or self._inflight is not None:
1010
+ return
1011
+ cancelled = threading.Event()
1012
+ if not continuation:
1013
+ self._vtext = text[:cursor]
1014
+ self._vcursor = cursor
1015
+ self._vsuffix = text[cursor:]
1016
+ self._exhausted = False
1017
+ origin = cursor
1018
+ self._inflight_origin = origin
1019
+ self._streaming = True
1020
+ req = FimRequest(
1021
+ path=view.path if view is not None else None,
1022
+ text=head + text + tail, cursor=len(head) + cursor,
1023
+ language=view.language if view is not None else "python",
1024
+ version=view.version if view is not None else None,
1025
+ context=self.context or FimContext(),
1026
+ max_tokens=Toggles.Fim.max_tokens,
1027
+ cancelled=cancelled,
1028
+ emit=lambda t, g=gen, o=origin: self._on_partial(g, o, t),
1029
+ span_start=view.span_start if view is not None else 0)
1030
+ self._inflight = req
1031
+ self.stats["requests"] += 1
1032
+ ds = self._owner_ds
1033
+
1034
+ def work():
1035
+ result = None
1036
+ err = None
1037
+ try:
1038
+ session = self._session_for_work()
1039
+ result = fn(req, session, **rkw)
1040
+ except Exception as e:
1041
+ err = e
1042
+ if not cancelled.is_set() and Toggles.Fim.debug_print:
1043
+ traceback.print_exc()
1044
+ self._on_done(gen, origin, req, result, err)
1045
+ _wake(ds)
1046
+
1047
+ th = threading.Thread(target=work, name=f"fim-{self.profile_name}", daemon=True)
1048
+ self._thread = th
1049
+ th.start()
1050
+
1051
+ # ── worker thread callbacks ────────────────────────────────────────
1052
+ def _on_partial(self, gen, origin, full_text):
1053
+ with self._lock:
1054
+ if gen != self._gen or self._vsuffix is None:
1055
+ return
1056
+ self._vtext = self._vtext[:origin] + full_text
1057
+ self._vcursor = min(self._vcursor, len(self._vtext))
1058
+ lines = full_text.count("\n")
1059
+ now = time.monotonic()
1060
+ if lines != self._wake_lines or now - self._last_wake > 0.15:
1061
+ self._wake_lines = lines
1062
+ self._last_wake = now
1063
+ _wake(self._owner_ds)
1064
+
1065
+ def _on_done(self, gen, origin, req, result, err):
1066
+ with self._lock:
1067
+ if self._inflight is req:
1068
+ self._inflight = None
1069
+ if gen != self._gen or self._vsuffix is None:
1070
+ return
1071
+ self._streaming = False
1072
+ if err is not None:
1073
+ self.error = f"{self.profile_name}: {err}"
1074
+ self._exhausted = True
1075
+ return
1076
+ self.error = None
1077
+ text = result.text if result is not None else ""
1078
+ self.last_result = result
1079
+ self.alternatives = tuple(result.alternatives) if result is not None else ()
1080
+ self._vtext = self._vtext[:origin] + text
1081
+ self._vcursor = min(self._vcursor, len(self._vtext))
1082
+ # A continuation is fetched ONLY when the model was cut off by the
1083
+ # token limit (truncated). A natural stop-text end - or an empty
1084
+ # reply - is the end: don't queue another request behind it.
1085
+ if not text or not getattr(result, "truncated", False):
1086
+ self._exhausted = True
1087
+ fn, _ = self._provider()
1088
+ hook = getattr(fn, "_on_shown", None)
1089
+ if hook is not None and result is not None:
1090
+ try:
1091
+ hook(req, result)
1092
+ except Exception:
1093
+ pass
1094
+
1095
+ # ── acceptance ─────────────────────────────────────────────────────
1096
+ def accept(self, mode="chunk") -> str:
1097
+ """The text the editor should splice at the caret ("" if nothing).
1098
+ Advances the buffer; the editor then moves its caret by len()."""
1099
+ from meltygui.core.runtime.toggles import Toggles
1100
+ with self._lock:
1101
+ chunk = self._chunk(max(1, Toggles.Fim.chunk_lines))
1102
+ if not chunk:
1103
+ return ""
1104
+ if mode == "word":
1105
+ chunk = first_word(chunk)
1106
+ elif mode == "line":
1107
+ nl = chunk.find("\n")
1108
+ chunk = chunk if nl < 0 else chunk[:nl + 1]
1109
+ elif mode == "all" and not self._streaming:
1110
+ chunk = strip_cursor_tail(self.buffer) or chunk
1111
+ self._vcursor += len(chunk)
1112
+ self.stats["accepted_chunks"] += 1
1113
+ accepted = self._vcursor - self._inflight_origin
1114
+ fn, _ = self._provider()
1115
+ hook = getattr(fn, "_on_accept", None)
1116
+ if hook is not None and self.last_result is not None:
1117
+ try:
1118
+ hook(self.last_result, accepted)
1119
+ except Exception:
1120
+ pass
1121
+ return chunk
1122
+
1123
+ # ── lifecycle ────────────────────────────────────────────────────
1124
+ def release(self):
1125
+ self.invalidate("release")
1126
+ release_session(self.session)
1127
+ self.session = None
1128
+ self._session_resolved = False
1129
+ self._session_profile = None
1130
+ self._session_error = None
1131
+ self.profile_name = ""
1132
+ self.context = None
1133
+
1134
+ @classmethod
1135
+ def on_window_deleted(cls, window_ds):
1136
+ """GLState.on_window_deleted's twin: release every state owned by a
1137
+ draw_state under the deleted window."""
1138
+ if window_ds is None:
1139
+ return
1140
+ for state in list(_live_states):
1141
+ node = state._owner_ds
1142
+ hops = 0
1143
+ while node is not None and hops < 64:
1144
+ if node is window_ds:
1145
+ state.release()
1146
+ break
1147
+ nxt = getattr(node, "parent_window", None)
1148
+ if nxt is None or nxt is node:
1149
+ break
1150
+ node = nxt
1151
+ hops += 1
1152
+
1153
+ @classmethod
1154
+ def shutdown_all(cls):
1155
+ """Called from Melty.cleanup — which runs on an in-process RESTART,
1156
+ not just real exit. So it does NOT close the pooled sessions (they
1157
+ live on `sys` and are reused next generation): it only cancels the
1158
+ current generation's in-flight requests and detaches the sessions so
1159
+ the next generation re-acquires them instead of re-spawning /
1160
+ re-logging-in. Sessions are closed only by the idle sweep, an
1161
+ explicit restart_session / credential change (drop_sessions), or the
1162
+ Copilot LS self-terminating on process exit."""
1163
+ for state in list(_live_states):
1164
+ try:
1165
+ state.invalidate("shutdown") # cancel in-flight, keep the session
1166
+ except Exception:
1167
+ pass
1168
+ detach_sessions_for_restart()
1169
+
1170
+
1171
+ # ──────────────────────────────────────────────────────────────────────────
1172
+ # Context assembly (sources live in fim_context.py; this is the fitter)
1173
+ # ──────────────────────────────────────────────────────────────────────────
1174
+
1175
+ def context_key_for(view: EditorView) -> tuple:
1176
+ """What stable-tier membership is keyed on: the file, its pending gen,
1177
+ and the enclosing def of the caret (its line in the buffer) — NOT the
1178
+ caret itself, so typing inside one function reuses the prefix."""
1179
+ from meltygui.completion.fim_context import enclosing_def_line
1180
+ return (view.path, view.version, enclosing_def_line(view.text, view.cursor))
1181
+
1182
+
1183
+ _TIER_SHARE = {"volatile": 0.15, "run": 0.25} # stable takes the remainder
1184
+
1185
+
1186
+ def assemble_context(view: EditorView, budget_tokens=None) -> FimContext:
1187
+ """Run every registered context source, dedupe by key, fit each tier to
1188
+ its budget share by score (degrading definition → signature before
1189
+ dropping), and order deterministically within a tier (source order,
1190
+ then key). Stored order is prompt order: stable, run, volatile."""
1191
+ from meltygui.core.runtime.toggles import Toggles
1192
+ import meltygui.completion.fim_context # noqa: F401 (registers the built-in sources)
1193
+ if budget_tokens is None:
1194
+ budget_tokens = Toggles.Fim.context_tokens
1195
+ view.resolve_fn()
1196
+ seen = {}
1197
+ order_of = {}
1198
+ for kind, src in context_sources().items():
1199
+ order_of[kind] = getattr(src, "_fim_order", 100)
1200
+ try:
1201
+ items = list(src(view) or ())
1202
+ except Exception as e:
1203
+ if Toggles.Fim.debug_print:
1204
+ print(f"[fim] context source {kind} failed: {e}")
1205
+ continue
1206
+ for it in items:
1207
+ prev = seen.get(it.key)
1208
+ if prev is None or it.score > prev.score:
1209
+ seen[it.key] = it
1210
+ items = list(seen.values())
1211
+ kept = []
1212
+ spent = 0
1213
+ for tier in ("volatile", "run", "stable"):
1214
+ cap = int(budget_tokens * _TIER_SHARE[tier]) if tier in _TIER_SHARE else max(0, budget_tokens - spent)
1215
+ ranked = sorted((it for it in items if it.tier == tier), key=lambda it: -it.score)
1216
+ used = 0
1217
+ for it in ranked:
1218
+ t = it.tokens
1219
+ if used + t <= cap:
1220
+ kept.append(it)
1221
+ used += t
1222
+ continue
1223
+ if it.kind == "definition" and isinstance(it.data, str) and it.data:
1224
+ sig = ContextItem("signature", it.key, it.data, it.tier, it.score,
1225
+ it.path, it.line, it.line, it.version)
1226
+ if used + sig.tokens <= cap:
1227
+ kept.append(sig)
1228
+ used += sig.tokens
1229
+ spent += used
1230
+ kept.sort(key=lambda it: (TIERS.index(it.tier) if it.tier in TIERS else 9,
1231
+ order_of.get(it.kind, 100), repr(it.key)))
1232
+ return FimContext(kept, context_key_for(view))