railmux 0.4.0.dev7__tar.gz → 0.4.0.dev9__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.
- {railmux-0.4.0.dev7/src/railmux.egg-info → railmux-0.4.0.dev9}/PKG-INFO +4 -2
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/README.md +3 -1
- railmux-0.4.0.dev9/src/railmux/__init__.py +1 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/cli.py +10 -2
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/discovery.py +141 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/fast_display_client.py +2 -2
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/fast_display_history.py +12 -3
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/fast_display_protocol.py +20 -7
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/fast_display_server.py +26 -3
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/tmux_binding_manager.py +52 -13
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/tmux_ctl.py +194 -43
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/app.py +139 -1
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/windows_bootstrap.py +5 -3
- railmux-0.4.0.dev9/src/railmux/windows_install_log.py +408 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/windows_msys2.py +350 -40
- railmux-0.4.0.dev9/src/railmux/windows_pacman.py +578 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9/src/railmux.egg-info}/PKG-INFO +4 -2
- railmux-0.4.0.dev9/tests/test_codex_lineage.py +157 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_discovery.py +113 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_fast_full_window.py +125 -12
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_fullscreen_binding.py +41 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tmux_binding_manager.py +39 -5
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tmux_ctl.py +73 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tmux_integration.py +58 -2
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_windows_bootstrap.py +3 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_windows_install_log.py +90 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_windows_msys2.py +159 -11
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_windows_pacman.py +166 -5
- railmux-0.4.0.dev7/src/railmux/__init__.py +0 -1
- railmux-0.4.0.dev7/src/railmux/windows_install_log.py +0 -189
- railmux-0.4.0.dev7/src/railmux/windows_pacman.py +0 -259
- railmux-0.4.0.dev7/tests/test_codex_lineage.py +0 -64
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/LICENSE +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/MANIFEST.in +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/pyproject.toml +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/setup.cfg +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/__main__.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/atomic_file.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/background_index.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/codex_index.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/config.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/config_cli.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/diagnostics.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/display_transport.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/entrypoint.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/fast_display_input.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/favorites.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/fuzzy.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/help_workspace.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/launcher.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/legacy_sessions.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/local_clipboard.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/local_open.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/models.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/modes.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/mouse_manager.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/orphan_marker.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/pane_surface.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/path_codec.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/provider_paths.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/remote_config.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/renames.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/restart_state.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/runtime_config.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/scroll_agent.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/scroll_manager.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/selection_isolation.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/self_update.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/session_cache.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/session_index.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/setting_contracts.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/settings.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ssh_args.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ssh_compat.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ssh_doctor.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/system_deps.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/terminal_status.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/tmux_health.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/tmux_server.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/tool_panes.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/transcript.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/__init__.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/_widgets.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/keymap.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/modals.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/projects_pane.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/running_pane.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/sessions_pane.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/sidebar.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/statusbar.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/workspace.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux.egg-info/SOURCES.txt +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux.egg-info/dependency_links.txt +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux.egg-info/entry_points.txt +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux.egg-info/requires.txt +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux.egg-info/top_level.txt +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_agent_workspace.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_atomic_file.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_background_index.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_cli.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_codex_delete.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_codex_index.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_codex_transcript.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_config.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_config_cli.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_diagnostics.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_display_transport.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_focus_highlight.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_fuzzy.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_help_agent.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_help_workspace.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_history_cleanup.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_keymap.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_launcher.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_layout_profile.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_legacy_sessions.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_local_clipboard.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_local_open.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_modals.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_mode_selection.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_modes.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_mouse_manager.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_new_project.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_options.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_orphan_recovery.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_pane_surface.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_path_codec.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_projects_pane.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_provider_paths.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_release_notes.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_release_workflow.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_remote_config.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_renames.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_restart_state.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_running_pane.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_running_sort.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_runtime_config.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_scroll_agent.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_scroll_manager.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_selection_isolation.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_self_update.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_session_cache.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_session_index.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_sessions_pane.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_setting_contracts.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_settings.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_sidebar.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_soft_quit.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_ssh_args.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_ssh_compat.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_ssh_display_benchmark.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_ssh_doctor.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_status.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_statusbar.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_system_deps.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_terminal_size.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_terminal_status.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_termux_local_touch.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tmux_health.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tmux_server.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tmux_status.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tool_panes.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_transcript.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_utf8_state.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_web_demo_recorder.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_wheel_smoke.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_widgets.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_windows_wrapper_contract.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_yolo_prompt.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tools/denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tools/release_notes.py +0 -0
- {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tools/wheel_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: railmux
|
|
3
|
-
Version: 0.4.0.
|
|
3
|
+
Version: 0.4.0.dev9
|
|
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
|
|
@@ -374,7 +374,9 @@ messages, tool calls, and abbreviated tool output are colour-coded, while
|
|
|
374
374
|
internal context and encrypted reasoning are hidden. Codex rewind lineages and
|
|
375
375
|
Claude Code `parentUuid` branches are projected onto the provider's current
|
|
376
376
|
conversation: the retained prefix and replacement suffix remain visible, while
|
|
377
|
-
the abandoned suffix is hidden.
|
|
377
|
+
the abandoned suffix is hidden. When Railmux observes a running Codex session
|
|
378
|
+
advance to its rewind child, it also removes the abandoned terminal screen and
|
|
379
|
+
tmux scrollback before Codex redraws the live pane.
|
|
378
380
|
|
|
379
381
|
Preview opens at the latest activity in `less`; large sessions are limited to
|
|
380
382
|
their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
|
|
@@ -331,7 +331,9 @@ messages, tool calls, and abbreviated tool output are colour-coded, while
|
|
|
331
331
|
internal context and encrypted reasoning are hidden. Codex rewind lineages and
|
|
332
332
|
Claude Code `parentUuid` branches are projected onto the provider's current
|
|
333
333
|
conversation: the retained prefix and replacement suffix remain visible, while
|
|
334
|
-
the abandoned suffix is hidden.
|
|
334
|
+
the abandoned suffix is hidden. When Railmux observes a running Codex session
|
|
335
|
+
advance to its rewind child, it also removes the abandoned terminal screen and
|
|
336
|
+
tmux scrollback before Codex redraws the live pane.
|
|
335
337
|
|
|
336
338
|
Preview opens at the latest activity in `less`; large sessions are limited to
|
|
337
339
|
their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0.dev9"
|
|
@@ -24,12 +24,15 @@ from railmux.system_deps import ensure_tmux_available
|
|
|
24
24
|
from railmux.ssh_args import AppendSshArgument, ExtendSshArguments
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
def _show_startup_message(
|
|
27
|
+
def _show_startup_message(
|
|
28
|
+
detail: str = "Reconnecting sessions and panes…",
|
|
29
|
+
) -> None:
|
|
28
30
|
"""Paint immediate feedback before App performs its initial discovery."""
|
|
29
31
|
if not sys.stdout.isatty():
|
|
30
32
|
return
|
|
31
33
|
size = shutil.get_terminal_size((80, 24))
|
|
32
|
-
sys.stdout.write(render_startup_surface(
|
|
34
|
+
sys.stdout.write(render_startup_surface(
|
|
35
|
+
size.columns, size.lines, detail=detail))
|
|
33
36
|
sys.stdout.flush()
|
|
34
37
|
|
|
35
38
|
|
|
@@ -441,6 +444,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
441
444
|
_show_startup_message()
|
|
442
445
|
# Lazy import so non-TUI invocations (--version etc) don't pull urwid.
|
|
443
446
|
from railmux.ui.app import App
|
|
447
|
+
from railmux.provider_paths import running_in_windows_wrapper
|
|
444
448
|
app = App(
|
|
445
449
|
claude_home=Path(args.claude_home),
|
|
446
450
|
config=config,
|
|
@@ -451,6 +455,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
451
455
|
is_ssh_session() if args.scroll_coalescing is None
|
|
452
456
|
else args.scroll_coalescing
|
|
453
457
|
),
|
|
458
|
+
startup_progress=(
|
|
459
|
+
_show_startup_message
|
|
460
|
+
if running_in_windows_wrapper() else None
|
|
461
|
+
),
|
|
454
462
|
)
|
|
455
463
|
app.run()
|
|
456
464
|
return 0
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Enumerate Claude project directories from ~/.claude/projects/."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import hashlib
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
7
|
+
import stat
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
|
|
8
10
|
from railmux.atomic_file import atomic_write_text
|
|
@@ -19,6 +21,134 @@ _cache: dict[Path, tuple[int, list[Project]]] = {}
|
|
|
19
21
|
_session_validity: dict[
|
|
20
22
|
Path, dict[Path, tuple[tuple[int, int, int], bool]]
|
|
21
23
|
] = {}
|
|
24
|
+
_persistent_validity_loaded: set[Path] = set()
|
|
25
|
+
_persistent_validity_exact_only: set[Path] = set()
|
|
26
|
+
_persistent_validity_dirty: set[Path] = set()
|
|
27
|
+
_VALIDITY_CACHE_SCHEMA = 1
|
|
28
|
+
_VALIDITY_CACHE_LIMIT = 16 * 1024 * 1024
|
|
29
|
+
_VALIDITY_RECORD_LIMIT = 100_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _windows_validity_cache_enabled() -> bool:
|
|
33
|
+
return os.environ.get("RAILMUX_WINDOWS_RUNTIME") == "msys2"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validity_cache_file(claude_home: Path) -> Path:
|
|
37
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
38
|
+
identity = hashlib.sha256(
|
|
39
|
+
os.fsencode(os.path.abspath(claude_home))
|
|
40
|
+
).hexdigest()[:16]
|
|
41
|
+
return base / "railmux" / f"claude-validity-{identity}.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_persistent_validity(claude_home: Path) -> None:
|
|
45
|
+
if (
|
|
46
|
+
not _windows_validity_cache_enabled()
|
|
47
|
+
or claude_home in _persistent_validity_loaded
|
|
48
|
+
):
|
|
49
|
+
return
|
|
50
|
+
_persistent_validity_loaded.add(claude_home)
|
|
51
|
+
path = _validity_cache_file(claude_home)
|
|
52
|
+
try:
|
|
53
|
+
info = path.lstat()
|
|
54
|
+
if (
|
|
55
|
+
not stat.S_ISREG(info.st_mode)
|
|
56
|
+
or info.st_uid != os.getuid()
|
|
57
|
+
or info.st_size > _VALIDITY_CACHE_LIMIT
|
|
58
|
+
or info.st_mode & 0o077
|
|
59
|
+
):
|
|
60
|
+
return
|
|
61
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
63
|
+
return
|
|
64
|
+
if not isinstance(raw, dict) or raw.get("schema") != _VALIDITY_CACHE_SCHEMA:
|
|
65
|
+
return
|
|
66
|
+
records = raw.get("records")
|
|
67
|
+
if not isinstance(records, list) or len(records) > _VALIDITY_RECORD_LIMIT:
|
|
68
|
+
return
|
|
69
|
+
projects_dir = claude_home / "projects"
|
|
70
|
+
loaded: list[Path] = []
|
|
71
|
+
try:
|
|
72
|
+
for record in records:
|
|
73
|
+
if not isinstance(record, list) or len(record) != 6:
|
|
74
|
+
raise ValueError("invalid validity record")
|
|
75
|
+
project, filename, inode, mtime_ns, size, valid = record
|
|
76
|
+
if (
|
|
77
|
+
not isinstance(project, str)
|
|
78
|
+
or not is_encoded_project_name(project)
|
|
79
|
+
or not isinstance(filename, str)
|
|
80
|
+
or Path(filename).name != filename
|
|
81
|
+
or not filename.endswith(".jsonl")
|
|
82
|
+
or not _looks_like_uuid(Path(filename).stem)
|
|
83
|
+
or not all(isinstance(value, int) and value >= 0
|
|
84
|
+
for value in (inode, mtime_ns, size))
|
|
85
|
+
or not isinstance(valid, bool)
|
|
86
|
+
):
|
|
87
|
+
raise ValueError("invalid validity record")
|
|
88
|
+
project_dir = projects_dir / project
|
|
89
|
+
session_path = project_dir / filename
|
|
90
|
+
_session_validity.setdefault(project_dir, {})[session_path] = (
|
|
91
|
+
(inode, mtime_ns, size), valid,
|
|
92
|
+
)
|
|
93
|
+
loaded.append(session_path)
|
|
94
|
+
except ValueError:
|
|
95
|
+
for session_path in loaded:
|
|
96
|
+
cached = _session_validity.get(session_path.parent)
|
|
97
|
+
if cached is not None:
|
|
98
|
+
cached.pop(session_path, None)
|
|
99
|
+
if not cached:
|
|
100
|
+
_session_validity.pop(session_path.parent, None)
|
|
101
|
+
return
|
|
102
|
+
_persistent_validity_exact_only.update(loaded)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _mark_persistent_validity_dirty(claude_dir: Path) -> None:
|
|
106
|
+
if _windows_validity_cache_enabled():
|
|
107
|
+
_persistent_validity_dirty.add(claude_dir.parent.parent)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _save_persistent_validity(claude_home: Path) -> None:
|
|
111
|
+
if (
|
|
112
|
+
not _windows_validity_cache_enabled()
|
|
113
|
+
or claude_home not in _persistent_validity_dirty
|
|
114
|
+
):
|
|
115
|
+
return
|
|
116
|
+
projects_dir = claude_home / "projects"
|
|
117
|
+
records: list[list[object]] = []
|
|
118
|
+
for project_dir, values in _session_validity.items():
|
|
119
|
+
if (
|
|
120
|
+
project_dir.parent != projects_dir
|
|
121
|
+
or not is_encoded_project_name(project_dir.name)
|
|
122
|
+
):
|
|
123
|
+
continue
|
|
124
|
+
for session_path, (signature, valid) in values.items():
|
|
125
|
+
if session_path.parent != project_dir:
|
|
126
|
+
continue
|
|
127
|
+
inode, mtime_ns, size = signature
|
|
128
|
+
records.append([
|
|
129
|
+
project_dir.name,
|
|
130
|
+
session_path.name,
|
|
131
|
+
inode,
|
|
132
|
+
mtime_ns,
|
|
133
|
+
size,
|
|
134
|
+
valid,
|
|
135
|
+
])
|
|
136
|
+
records.sort(key=lambda item: int(item[3]), reverse=True)
|
|
137
|
+
records = records[:_VALIDITY_RECORD_LIMIT]
|
|
138
|
+
path = _validity_cache_file(claude_home)
|
|
139
|
+
try:
|
|
140
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
atomic_write_text(
|
|
142
|
+
path,
|
|
143
|
+
json.dumps(
|
|
144
|
+
{"schema": _VALIDITY_CACHE_SCHEMA, "records": records},
|
|
145
|
+
separators=(",", ":"),
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
os.chmod(path, 0o600)
|
|
149
|
+
except OSError:
|
|
150
|
+
return
|
|
151
|
+
_persistent_validity_dirty.discard(claude_home)
|
|
22
152
|
|
|
23
153
|
|
|
24
154
|
def invalidate_session(path: Path) -> None:
|
|
@@ -27,6 +157,8 @@ def invalidate_session(path: Path) -> None:
|
|
|
27
157
|
if cached is None:
|
|
28
158
|
return
|
|
29
159
|
cached.pop(path, None)
|
|
160
|
+
_persistent_validity_exact_only.discard(path)
|
|
161
|
+
_mark_persistent_validity_dirty(path.parent)
|
|
30
162
|
if not cached:
|
|
31
163
|
_session_validity.pop(path.parent, None)
|
|
32
164
|
|
|
@@ -64,6 +196,7 @@ def list_projects(claude_home: Path) -> list[Project]:
|
|
|
64
196
|
session counts and recency track files created, changed, or removed below
|
|
65
197
|
the cached top-level directory.
|
|
66
198
|
"""
|
|
199
|
+
_load_persistent_validity(claude_home)
|
|
67
200
|
projects_dir = claude_home / "projects"
|
|
68
201
|
if not projects_dir.is_dir():
|
|
69
202
|
return []
|
|
@@ -79,6 +212,7 @@ def list_projects(claude_home: Path) -> list[Project]:
|
|
|
79
212
|
# without re-decoding every project name.
|
|
80
213
|
refreshed = _refresh_activity(cached[1])
|
|
81
214
|
_cache[claude_home] = (parent_mtime, refreshed)
|
|
215
|
+
_save_persistent_validity(claude_home)
|
|
82
216
|
return refreshed
|
|
83
217
|
|
|
84
218
|
# Full scan needed.
|
|
@@ -153,6 +287,7 @@ def list_projects(claude_home: Path) -> list[Project]:
|
|
|
153
287
|
|
|
154
288
|
results.sort(key=lambda p: p.last_activity_ts, reverse=True)
|
|
155
289
|
_cache[claude_home] = (parent_mtime, results)
|
|
290
|
+
_save_persistent_validity(claude_home)
|
|
156
291
|
return results
|
|
157
292
|
|
|
158
293
|
|
|
@@ -198,6 +333,7 @@ def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, flo
|
|
|
198
333
|
if cached is not None and cached[0] == signature:
|
|
199
334
|
valid = cached[1]
|
|
200
335
|
elif (cached is not None and cached[1]
|
|
336
|
+
and path not in _persistent_validity_exact_only
|
|
201
337
|
and cached[0][0] == stat.st_ino
|
|
202
338
|
and stat.st_size > cached[0][2]):
|
|
203
339
|
# Claude session JSONLs are append-only. Once a file is valid,
|
|
@@ -208,9 +344,12 @@ def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, flo
|
|
|
208
344
|
# explicitly via ``invalidate_session``).
|
|
209
345
|
valid = True
|
|
210
346
|
validity[path] = (signature, True)
|
|
347
|
+
_mark_persistent_validity_dirty(claude_dir)
|
|
211
348
|
else:
|
|
212
349
|
valid = _scan_session(temp_project, path) is not None
|
|
213
350
|
validity[path] = (signature, valid)
|
|
351
|
+
_persistent_validity_exact_only.discard(path)
|
|
352
|
+
_mark_persistent_validity_dirty(claude_dir)
|
|
214
353
|
if not valid:
|
|
215
354
|
continue
|
|
216
355
|
count += 1
|
|
@@ -219,6 +358,8 @@ def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, flo
|
|
|
219
358
|
latest = m
|
|
220
359
|
for stale in set(validity) - current_paths:
|
|
221
360
|
del validity[stale]
|
|
361
|
+
_persistent_validity_exact_only.discard(stale)
|
|
362
|
+
_mark_persistent_validity_dirty(claude_dir)
|
|
222
363
|
if not validity:
|
|
223
364
|
_session_validity.pop(claude_dir, None)
|
|
224
365
|
return count, latest
|
|
@@ -1074,7 +1074,7 @@ class TerminalSurface:
|
|
|
1074
1074
|
self,
|
|
1075
1075
|
requested: TerminalMode,
|
|
1076
1076
|
) -> TerminalMode:
|
|
1077
|
-
"""Mirror only input-affecting modes explicitly carried by protocol
|
|
1077
|
+
"""Mirror only input-affecting modes explicitly carried by protocol v15."""
|
|
1078
1078
|
disabled = self.terminal_modes & ~requested
|
|
1079
1079
|
enabled = requested & ~self.terminal_modes
|
|
1080
1080
|
controls: list[bytes] = []
|
|
@@ -2013,7 +2013,7 @@ def _finish_remote_attach(
|
|
|
2013
2013
|
raise ProbeError("remote display helper failed before attaching")
|
|
2014
2014
|
|
|
2015
2015
|
_stop_unstarted_remote(process)
|
|
2016
|
-
# A current
|
|
2016
|
+
# A current v15 helper holds the mutex only while registering its exact tmux
|
|
2017
2017
|
# child. Give that ordinary race one fresh SSH process before presenting
|
|
2018
2018
|
# the explicit legacy-lock takeover choice.
|
|
2019
2019
|
time.sleep(_REMOTE_ATTACH_RETRY_DELAY)
|
|
@@ -207,7 +207,11 @@ class LocalHistoryView:
|
|
|
207
207
|
restore_live = False
|
|
208
208
|
for pane_id, viewport in tuple(self.viewports.items()):
|
|
209
209
|
route = routes.get(pane_id)
|
|
210
|
-
if
|
|
210
|
+
if (
|
|
211
|
+
route is None
|
|
212
|
+
or not self._same_geometry(viewport.snapshot, route)
|
|
213
|
+
or not self._history_source_matches(viewport.snapshot, route)
|
|
214
|
+
):
|
|
211
215
|
self.cancel_pane(pane_id)
|
|
212
216
|
restore_live = True
|
|
213
217
|
return HistoryAction(restore_live=restore_live)
|
|
@@ -217,10 +221,11 @@ class LocalHistoryView:
|
|
|
217
221
|
left: HistorySnapshot,
|
|
218
222
|
right: HistorySnapshot,
|
|
219
223
|
) -> bool:
|
|
220
|
-
"""Do not combine
|
|
224
|
+
"""Do not combine different providers or terminal generations."""
|
|
221
225
|
return (
|
|
222
226
|
left.transcript_backed == right.transcript_backed
|
|
223
227
|
and left.history_choice_required == right.history_choice_required
|
|
228
|
+
and left.generation == right.generation
|
|
224
229
|
)
|
|
225
230
|
|
|
226
231
|
@staticmethod
|
|
@@ -813,7 +818,11 @@ class LocalHistoryView:
|
|
|
813
818
|
),
|
|
814
819
|
None,
|
|
815
820
|
)
|
|
816
|
-
if
|
|
821
|
+
if (
|
|
822
|
+
route is None
|
|
823
|
+
or not self._same_geometry(route, snapshot)
|
|
824
|
+
or not self._history_source_matches(route, snapshot)
|
|
825
|
+
):
|
|
817
826
|
return HistoryAction()
|
|
818
827
|
viewport = self.viewports.get(pending.pane_id)
|
|
819
828
|
if viewport is None:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Private
|
|
1
|
+
"""Private v15 framing for the coalesced full-window SSH display."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -8,9 +8,9 @@ from dataclasses import dataclass
|
|
|
8
8
|
from enum import IntEnum, IntFlag
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
DISPLAY_MAGIC = b"
|
|
12
|
-
INPUT_MAGIC = b"
|
|
13
|
-
PROTOCOL_VERSION =
|
|
11
|
+
DISPLAY_MAGIC = b"RMUXD15\x00"
|
|
12
|
+
INPUT_MAGIC = b"RMUXK15\x00"
|
|
13
|
+
PROTOCOL_VERSION = 15
|
|
14
14
|
REMOTE_CONFIG_PROTOCOL = 1
|
|
15
15
|
LENGTH_BYTES = 4
|
|
16
16
|
REMOTE_HELLO_PREFIX = b"RAILMUX-REMOTE/1 "
|
|
@@ -28,10 +28,10 @@ MAX_HISTORY_PANES = 8
|
|
|
28
28
|
MAX_CLIPBOARD_BYTES = 64 * 1024
|
|
29
29
|
MAX_PATH_BYTES = 4096
|
|
30
30
|
_UPDATE_METADATA = struct.Struct(">BIHHHHBHI")
|
|
31
|
-
_HISTORY_METADATA = struct.Struct(">
|
|
31
|
+
_HISTORY_METADATA = struct.Struct(">IIHHHHBQI")
|
|
32
32
|
_HISTORY_REQUEST = struct.Struct(">IHHH")
|
|
33
33
|
_HISTORY_BATCH_METADATA = struct.Struct(">II")
|
|
34
|
-
_HISTORY_PANE_METADATA = struct.Struct(">
|
|
34
|
+
_HISTORY_PANE_METADATA = struct.Struct(">IHHHHBQ")
|
|
35
35
|
_PREFETCH_HISTORY_REQUEST = struct.Struct(">IH")
|
|
36
36
|
_PATH_REQUEST = struct.Struct(">IIH")
|
|
37
37
|
_PATH_RESULT = struct.Struct(">IBBH")
|
|
@@ -143,6 +143,7 @@ class HistorySnapshot:
|
|
|
143
143
|
more_available: bool = False
|
|
144
144
|
transcript_available: bool = False
|
|
145
145
|
history_choice_required: bool = False
|
|
146
|
+
generation: int = 0
|
|
146
147
|
|
|
147
148
|
|
|
148
149
|
@dataclass(frozen=True)
|
|
@@ -302,6 +303,7 @@ def encode_history_snapshot(snapshot: HistorySnapshot) -> bytes:
|
|
|
302
303
|
snapshot.more_available,
|
|
303
304
|
snapshot.transcript_available,
|
|
304
305
|
snapshot.history_choice_required,
|
|
306
|
+
snapshot.generation,
|
|
305
307
|
)):
|
|
306
308
|
raise ValueError("a rejected history response must be empty")
|
|
307
309
|
pane_number = 0
|
|
@@ -317,6 +319,8 @@ def encode_history_snapshot(snapshot: HistorySnapshot) -> bytes:
|
|
|
317
319
|
raise ValueError("invalid history position")
|
|
318
320
|
if len(snapshot.lines) < snapshot.height:
|
|
319
321
|
raise ValueError("history snapshot is shorter than its viewport")
|
|
322
|
+
if not 0 <= snapshot.generation <= 0xFFFFFFFFFFFFFFFF:
|
|
323
|
+
raise ValueError("invalid history generation")
|
|
320
324
|
raw_lines = _pack_history_lines(snapshot.lines)
|
|
321
325
|
compressed = zlib.compress(raw_lines, level=3)
|
|
322
326
|
metadata = _HISTORY_METADATA.pack(
|
|
@@ -327,6 +331,7 @@ def encode_history_snapshot(snapshot: HistorySnapshot) -> bytes:
|
|
|
327
331
|
snapshot.width,
|
|
328
332
|
snapshot.height,
|
|
329
333
|
_history_capabilities(snapshot),
|
|
334
|
+
snapshot.generation,
|
|
330
335
|
len(raw_lines),
|
|
331
336
|
)
|
|
332
337
|
payload = bytes((int(OutputKind.HISTORY),)) + metadata + compressed
|
|
@@ -360,6 +365,8 @@ def encode_history_batch(batch: HistoryBatch) -> bytes:
|
|
|
360
365
|
raise ValueError("invalid history position")
|
|
361
366
|
if len(snapshot.lines) < snapshot.height:
|
|
362
367
|
raise ValueError("history snapshot is shorter than its viewport")
|
|
368
|
+
if not 0 <= snapshot.generation <= 0xFFFFFFFFFFFFFFFF:
|
|
369
|
+
raise ValueError("invalid history generation")
|
|
363
370
|
packed_lines = _pack_history_lines(snapshot.lines)
|
|
364
371
|
pane_metadata = _HISTORY_PANE_METADATA.pack(
|
|
365
372
|
pane_number,
|
|
@@ -368,6 +375,7 @@ def encode_history_batch(batch: HistoryBatch) -> bytes:
|
|
|
368
375
|
snapshot.width,
|
|
369
376
|
snapshot.height,
|
|
370
377
|
_history_capabilities(snapshot),
|
|
378
|
+
snapshot.generation,
|
|
371
379
|
)
|
|
372
380
|
total += len(pane_metadata) + len(packed_lines)
|
|
373
381
|
if total > MAX_SCREEN_BYTES:
|
|
@@ -611,6 +619,7 @@ class ServerMessageDecoder:
|
|
|
611
619
|
width,
|
|
612
620
|
height,
|
|
613
621
|
raw_capabilities,
|
|
622
|
+
generation,
|
|
614
623
|
raw_size,
|
|
615
624
|
) = (
|
|
616
625
|
_HISTORY_METADATA.unpack(body[:_HISTORY_METADATA.size])
|
|
@@ -628,6 +637,7 @@ class ServerMessageDecoder:
|
|
|
628
637
|
height,
|
|
629
638
|
lines,
|
|
630
639
|
raw_capabilities,
|
|
640
|
+
generation,
|
|
631
641
|
)):
|
|
632
642
|
raise ValueError("invalid rejected history response")
|
|
633
643
|
pane_id = None
|
|
@@ -662,6 +672,7 @@ class ServerMessageDecoder:
|
|
|
662
672
|
history_choice_required=bool(
|
|
663
673
|
raw_capabilities & _HISTORY_CHOICE_REQUIRED
|
|
664
674
|
),
|
|
675
|
+
generation=generation,
|
|
665
676
|
))
|
|
666
677
|
continue
|
|
667
678
|
if output_kind is OutputKind.HISTORY_BATCH:
|
|
@@ -691,6 +702,7 @@ class ServerMessageDecoder:
|
|
|
691
702
|
width,
|
|
692
703
|
height,
|
|
693
704
|
raw_capabilities,
|
|
705
|
+
generation,
|
|
694
706
|
) = _HISTORY_PANE_METADATA.unpack(
|
|
695
707
|
raw[offset:offset + _HISTORY_PANE_METADATA.size]
|
|
696
708
|
)
|
|
@@ -729,6 +741,7 @@ class ServerMessageDecoder:
|
|
|
729
741
|
history_choice_required=bool(
|
|
730
742
|
raw_capabilities & _HISTORY_CHOICE_REQUIRED
|
|
731
743
|
),
|
|
744
|
+
generation=generation,
|
|
732
745
|
))
|
|
733
746
|
if offset != len(raw):
|
|
734
747
|
raise ValueError("trailing history batch data")
|
|
@@ -843,7 +856,7 @@ class ServerMessageDecoder:
|
|
|
843
856
|
|
|
844
857
|
|
|
845
858
|
class ScreenUpdateDecoder:
|
|
846
|
-
"""Compatibility view which ignores
|
|
859
|
+
"""Compatibility view which ignores v15 control-response messages."""
|
|
847
860
|
|
|
848
861
|
def __init__(self) -> None:
|
|
849
862
|
self._decoder = ServerMessageDecoder()
|
|
@@ -45,6 +45,7 @@ from railmux import (
|
|
|
45
45
|
__version__,
|
|
46
46
|
local_open,
|
|
47
47
|
restart_state,
|
|
48
|
+
tmux_ctl,
|
|
48
49
|
tmux_health,
|
|
49
50
|
tmux_server,
|
|
50
51
|
)
|
|
@@ -403,6 +404,7 @@ class _PaneGeometry:
|
|
|
403
404
|
transcript_source: str | None = None
|
|
404
405
|
transcript_backed: bool = False
|
|
405
406
|
claude_history_policy: str = "ask"
|
|
407
|
+
history_generation: int = 0
|
|
406
408
|
|
|
407
409
|
|
|
408
410
|
@dataclass(frozen=True)
|
|
@@ -421,6 +423,24 @@ _INFERRED_TRANSCRIPT_LIMIT = 8
|
|
|
421
423
|
_TRANSCRIPT_MAX_BYTES = 32 * 1024 * 1024
|
|
422
424
|
_SESSION_BINDING_OPTION = "@railmux_binding_v1"
|
|
423
425
|
_SWAP_OPTIONS = ("@railmux_swap_primary", "@railmux_swap_secondary")
|
|
426
|
+
_HISTORY_GENERATION_RE = re.compile(
|
|
427
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
|
428
|
+
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _history_generation(marker: str) -> int:
|
|
433
|
+
"""Map one validated provider UUID to a bounded opaque wire epoch."""
|
|
434
|
+
if not _HISTORY_GENERATION_RE.fullmatch(marker):
|
|
435
|
+
return 0
|
|
436
|
+
return int.from_bytes(
|
|
437
|
+
hashlib.blake2b(
|
|
438
|
+
marker.lower().encode("ascii"),
|
|
439
|
+
digest_size=8,
|
|
440
|
+
person=b"railmux-history",
|
|
441
|
+
).digest(),
|
|
442
|
+
"big",
|
|
443
|
+
)
|
|
424
444
|
|
|
425
445
|
|
|
426
446
|
_ANSI_FG = {
|
|
@@ -834,6 +854,7 @@ def _list_agent_panes(
|
|
|
834
854
|
"#{history_size} #{alternate_on} #{mouse_any_flag} "
|
|
835
855
|
f"#{{{tmux_server.HISTORY_SOURCE_OPTION}}} "
|
|
836
856
|
f"#{{{tmux_server.TRANSCRIPT_SOURCE_OPTION}}} "
|
|
857
|
+
f"#{{{tmux_ctl.RAILMUX_HISTORY_GENERATION_OPTION}}} "
|
|
837
858
|
f"#{{{TOOL_PANE_OPTION}}}",
|
|
838
859
|
),
|
|
839
860
|
stderr=subprocess.DEVNULL,
|
|
@@ -849,7 +870,7 @@ def _list_agent_panes(
|
|
|
849
870
|
for raw_row in output.splitlines():
|
|
850
871
|
fields = raw_row.split(" ")
|
|
851
872
|
if (
|
|
852
|
-
len(fields) !=
|
|
873
|
+
len(fields) != 17
|
|
853
874
|
or fields[0] != session_id
|
|
854
875
|
or fields[2] not in ("0", "1")
|
|
855
876
|
or fields[3] not in ("0", "1")
|
|
@@ -880,7 +901,7 @@ def _list_agent_panes(
|
|
|
880
901
|
):
|
|
881
902
|
return ()
|
|
882
903
|
seen.add(pane_id)
|
|
883
|
-
if is_tool_pane_marker(fields[
|
|
904
|
+
if is_tool_pane_marker(fields[16], outer_session_id=session_id):
|
|
884
905
|
continue
|
|
885
906
|
history_server = None
|
|
886
907
|
history_pane_id = None
|
|
@@ -931,6 +952,7 @@ def _list_agent_panes(
|
|
|
931
952
|
transcript_source=transcript_marker,
|
|
932
953
|
transcript_backed=transcript_backed,
|
|
933
954
|
claude_history_policy=claude_history_policy,
|
|
955
|
+
history_generation=_history_generation(fields[15]),
|
|
934
956
|
),
|
|
935
957
|
)
|
|
936
958
|
)
|
|
@@ -1376,6 +1398,7 @@ def _capture_pane_history(
|
|
|
1376
1398
|
else pane.history_size + pane.height > len(lines)
|
|
1377
1399
|
)
|
|
1378
1400
|
),
|
|
1401
|
+
generation=pane.history_generation,
|
|
1379
1402
|
)
|
|
1380
1403
|
|
|
1381
1404
|
|
|
@@ -1916,7 +1939,7 @@ def render_rows(screen: object) -> tuple[bytes, ...]:
|
|
|
1916
1939
|
|
|
1917
1940
|
|
|
1918
1941
|
def terminal_modes_for_screen(screen: object) -> TerminalMode:
|
|
1919
|
-
"""Project pyte's private-mode set onto the bounded
|
|
1942
|
+
"""Project pyte's private-mode set onto the bounded v15 wire allowlist."""
|
|
1920
1943
|
terminal_modes = TerminalMode.NONE
|
|
1921
1944
|
if 2004 << 5 in screen.mode:
|
|
1922
1945
|
terminal_modes |= TerminalMode.BRACKETED_PASTE
|
|
@@ -14,8 +14,14 @@ from railmux import restart_state, tmux_ctl
|
|
|
14
14
|
from railmux.atomic_file import atomic_write_text
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
_VERSION =
|
|
17
|
+
_VERSION = 9
|
|
18
18
|
_KEYS = ("F8", "F9")
|
|
19
|
+
_STATUS_CLICK_KEYS = (
|
|
20
|
+
"MouseDown1Status",
|
|
21
|
+
"MouseDown1Control0",
|
|
22
|
+
"MouseDown1Control1",
|
|
23
|
+
"MouseDown1Control2",
|
|
24
|
+
)
|
|
19
25
|
_MAX_STATE_BYTES = 64 * 1024
|
|
20
26
|
|
|
21
27
|
|
|
@@ -114,12 +120,18 @@ class SharedTmuxBindingManager:
|
|
|
114
120
|
and all(value is None or isinstance(value, str)
|
|
115
121
|
for value in right_click_backup.values())
|
|
116
122
|
)
|
|
117
|
-
|
|
123
|
+
legacy_status_click_valid = (
|
|
118
124
|
isinstance(status_click_backup, dict)
|
|
119
125
|
and set(status_click_backup) == {"MouseDown1Status"}
|
|
120
126
|
and all(value is None or isinstance(value, str)
|
|
121
127
|
for value in status_click_backup.values())
|
|
122
128
|
)
|
|
129
|
+
status_click_valid = (
|
|
130
|
+
isinstance(status_click_backup, dict)
|
|
131
|
+
and set(status_click_backup) == set(_STATUS_CLICK_KEYS)
|
|
132
|
+
and all(value is None or isinstance(value, str)
|
|
133
|
+
for value in status_click_backup.values())
|
|
134
|
+
)
|
|
123
135
|
termux_tap_valid = (
|
|
124
136
|
isinstance(termux_tap_backup, dict)
|
|
125
137
|
and set(termux_tap_backup) == {"MouseDown1Pane"}
|
|
@@ -149,7 +161,13 @@ class SharedTmuxBindingManager:
|
|
|
149
161
|
or (not selection_hook_managed
|
|
150
162
|
and selection_hook_index is not None)))
|
|
151
163
|
or (raw["version"] >= 5
|
|
152
|
-
and (
|
|
164
|
+
and ((raw["version"] < 9
|
|
165
|
+
and not (
|
|
166
|
+
legacy_status_click_valid
|
|
167
|
+
or status_click_valid
|
|
168
|
+
))
|
|
169
|
+
or (raw["version"] >= 9
|
|
170
|
+
and not status_click_valid)
|
|
153
171
|
or not isinstance(status_click_managed, bool)))
|
|
154
172
|
or (raw["version"] >= 8
|
|
155
173
|
and (not termux_tap_valid
|
|
@@ -293,6 +311,29 @@ class SharedTmuxBindingManager:
|
|
|
293
311
|
or {"MouseDown1Pane": None}
|
|
294
312
|
)
|
|
295
313
|
upgraded = True
|
|
314
|
+
if state["version"] < 9:
|
|
315
|
+
if state["status_click_managed"]:
|
|
316
|
+
if set(state["status_click_backup"]) != set(
|
|
317
|
+
_STATUS_CLICK_KEYS
|
|
318
|
+
):
|
|
319
|
+
status_click_backup = (
|
|
320
|
+
tmux_ctl.upgrade_root_status_click_binding(
|
|
321
|
+
state["status_click_backup"],
|
|
322
|
+
state["token"],
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
if status_click_backup is None:
|
|
326
|
+
return False
|
|
327
|
+
state["status_click_backup"] = (
|
|
328
|
+
status_click_backup)
|
|
329
|
+
else:
|
|
330
|
+
state["status_click_backup"] = {
|
|
331
|
+
key: None for key in _STATUS_CLICK_KEYS
|
|
332
|
+
}
|
|
333
|
+
# tmux 3.7 routes control ranges through dedicated
|
|
334
|
+
# MouseDown1ControlN keys. Capture all originals before
|
|
335
|
+
# reinstalling the shared status-click lease.
|
|
336
|
+
upgraded = True
|
|
296
337
|
if upgraded:
|
|
297
338
|
state["version"] = _VERSION
|
|
298
339
|
state["phase"] = "installing"
|
|
@@ -341,17 +382,14 @@ class SharedTmuxBindingManager:
|
|
|
341
382
|
)
|
|
342
383
|
if state["status_click_managed"]:
|
|
343
384
|
current_status = (
|
|
344
|
-
tmux_ctl.read_root_status_click_binding()
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
and tmux_ctl.root_status_click_binding_is_original_or_owned(
|
|
350
|
-
current_status,
|
|
351
|
-
state["status_click_backup"].get(
|
|
352
|
-
"MouseDown1Status"),
|
|
385
|
+
tmux_ctl.read_root_status_click_binding())
|
|
386
|
+
safe = safe and all(
|
|
387
|
+
tmux_ctl.root_status_click_binding_is_original_or_owned(
|
|
388
|
+
current_status.get(key),
|
|
389
|
+
state["status_click_backup"].get(key),
|
|
353
390
|
token,
|
|
354
391
|
)
|
|
392
|
+
for key in _STATUS_CLICK_KEYS
|
|
355
393
|
)
|
|
356
394
|
if state["termux_tap_managed"]:
|
|
357
395
|
current_tap = (
|
|
@@ -543,7 +581,8 @@ class SharedTmuxBindingManager:
|
|
|
543
581
|
"selection_hook_index": selection_hook_index,
|
|
544
582
|
"status_click_managed": status_click_backup is not None,
|
|
545
583
|
"status_click_backup": (
|
|
546
|
-
status_click_backup
|
|
584
|
+
status_click_backup
|
|
585
|
+
or {key: None for key in _STATUS_CLICK_KEYS}),
|
|
547
586
|
"termux_tap_managed": termux_tap_backup is not None,
|
|
548
587
|
"termux_tap_backup": (
|
|
549
588
|
termux_tap_backup or {"MouseDown1Pane": None}),
|