railmux 0.3.3.dev2__tar.gz → 0.4.0.dev2__tar.gz

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 (197) hide show
  1. {railmux-0.3.3.dev2/src/railmux.egg-info → railmux-0.4.0.dev2}/PKG-INFO +5 -1
  2. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/pyproject.toml +4 -0
  3. railmux-0.4.0.dev2/src/railmux/__init__.py +1 -0
  4. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/cli.py +40 -2
  5. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/codex_index.py +6 -15
  6. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/config.py +3 -1
  7. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/discovery.py +66 -7
  8. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/display_transport.py +0 -18
  9. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/fast_display_client.py +11 -24
  10. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/fast_display_server.py +8 -35
  11. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/favorites.py +2 -1
  12. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/help_workspace.py +2 -7
  13. railmux-0.4.0.dev2/src/railmux/local_clipboard.py +101 -0
  14. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/local_open.py +37 -10
  15. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/modes.py +8 -0
  16. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/mouse_manager.py +7 -3
  17. railmux-0.4.0.dev2/src/railmux/mux/__init__.py +7 -0
  18. railmux-0.4.0.dev2/src/railmux/mux/backend.py +75 -0
  19. railmux-0.4.0.dev2/src/railmux/mux/tmux_backend.py +165 -0
  20. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/orphan_marker.py +9 -14
  21. railmux-0.4.0.dev2/src/railmux/platform/__init__.py +44 -0
  22. railmux-0.4.0.dev2/src/railmux/platform/config_paths.py +30 -0
  23. railmux-0.4.0.dev2/src/railmux/platform/console.py +165 -0
  24. railmux-0.4.0.dev2/src/railmux/platform/file_security.py +34 -0
  25. railmux-0.4.0.dev2/src/railmux/platform/filelock.py +42 -0
  26. railmux-0.4.0.dev2/src/railmux/platform/multiplex.py +132 -0
  27. railmux-0.4.0.dev2/src/railmux/platform/process.py +21 -0
  28. railmux-0.4.0.dev2/src/railmux/platform/runtime_paths.py +75 -0
  29. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/renames.py +2 -1
  30. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/restart_state.py +7 -12
  31. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/runtime_config.py +4 -1
  32. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/scroll_manager.py +3 -5
  33. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ssh_display_diagnostics.py +3 -5
  34. railmux-0.4.0.dev2/src/railmux/terminal_emulator.py +180 -0
  35. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/tmux_binding_manager.py +7 -3
  36. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ui/app.py +336 -273
  37. railmux-0.4.0.dev2/src/railmux/ui/status_chrome.py +149 -0
  38. railmux-0.4.0.dev2/src/railmux/winlocal/__init__.py +2 -0
  39. railmux-0.4.0.dev2/src/railmux/winlocal/backend.py +1088 -0
  40. railmux-0.4.0.dev2/src/railmux/winlocal/client.py +299 -0
  41. railmux-0.4.0.dev2/src/railmux/winlocal/compositor.py +287 -0
  42. railmux-0.4.0.dev2/src/railmux/winlocal/conpty.py +200 -0
  43. railmux-0.4.0.dev2/src/railmux/winlocal/daemon.py +398 -0
  44. railmux-0.4.0.dev2/src/railmux/winlocal/history_viewer.py +93 -0
  45. railmux-0.4.0.dev2/src/railmux/winlocal/ipc.py +88 -0
  46. railmux-0.4.0.dev2/src/railmux/winlocal/session_store.py +96 -0
  47. railmux-0.4.0.dev2/src/railmux/winlocal/virtual_screen.py +105 -0
  48. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2/src/railmux.egg-info}/PKG-INFO +5 -1
  49. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux.egg-info/SOURCES.txt +36 -1
  50. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux.egg-info/requires.txt +4 -0
  51. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_cli.py +15 -0
  52. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_codex_delete.py +7 -3
  53. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_config.py +1 -0
  54. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_discovery.py +24 -0
  55. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_display_transport.py +1 -21
  56. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_local_open.py +26 -0
  57. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_modes.py +2 -0
  58. railmux-0.4.0.dev2/tests/test_mux_backend.py +60 -0
  59. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_orphan_recovery.py +2 -2
  60. railmux-0.4.0.dev2/tests/test_platform.py +248 -0
  61. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_release_workflow.py +9 -0
  62. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_runtime_config.py +12 -0
  63. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_soft_quit.py +2 -41
  64. railmux-0.4.0.dev2/tests/test_status_chrome.py +87 -0
  65. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_tmux_integration.py +2 -9
  66. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_wheel_smoke.py +6 -1
  67. railmux-0.4.0.dev2/tests/test_windows_parity_contract.py +79 -0
  68. railmux-0.4.0.dev2/tests/test_winlocal_backend.py +577 -0
  69. railmux-0.4.0.dev2/tests/test_winlocal_compositor.py +67 -0
  70. railmux-0.4.0.dev2/tests/test_winlocal_conpty.py +60 -0
  71. railmux-0.4.0.dev2/tests/test_winlocal_daemon.py +203 -0
  72. railmux-0.4.0.dev2/tests/test_winlocal_history_viewer.py +43 -0
  73. railmux-0.4.0.dev2/tests/test_winlocal_ipc.py +39 -0
  74. railmux-0.4.0.dev2/tests/test_winlocal_real_conpty.py +94 -0
  75. railmux-0.4.0.dev2/tests/test_winlocal_session_store.py +42 -0
  76. railmux-0.4.0.dev2/tests/test_winlocal_virtual_screen.py +62 -0
  77. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tools/wheel_smoke.py +12 -6
  78. railmux-0.3.3.dev2/src/railmux/__init__.py +0 -1
  79. railmux-0.3.3.dev2/src/railmux/local_clipboard.py +0 -47
  80. railmux-0.3.3.dev2/tests/test_utf8_state.py +0 -111
  81. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/LICENSE +0 -0
  82. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/MANIFEST.in +0 -0
  83. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/README.md +0 -0
  84. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/setup.cfg +0 -0
  85. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/__main__.py +0 -0
  86. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/atomic_file.py +0 -0
  87. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/background_index.py +0 -0
  88. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/config_cli.py +0 -0
  89. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/diagnostics.py +0 -0
  90. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/fast_display_history.py +0 -0
  91. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/fast_display_input.py +0 -0
  92. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/fast_display_protocol.py +0 -0
  93. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/fuzzy.py +0 -0
  94. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/launcher.py +0 -0
  95. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/legacy_sessions.py +0 -0
  96. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/models.py +0 -0
  97. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/pane_surface.py +0 -0
  98. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/path_codec.py +0 -0
  99. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/remote_config.py +0 -0
  100. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/scroll_agent.py +0 -0
  101. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/selection_isolation.py +0 -0
  102. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/self_update.py +0 -0
  103. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/session_cache.py +0 -0
  104. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/session_index.py +0 -0
  105. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/setting_contracts.py +0 -0
  106. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/settings.py +0 -0
  107. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ssh_args.py +0 -0
  108. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ssh_compat.py +0 -0
  109. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ssh_doctor.py +0 -0
  110. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/system_deps.py +0 -0
  111. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/terminal_status.py +0 -0
  112. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/tmux_ctl.py +0 -0
  113. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/tmux_health.py +0 -0
  114. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/tmux_server.py +0 -0
  115. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/tool_panes.py +0 -0
  116. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/transcript.py +0 -0
  117. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ui/__init__.py +0 -0
  118. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ui/_widgets.py +0 -0
  119. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ui/keymap.py +0 -0
  120. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ui/modals.py +0 -0
  121. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ui/projects_pane.py +0 -0
  122. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ui/running_pane.py +0 -0
  123. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ui/sessions_pane.py +0 -0
  124. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ui/sidebar.py +0 -0
  125. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ui/statusbar.py +0 -0
  126. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux/ui/workspace.py +0 -0
  127. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux.egg-info/dependency_links.txt +0 -0
  128. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux.egg-info/entry_points.txt +0 -0
  129. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/src/railmux.egg-info/top_level.txt +0 -0
  130. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_agent_workspace.py +0 -0
  131. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_atomic_file.py +0 -0
  132. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_background_index.py +0 -0
  133. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_codex_index.py +0 -0
  134. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_codex_lineage.py +0 -0
  135. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_codex_transcript.py +0 -0
  136. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_config_cli.py +0 -0
  137. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_denested_transport_benchmark.py +0 -0
  138. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_diagnostics.py +0 -0
  139. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_fast_full_window.py +0 -0
  140. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_focus_highlight.py +0 -0
  141. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_fullscreen_binding.py +0 -0
  142. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_fuzzy.py +0 -0
  143. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_help_agent.py +0 -0
  144. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_help_workspace.py +0 -0
  145. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_history_cleanup.py +0 -0
  146. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_keymap.py +0 -0
  147. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_launcher.py +0 -0
  148. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_layout_profile.py +0 -0
  149. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_legacy_sessions.py +0 -0
  150. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_local_clipboard.py +0 -0
  151. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_modals.py +0 -0
  152. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_mode_selection.py +0 -0
  153. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_mouse_manager.py +0 -0
  154. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_new_project.py +0 -0
  155. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_options.py +0 -0
  156. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_pane_surface.py +0 -0
  157. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_path_codec.py +0 -0
  158. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_projects_pane.py +0 -0
  159. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_release_notes.py +0 -0
  160. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_remote_config.py +0 -0
  161. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_renames.py +0 -0
  162. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_restart_state.py +0 -0
  163. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_running_pane.py +0 -0
  164. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_running_sort.py +0 -0
  165. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_scroll_agent.py +0 -0
  166. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_scroll_manager.py +0 -0
  167. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_selection_isolation.py +0 -0
  168. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_self_update.py +0 -0
  169. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_session_cache.py +0 -0
  170. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_session_index.py +0 -0
  171. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_sessions_pane.py +0 -0
  172. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_setting_contracts.py +0 -0
  173. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_settings.py +0 -0
  174. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_sidebar.py +0 -0
  175. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_ssh_args.py +0 -0
  176. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_ssh_compat.py +0 -0
  177. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_ssh_display_benchmark.py +0 -0
  178. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_ssh_display_diagnostics.py +0 -0
  179. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_ssh_doctor.py +0 -0
  180. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_status.py +0 -0
  181. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_statusbar.py +0 -0
  182. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_system_deps.py +0 -0
  183. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_terminal_size.py +0 -0
  184. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_terminal_status.py +0 -0
  185. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_termux_local_touch.py +0 -0
  186. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_tmux_binding_manager.py +0 -0
  187. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_tmux_ctl.py +0 -0
  188. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_tmux_health.py +0 -0
  189. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_tmux_server.py +0 -0
  190. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_tmux_status.py +0 -0
  191. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_tool_panes.py +0 -0
  192. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_transcript.py +0 -0
  193. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_web_demo_recorder.py +0 -0
  194. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_widgets.py +0 -0
  195. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tests/test_yolo_prompt.py +0 -0
  196. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tools/denested_transport_benchmark.py +0 -0
  197. {railmux-0.3.3.dev2 → railmux-0.4.0.dev2}/tools/release_notes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.3.3.dev2
3
+ Version: 0.4.0.dev2
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -14,12 +14,14 @@ Classifier: Environment :: Console :: Curses
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: Operating System :: POSIX :: Linux
16
16
  Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: Microsoft :: Windows
17
18
  Classifier: Programming Language :: Python :: 3
18
19
  Classifier: Programming Language :: Python :: 3.9
19
20
  Classifier: Programming Language :: Python :: 3.10
20
21
  Classifier: Programming Language :: Python :: 3.11
21
22
  Classifier: Programming Language :: Python :: 3.12
22
23
  Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
23
25
  Classifier: Topic :: Software Development
24
26
  Classifier: Topic :: Terminals
25
27
  Classifier: Topic :: Utilities
@@ -30,6 +32,8 @@ Requires-Dist: packaging>=23.0
30
32
  Requires-Dist: tomlkit<1,>=0.13.2
31
33
  Requires-Dist: urwid>=2.6.16
32
34
  Requires-Dist: tomli>=2.0.1; python_version < "3.11"
35
+ Requires-Dist: pyte<0.9,>=0.8.2; sys_platform == "win32"
36
+ Requires-Dist: pywinpty<4,>=3.0.5; sys_platform == "win32"
33
37
  Provides-Extra: ssh
34
38
  Requires-Dist: pyte<0.9,>=0.8.2; extra == "ssh"
35
39
  Provides-Extra: dev
@@ -20,12 +20,14 @@ classifiers = [
20
20
  "Intended Audience :: Developers",
21
21
  "Operating System :: POSIX :: Linux",
22
22
  "Operating System :: MacOS",
23
+ "Operating System :: Microsoft :: Windows",
23
24
  "Programming Language :: Python :: 3",
24
25
  "Programming Language :: Python :: 3.9",
25
26
  "Programming Language :: Python :: 3.10",
26
27
  "Programming Language :: Python :: 3.11",
27
28
  "Programming Language :: Python :: 3.12",
28
29
  "Programming Language :: Python :: 3.13",
30
+ "Programming Language :: Python :: 3.14",
29
31
  "Topic :: Software Development",
30
32
  "Topic :: Terminals",
31
33
  "Topic :: Utilities",
@@ -35,6 +37,8 @@ dependencies = [
35
37
  "tomlkit>=0.13.2,<1",
36
38
  "urwid>=2.6.16",
37
39
  "tomli>=2.0.1; python_version < '3.11'",
40
+ "pyte>=0.8.2,<0.9; sys_platform == 'win32'",
41
+ "pywinpty>=3.0.5,<4; sys_platform == 'win32'",
38
42
  ]
39
43
 
40
44
  [project.optional-dependencies]
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0.dev2"
@@ -1,18 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import os
4
5
  import shutil
5
6
  import subprocess
6
7
  import sys
7
- import termios
8
8
  import time
9
9
  from dataclasses import replace
10
10
  from pathlib import Path
11
11
 
12
12
  from railmux import __version__
13
13
  from railmux.config import ConfigError, default_config_path, load_config
14
- from railmux.diagnostics import is_ssh_session, run_doctor
15
14
  from railmux.pane_surface import render_startup_surface
15
+ from railmux.platform import python_support_error
16
16
  from railmux.runtime_config import (
17
17
  activate_runtime_environment,
18
18
  check_executable,
@@ -37,9 +37,29 @@ _LOCAL_WATCHDOG_INTERVAL = 5.0
37
37
  _LOCAL_WATCHDOG_FAILURES = 3
38
38
 
39
39
 
40
+ def _native_windows() -> bool:
41
+ return os.name == "nt"
42
+
43
+
44
+ def is_ssh_session(environment: dict[str, str] | None = None) -> bool:
45
+ """Lazy compatibility wrapper that keeps POSIX diagnostics import-clean."""
46
+ from railmux.diagnostics import is_ssh_session as detect
47
+
48
+ return detect(environment)
49
+
50
+
51
+ def run_doctor(*args: object, **kwargs: object) -> int:
52
+ """Lazy compatibility wrapper for the established CLI test/repair hook."""
53
+ from railmux.diagnostics import run_doctor as doctor
54
+
55
+ return doctor(*args, **kwargs)
56
+
57
+
40
58
  def _restore_terminal(attributes: list | None) -> None:
41
59
  if attributes is None:
42
60
  return
61
+ import termios
62
+
43
63
  try:
44
64
  termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, attributes)
45
65
  except (OSError, termios.error):
@@ -67,6 +87,8 @@ def _run_tmux_client_with_watchdog(
67
87
  """Keep one monitor outside tmux so a frozen server cannot trap the TTY."""
68
88
  attributes = None
69
89
  if sys.stdin.isatty():
90
+ import termios
91
+
70
92
  try:
71
93
  attributes = termios.tcgetattr(sys.stdin.fileno())
72
94
  except (OSError, termios.error):
@@ -152,6 +174,10 @@ def _run_tmux_client_with_watchdog(
152
174
 
153
175
 
154
176
  def main(argv: list[str] | None = None) -> int:
177
+ support_error = python_support_error()
178
+ if support_error is not None:
179
+ print(f"error: {support_error}", file=sys.stderr)
180
+ return 2
155
181
  raw_args = list(sys.argv[1:] if argv is None else argv)
156
182
  if raw_args and raw_args[0] == "config":
157
183
  from railmux.config_cli import main as config_main
@@ -338,6 +364,18 @@ def main(argv: list[str] | None = None) -> int:
338
364
  file=sys.stderr,
339
365
  )
340
366
  return 2
367
+ if _native_windows():
368
+ # Native Windows owns no local tmux server. The detached daemon keeps
369
+ # ConPTY handles alive while this terminal remains a disposable client.
370
+ activate_runtime_environment(replace(config, tmux_binary="tmux"))
371
+ if not args.inside_tmux:
372
+ from railmux.self_update import maybe_upgrade_before_launch
373
+ from railmux.settings import Settings
374
+
375
+ maybe_upgrade_before_launch(raw_args, Settings())
376
+ from railmux.winlocal.client import main as native_main
377
+
378
+ return native_main(raw_args, config=config)
341
379
  if config.tmux_binary != "tmux":
342
380
  tmux_check = check_executable("tmux", config.tmux_binary)
343
381
  if not tmux_check.valid:
@@ -13,12 +13,15 @@ import hashlib
13
13
  import json
14
14
  import math
15
15
  import os
16
- import stat
17
16
  import time
18
17
  from dataclasses import dataclass, replace
19
18
  from pathlib import Path
20
19
 
21
20
  from railmux.atomic_file import atomic_write_text
21
+ from railmux.platform.file_security import (
22
+ prepare_private_directory,
23
+ private_regular_file,
24
+ )
22
25
  from railmux.models import (
23
26
  AttentionCategory,
24
27
  AttentionState,
@@ -457,9 +460,7 @@ class CodexIndex:
457
460
  try:
458
461
  info = path.lstat()
459
462
  if (
460
- not stat.S_ISREG(info.st_mode)
461
- or info.st_uid != os.getuid()
462
- or info.st_mode & 0o077
463
+ not private_regular_file(info)
463
464
  or info.st_size > _PERSISTENT_CACHE_MAX_BYTES
464
465
  ):
465
466
  return
@@ -514,17 +515,7 @@ class CodexIndex:
514
515
  path = self._cache_path
515
516
  if path is None:
516
517
  return False
517
- parent = path.parent
518
- try:
519
- parent.mkdir(mode=0o700, parents=True, exist_ok=True)
520
- info = parent.lstat()
521
- if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid():
522
- return False
523
- if info.st_mode & 0o077:
524
- os.chmod(parent, stat.S_IMODE(info.st_mode) & ~0o077)
525
- return True
526
- except OSError:
527
- return False
518
+ return prepare_private_directory(path.parent)
528
519
 
529
520
  def _save_persistent_cache(self) -> None:
530
521
  path = self._cache_path
@@ -6,6 +6,8 @@ from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
  from typing import Any
8
8
 
9
+ from railmux.platform.config_paths import default_config_path as _platform_config_path
10
+
9
11
  try:
10
12
  import tomllib
11
13
  except ModuleNotFoundError: # Python 3.9-3.10
@@ -55,7 +57,7 @@ class Config:
55
57
 
56
58
 
57
59
  def default_config_path() -> Path:
58
- return Path.home() / ".config" / "railmux" / "config.toml"
60
+ return _platform_config_path()
59
61
 
60
62
 
61
63
  def _table(data: dict[str, Any], name: str) -> dict[str, Any]:
@@ -7,7 +7,8 @@ from pathlib import Path
7
7
 
8
8
  from railmux.atomic_file import atomic_write_text
9
9
  from railmux.models import Project
10
- from railmux.path_codec import decode
10
+ from railmux.path_codec import _claude_encode_path, decode
11
+ from railmux.platform.config_paths import config_dir
11
12
  from railmux.session_index import _looks_like_uuid, _scan_session
12
13
 
13
14
 
@@ -37,7 +38,7 @@ def _path_cache_file() -> Path:
37
38
  Decoding an encoded project name is expensive on an NFS home (see
38
39
  path_codec), and the mapping is stable, so we persist it across launches.
39
40
  """
40
- return Path.home() / ".config" / "railmux" / "path-cache.json"
41
+ return config_dir() / "path-cache.json"
41
42
 
42
43
 
43
44
  def _load_path_cache() -> dict[str, str]:
@@ -94,7 +95,7 @@ def list_projects(claude_home: Path) -> list[Project]:
94
95
  for entry in scan:
95
96
  if not entry.is_dir(follow_symlinks=False):
96
97
  continue
97
- if not entry.name.startswith("-"):
98
+ if os.name != "nt" and not entry.name.startswith("-"):
98
99
  continue
99
100
  seen.add(entry.name)
100
101
 
@@ -106,10 +107,16 @@ def list_projects(claude_home: Path) -> list[Project]:
106
107
  if cached is not None and Path(cached).is_dir():
107
108
  real_path = Path(cached)
108
109
  else:
109
- try:
110
- decoded = decode(entry.name)
111
- except ValueError:
112
- decoded = None
110
+ decoded = None
111
+ if os.name == "nt":
112
+ decoded = _windows_project_path_from_transcript(
113
+ Path(entry.path), entry.name
114
+ )
115
+ else:
116
+ try:
117
+ decoded = decode(entry.name)
118
+ except ValueError:
119
+ pass
113
120
  # Skip projects whose original directory no longer exists on
114
121
  # this machine (deleted or moved). They can't be launched —
115
122
  # `cd` into a missing dir fails — so don't list them, and drop
@@ -156,6 +163,58 @@ def list_projects(claude_home: Path) -> list[Project]:
156
163
  return results
157
164
 
158
165
 
166
+ def _windows_project_path_from_transcript(
167
+ claude_dir: Path,
168
+ encoded_name: str,
169
+ ) -> Path | None:
170
+ """Recover a Windows project cwd from provider-owned transcript metadata.
171
+
172
+ Drive letters and UNC roots do not fit the POSIX path backtracker. Claude
173
+ records the exact cwd in its JSONL, so validate that bounded value against
174
+ the enclosing encoded directory instead of guessing a Windows path.
175
+ """
176
+ try:
177
+ candidates = sorted(
178
+ claude_dir.glob("*.jsonl"),
179
+ key=lambda path: path.stat().st_mtime_ns,
180
+ reverse=True,
181
+ )[:8]
182
+ except OSError:
183
+ return None
184
+ for path in candidates:
185
+ try:
186
+ with path.open("r", encoding="utf-8", errors="replace") as stream:
187
+ for _line_number in range(64):
188
+ line = stream.readline(64 * 1024 + 1)
189
+ if not line:
190
+ break
191
+ if len(line) > 64 * 1024:
192
+ continue
193
+ try:
194
+ record = json.loads(line)
195
+ except (json.JSONDecodeError, ValueError):
196
+ continue
197
+ raw_cwd = record.get("cwd") if isinstance(record, dict) else None
198
+ if (
199
+ not isinstance(raw_cwd, str)
200
+ or not raw_cwd
201
+ or len(raw_cwd) > 32768
202
+ or "\0" in raw_cwd
203
+ ):
204
+ continue
205
+ candidate = Path(raw_cwd)
206
+ if (
207
+ candidate.is_absolute()
208
+ and candidate.is_dir()
209
+ and _claude_encode_path(str(candidate)).casefold()
210
+ == encoded_name.casefold()
211
+ ):
212
+ return candidate
213
+ except OSError:
214
+ continue
215
+ return None
216
+
217
+
159
218
  def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, float]:
160
219
  """Count sessions that pass every ``_scan_session`` filter and return the max
161
220
  JSONL mtime. Uses ``_scan_session`` so the count exactly matches the
@@ -738,22 +738,6 @@ class AgentDisplayTransport:
738
738
  def _with_phase(state: SwapState, phase: str) -> SwapState:
739
739
  return SwapState(**{**asdict(state), "phase": phase})
740
740
 
741
- @staticmethod
742
- def _fit_home_to_display(state: SwapState) -> None:
743
- """Best-effort preserve the provider's visible geometry at home.
744
-
745
- While a real pane is displayed, its named session contains only the
746
- inert placeholder. Resize that detached home window to the real
747
- pane's current size before swapping back. Otherwise background output
748
- is formatted at the placeholder's older (often much narrower) launch
749
- geometry and later appears as a narrow discontinuity in scrollback.
750
-
751
- Geometry is presentation-only: failure must never weaken or abort the
752
- identity-pinned return transaction.
753
- """
754
- tmux_ctl.fit_session_to_pane(
755
- state.agent_tmux_name, state.agent_pane_id)
756
-
757
741
  def park(self, slot: AgentSlot) -> bool:
758
742
  """Move one displayed swap agent home without forgetting its slot.
759
743
 
@@ -780,7 +764,6 @@ class AgentDisplayTransport:
780
764
  parking = self._with_phase(state, "parking")
781
765
  if not _write_marker_pair(parking, slot.key):
782
766
  return False
783
- self._fit_home_to_display(state)
784
767
  if not tmux_ctl.swap_panes(
785
768
  state.agent_pane_id, state.placeholder_pane_id):
786
769
  _write_marker_pair(state, slot.key)
@@ -874,7 +857,6 @@ class AgentDisplayTransport:
874
857
  return False
875
858
  returning = SwapState(**{**asdict(state), "phase": "returning"})
876
859
  _write_marker_pair(returning, slot.key)
877
- self._fit_home_to_display(state)
878
860
  if not tmux_ctl.swap_panes(
879
861
  state.agent_pane_id, state.placeholder_pane_id):
880
862
  return False
@@ -14,15 +14,12 @@ import base64
14
14
  import json
15
15
  import os
16
16
  import re
17
- import select
18
17
  import selectors
19
18
  import shlex
20
19
  import shutil
21
20
  import subprocess
22
21
  import sys
23
- import termios
24
22
  import time
25
- import tty
26
23
  from collections.abc import Callable
27
24
  from dataclasses import dataclass
28
25
  from enum import Enum
@@ -81,6 +78,8 @@ from railmux.fast_display_protocol import (
81
78
  encode_path_open_request,
82
79
  encode_resize,
83
80
  )
81
+ from railmux.platform.console import RawConsole
82
+ from railmux.platform.multiplex import PortableSelector, wait_readable
84
83
  from railmux.pane_surface import render_startup_surface
85
84
  from railmux.ssh_compat import CompatibilityFacts, decide as decide_compatibility
86
85
  from railmux.ssh_args import AppendSshArgument, ExtendSshArguments
@@ -320,7 +319,7 @@ def await_remote_attach_status(
320
319
  readers = [process.stdout.fileno()]
321
320
  if cancel_fd is not None:
322
321
  readers.append(cancel_fd)
323
- readable, _writable, _exceptional = select.select(readers, [], [], remaining)
322
+ readable = wait_readable(readers, remaining)
324
323
  if not readable:
325
324
  return RemoteAttachKind.TIMEOUT
326
325
  if cancel_fd is not None and cancel_fd in readable:
@@ -416,7 +415,7 @@ def await_remote_startup(
416
415
  readers = [process.stdout.fileno()]
417
416
  if cancel_fd is not None:
418
417
  readers.append(cancel_fd)
419
- readable, _writable, _exceptional = select.select(readers, [], [], remaining)
418
+ readable = wait_readable(readers, remaining)
420
419
  if not readable:
421
420
  return RemoteStartup(RemoteStartKind.TIMEOUT)
422
421
  if cancel_fd is not None and cancel_fd in readable:
@@ -603,20 +602,8 @@ def _is_soft_keyboard_projection(
603
602
  )
604
603
 
605
604
 
606
- class RawTerminal:
607
- def __init__(self, fd: int) -> None:
608
- self.fd = fd
609
- self.saved: Optional[list[object]] = None
610
-
611
- def __enter__(self) -> "RawTerminal":
612
- self.saved = termios.tcgetattr(self.fd)
613
- tty.setraw(self.fd)
614
- return self
615
-
616
- def __exit__(self, *_exc: object) -> None:
617
- if self.saved is not None:
618
- termios.tcsetattr(self.fd, termios.TCSADRAIN, self.saved)
619
- self.saved = None
605
+ class RawTerminal(RawConsole):
606
+ """Backward-compatible name for the platform raw-console port."""
620
607
 
621
608
 
622
609
  class TerminalSurface:
@@ -1890,9 +1877,7 @@ def _wait_reconnect_delay(delay: float, cancel_fd: int) -> None:
1890
1877
  remaining = deadline - time.monotonic()
1891
1878
  if remaining <= 0:
1892
1879
  return
1893
- readable, _writable, _exceptional = select.select(
1894
- [cancel_fd], [], [], remaining
1895
- )
1880
+ readable = wait_readable([cancel_fd], remaining)
1896
1881
  if readable:
1897
1882
  _consume_reconnect_input(cancel_fd)
1898
1883
 
@@ -2413,7 +2398,7 @@ def run(args: argparse.Namespace) -> int:
2413
2398
  touch_keyboard = TermuxTouchKeyboard(
2414
2399
  enabled=not args.no_mouse and termux_touch
2415
2400
  )
2416
- selector = selectors.DefaultSelector()
2401
+ selector = PortableSelector()
2417
2402
  selector.register(process.stdout.fileno(), selectors.EVENT_READ, "remote")
2418
2403
  selector.register(sys.stdin.fileno(), selectors.EVENT_READ, "local")
2419
2404
  started = time.monotonic()
@@ -2958,7 +2943,9 @@ def run(args: argparse.Namespace) -> int:
2958
2943
  )
2959
2944
  history_info_until = time.monotonic() + _HISTORY_INFO_SECONDS
2960
2945
  try:
2961
- with RawTerminal(sys.stdin.fileno()):
2946
+ with RawTerminal(
2947
+ sys.stdin.fileno(), output_fd=sys.stdout.fileno()
2948
+ ):
2962
2949
  while True:
2963
2950
  observed_size = os.get_terminal_size(sys.stdout.fileno())
2964
2951
  if observed_size != local_size:
@@ -83,6 +83,11 @@ from railmux.fast_display_protocol import (
83
83
  encode_path_open_result,
84
84
  encode_update,
85
85
  )
86
+ from railmux.terminal_emulator import (
87
+ extended_pyte as _shared_extended_pyte,
88
+ render_rows as _shared_render_rows,
89
+ terminal_modes_for_screen as _shared_terminal_modes_for_screen,
90
+ )
86
91
  from railmux.ui.workspace import (
87
92
  COMPACT_ENTER_HEIGHT,
88
93
  COMPACT_ENTER_WIDTH,
@@ -371,9 +376,7 @@ def _build_extended_pyte(pyte: object) -> object:
371
376
 
372
377
  def _extended_pyte(pyte: object) -> object:
373
378
  """Idempotently adapt one imported pyte module for tmux's VT stream."""
374
- if getattr(pyte, "_railmux_extended", False):
375
- return pyte
376
- return _build_extended_pyte(pyte)
379
+ return _shared_extended_pyte(pyte)
377
380
 
378
381
 
379
382
  @dataclass(frozen=True)
@@ -1887,42 +1890,12 @@ def _style_key(char: object) -> tuple[object, ...]:
1887
1890
 
1888
1891
  def render_rows(screen: object) -> tuple[bytes, ...]:
1889
1892
  """Render independently paintable rows with allowlisted SGR controls."""
1890
- rendered_rows: list[bytes] = []
1891
- for row_index in range(screen.lines):
1892
- rendered = [b"\033[0m"]
1893
- previous_style: tuple[object, ...] | None = None
1894
- row = screen.buffer[row_index]
1895
- for column in range(screen.columns):
1896
- char = row[column]
1897
- style = _style_key(char)
1898
- if style != previous_style:
1899
- rendered.append(_style(char))
1900
- previous_style = style
1901
- # pyte represents the second cell of a wide glyph as empty data;
1902
- # the real terminal already advances two columns for the glyph.
1903
- if char.data:
1904
- safe_data = "".join(
1905
- value
1906
- if value >= " "
1907
- and value != "\x7f"
1908
- and not "\x80" <= value <= "\x9f"
1909
- else "�"
1910
- for value in char.data
1911
- )
1912
- rendered.append(safe_data.encode("utf-8", errors="replace"))
1913
- rendered.append(b"\033[0m")
1914
- rendered_rows.append(b"".join(rendered))
1915
- return tuple(rendered_rows)
1893
+ return _shared_render_rows(screen)
1916
1894
 
1917
1895
 
1918
1896
  def terminal_modes_for_screen(screen: object) -> TerminalMode:
1919
1897
  """Project pyte's private-mode set onto the bounded v14 wire allowlist."""
1920
- terminal_modes = TerminalMode.NONE
1921
- if 2004 << 5 in screen.mode:
1922
- terminal_modes |= TerminalMode.BRACKETED_PASTE
1923
- if 1004 << 5 in screen.mode:
1924
- terminal_modes |= TerminalMode.FOCUS_EVENTS
1925
- return terminal_modes
1898
+ return _shared_terminal_modes_for_screen(screen)
1926
1899
 
1927
1900
 
1928
1901
  def _child_exited(pid: int) -> bool:
@@ -10,10 +10,11 @@ import json
10
10
  from pathlib import Path
11
11
 
12
12
  from railmux.atomic_file import atomic_write_text
13
+ from railmux.platform.config_paths import config_dir
13
14
 
14
15
 
15
16
  def _favorites_path() -> Path:
16
- return Path.home() / ".config" / "railmux" / "favorites.json"
17
+ return config_dir() / "favorites.json"
17
18
 
18
19
 
19
20
  class Favorites:
@@ -1,12 +1,12 @@
1
1
  """Materialise the private, read-only context used by Ask Railmux."""
2
2
  from __future__ import annotations
3
3
 
4
- import os
5
4
  from importlib import metadata
6
5
  from pathlib import Path
7
6
 
8
7
  from railmux import __version__
9
8
  from railmux.atomic_file import atomic_write_text
9
+ from railmux.platform.config_paths import data_dir
10
10
 
11
11
 
12
12
  _FALLBACK_GUIDE = """# Railmux
@@ -35,12 +35,7 @@ permissions.
35
35
 
36
36
  def help_workspace_path() -> Path:
37
37
  """Return Railmux's per-user help workspace, following the XDG spec."""
38
- raw = os.environ.get("XDG_DATA_HOME")
39
- if raw:
40
- candidate = Path(raw).expanduser()
41
- if candidate.is_absolute():
42
- return candidate / "railmux" / "help"
43
- return Path.home() / ".local" / "share" / "railmux" / "help"
38
+ return data_dir() / "help"
44
39
 
45
40
 
46
41
  def _source_readme() -> str | None:
@@ -0,0 +1,101 @@
1
+ """Best-effort native clipboard writers for explicit user copy actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+
10
+
11
+ def command() -> tuple[str, ...] | None:
12
+ """Return a trusted platform clipboard command, if one is available."""
13
+ if os.name == "nt":
14
+ return None
15
+ if sys.platform == "darwin":
16
+ if executable := shutil.which("pbcopy"):
17
+ return (executable,)
18
+ return None
19
+ if os.environ.get("WAYLAND_DISPLAY"):
20
+ if executable := shutil.which("wl-copy"):
21
+ return (executable,)
22
+ if os.environ.get("DISPLAY"):
23
+ if executable := shutil.which("xclip"):
24
+ return (executable, "-selection", "clipboard")
25
+ if executable := shutil.which("xsel"):
26
+ return (executable, "--clipboard", "--input")
27
+ if os.environ.get("WSL_DISTRO_NAME") or os.environ.get("WSL_INTEROP"):
28
+ if executable := shutil.which("clip.exe"):
29
+ return (executable,)
30
+ return None
31
+
32
+
33
+ def copy(data: bytes) -> bool:
34
+ """Write bytes to the local OS clipboard without invoking a shell."""
35
+ if os.name == "nt":
36
+ return _copy_windows(data)
37
+ writer = command()
38
+ if writer is None:
39
+ return False
40
+ try:
41
+ result = subprocess.run(
42
+ writer,
43
+ input=data,
44
+ stdout=subprocess.DEVNULL,
45
+ stderr=subprocess.DEVNULL,
46
+ timeout=2.0,
47
+ check=False,
48
+ )
49
+ except (OSError, subprocess.SubprocessError):
50
+ return False
51
+ return result.returncode == 0
52
+
53
+
54
+ def _copy_windows(data: bytes) -> bool:
55
+ """Write bounded UTF-8 data as CF_UNICODETEXT using the Win32 API."""
56
+ import ctypes
57
+
58
+ try:
59
+ text = data.decode("utf-8")
60
+ except UnicodeDecodeError:
61
+ return False
62
+ if "\x00" in text:
63
+ return False
64
+ encoded = (text + "\x00").encode("utf-16-le")
65
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
66
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
67
+ kernel32.GlobalAlloc.argtypes = (ctypes.c_uint, ctypes.c_size_t)
68
+ kernel32.GlobalAlloc.restype = ctypes.c_void_p
69
+ kernel32.GlobalLock.argtypes = (ctypes.c_void_p,)
70
+ kernel32.GlobalLock.restype = ctypes.c_void_p
71
+ kernel32.GlobalUnlock.argtypes = (ctypes.c_void_p,)
72
+ kernel32.GlobalFree.argtypes = (ctypes.c_void_p,)
73
+ user32.SetClipboardData.argtypes = (ctypes.c_uint, ctypes.c_void_p)
74
+ user32.SetClipboardData.restype = ctypes.c_void_p
75
+ movable = 0x0002
76
+ handle = kernel32.GlobalAlloc(movable, len(encoded))
77
+ if not handle:
78
+ return False
79
+ owned_by_clipboard = False
80
+ try:
81
+ pointer = kernel32.GlobalLock(handle)
82
+ if not pointer:
83
+ return False
84
+ try:
85
+ ctypes.memmove(pointer, encoded, len(encoded))
86
+ finally:
87
+ kernel32.GlobalUnlock(handle)
88
+ if not user32.OpenClipboard(None):
89
+ return False
90
+ try:
91
+ if not user32.EmptyClipboard():
92
+ return False
93
+ if not user32.SetClipboardData(13, handle): # CF_UNICODETEXT
94
+ return False
95
+ owned_by_clipboard = True
96
+ return True
97
+ finally:
98
+ user32.CloseClipboard()
99
+ finally:
100
+ if not owned_by_clipboard:
101
+ kernel32.GlobalFree(handle)