railmux 0.4.0.dev18__tar.gz → 0.4.0.dev20__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.dev18/src/railmux.egg-info → railmux-0.4.0.dev20}/PKG-INFO +6 -6
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/README.md +5 -5
- railmux-0.4.0.dev20/src/railmux/__init__.py +1 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/cli.py +44 -11
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/fast_display_history.py +128 -73
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/fast_display_server.py +33 -16
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/tmux_ctl.py +7 -41
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/tmux_server.py +55 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ui/app.py +276 -47
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ui/modals.py +81 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20/src/railmux.egg-info}/PKG-INFO +6 -6
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_agent_workspace.py +1 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_cli.py +73 -2
- railmux-0.4.0.dev20/tests/test_codex_lineage.py +561 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_fast_full_window.py +252 -65
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_focus_highlight.py +6 -2
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_fullscreen_binding.py +61 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_modals.py +68 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_soft_quit.py +101 -1
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_tmux_ctl.py +0 -24
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_tmux_integration.py +12 -49
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_tmux_server.py +109 -1
- railmux-0.4.0.dev18/src/railmux/__init__.py +0 -1
- railmux-0.4.0.dev18/tests/test_codex_lineage.py +0 -253
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/LICENSE +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/MANIFEST.in +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/pyproject.toml +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/setup.cfg +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/__main__.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/atomic_file.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/background_index.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/codex_index.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/config.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/config_cli.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/diagnostics.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/discovery.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/display_transport.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/entrypoint.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/fast_display_client.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/fast_display_input.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/fast_display_protocol.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/favorites.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/fuzzy.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/help_workspace.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/launcher.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/legacy_sessions.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/local_clipboard.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/local_open.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/models.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/modes.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/mouse_manager.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/orphan_marker.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/pane_surface.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/path_codec.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/provider_paths.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/remote_config.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/renames.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/restart_state.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/runtime_config.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/scroll_agent.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/scroll_manager.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/selection_isolation.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/self_update.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/session_cache.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/session_index.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/session_lease.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/setting_contracts.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/settings.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ssh_args.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ssh_compat.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ssh_doctor.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/system_deps.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/terminal_status.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/tmux_binding_manager.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/tmux_health.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/tool_panes.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/transcript.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ui/__init__.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ui/_widgets.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ui/keymap.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ui/projects_pane.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ui/running_pane.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ui/sessions_pane.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ui/sidebar.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ui/statusbar.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/ui/workspace.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/windows_attach_relay.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/windows_install_log.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/windows_msys2.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/windows_pacman.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux/windows_tmux_lifecycle.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux.egg-info/SOURCES.txt +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux.egg-info/dependency_links.txt +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux.egg-info/entry_points.txt +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux.egg-info/requires.txt +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/src/railmux.egg-info/top_level.txt +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_atomic_file.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_background_index.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_codex_delete.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_codex_index.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_codex_transcript.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_config.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_config_cli.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_diagnostics.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_discovery.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_display_transport.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_fuzzy.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_help_agent.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_help_workspace.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_history_cleanup.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_keymap.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_launcher.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_layout_profile.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_legacy_sessions.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_local_clipboard.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_local_open.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_mode_selection.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_modes.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_mouse_manager.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_new_project.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_options.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_orphan_recovery.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_pane_surface.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_path_codec.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_projects_pane.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_provider_paths.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_release_notes.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_release_workflow.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_remote_config.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_renames.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_restart_state.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_running_pane.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_running_sort.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_runtime_config.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_scroll_agent.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_scroll_manager.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_selection_isolation.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_self_update.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_session_cache.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_session_index.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_session_lease.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_sessions_pane.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_setting_contracts.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_settings.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_sidebar.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_ssh_args.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_ssh_compat.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_ssh_display_benchmark.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_ssh_doctor.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_status.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_statusbar.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_system_deps.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_terminal_size.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_terminal_status.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_termux_local_touch.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_tmux_binding_manager.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_tmux_health.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_tmux_status.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_tool_panes.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_transcript.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_utf8_state.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_web_demo_recorder.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_wheel_smoke.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_widgets.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_windows_attach_relay.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_windows_install_log.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_windows_msys2.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_windows_pacman.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_windows_tmux_lifecycle.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_windows_wrapper_contract.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tests/test_yolo_prompt.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tools/denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/tools/release_notes.py +0 -0
- {railmux-0.4.0.dev18 → railmux-0.4.0.dev20}/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.dev20
|
|
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
|
|
@@ -384,10 +384,9 @@ Scrolling a live agent does not enter Preview. Direct `railmux` uses the
|
|
|
384
384
|
terminal/tmux scrolling behavior; `railmux ssh` keeps its own bounded,
|
|
385
385
|
per-pane history and normally preserves the live terminal's captured styling.
|
|
386
386
|
When Railmux confirms that a running Codex session advanced after rewind or
|
|
387
|
-
steering, it
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
rollout so its abandoned suffix stays hidden.
|
|
387
|
+
steering, it leaves the live terminal untouched and advances only that pane's
|
|
388
|
+
SSH history generation. The SSH history for that branched generation uses the
|
|
389
|
+
canonical rollout so its abandoned suffix stays hidden.
|
|
391
390
|
|
|
392
391
|
Preview opens at the latest activity in `less`; large sessions are limited to
|
|
393
392
|
their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
|
|
@@ -446,7 +445,8 @@ Each opened agent runs in a detached tmux session, so switching sessions does
|
|
|
446
445
|
not interrupt it. To leave agents running when you quit Railmux, press `s` for
|
|
447
446
|
soft quit in the confirmation popup; restarting the same Railmux instance then
|
|
448
447
|
restores the usable workspace when those sessions are still available. A normal
|
|
449
|
-
quit
|
|
448
|
+
quit ends all running sessions instead and therefore requires a second
|
|
449
|
+
confirmation; `y` and Enter keep their existing confirm meaning in both steps.
|
|
450
450
|
|
|
451
451
|
All terminals attached to one managed Railmux window view the same UI process.
|
|
452
452
|
Soft Quit therefore closes that shared UI for every attached view, although it
|
|
@@ -341,10 +341,9 @@ Scrolling a live agent does not enter Preview. Direct `railmux` uses the
|
|
|
341
341
|
terminal/tmux scrolling behavior; `railmux ssh` keeps its own bounded,
|
|
342
342
|
per-pane history and normally preserves the live terminal's captured styling.
|
|
343
343
|
When Railmux confirms that a running Codex session advanced after rewind or
|
|
344
|
-
steering, it
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
rollout so its abandoned suffix stays hidden.
|
|
344
|
+
steering, it leaves the live terminal untouched and advances only that pane's
|
|
345
|
+
SSH history generation. The SSH history for that branched generation uses the
|
|
346
|
+
canonical rollout so its abandoned suffix stays hidden.
|
|
348
347
|
|
|
349
348
|
Preview opens at the latest activity in `less`; large sessions are limited to
|
|
350
349
|
their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
|
|
@@ -403,7 +402,8 @@ Each opened agent runs in a detached tmux session, so switching sessions does
|
|
|
403
402
|
not interrupt it. To leave agents running when you quit Railmux, press `s` for
|
|
404
403
|
soft quit in the confirmation popup; restarting the same Railmux instance then
|
|
405
404
|
restores the usable workspace when those sessions are still available. A normal
|
|
406
|
-
quit
|
|
405
|
+
quit ends all running sessions instead and therefore requires a second
|
|
406
|
+
confirmation; `y` and Enter keep their existing confirm meaning in both steps.
|
|
407
407
|
|
|
408
408
|
All terminals attached to one managed Railmux window view the same UI process.
|
|
409
409
|
Soft Quit therefore closes that shared UI for every attached view, although it
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0.dev20"
|
|
@@ -26,6 +26,7 @@ from railmux import tmux_server
|
|
|
26
26
|
from railmux import windows_tmux_lifecycle
|
|
27
27
|
from railmux.system_deps import ensure_tmux_available
|
|
28
28
|
from railmux.ssh_args import AppendSshArgument, ExtendSshArguments
|
|
29
|
+
from railmux.terminal_status import command_status
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
def _show_startup_message(
|
|
@@ -44,6 +45,20 @@ _LOCAL_WATCHDOG_INTERVAL = 5.0
|
|
|
44
45
|
_LOCAL_WATCHDOG_FAILURES = 3
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def _interactive_terminal_size() -> tuple[int, int] | None:
|
|
49
|
+
"""Return an exact entry TTY size without manufacturing a fallback."""
|
|
50
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
51
|
+
return None
|
|
52
|
+
for stream in (sys.stdout, sys.stdin):
|
|
53
|
+
try:
|
|
54
|
+
size = os.get_terminal_size(stream.fileno())
|
|
55
|
+
except (AttributeError, OSError):
|
|
56
|
+
continue
|
|
57
|
+
if 0 < size.columns <= 65535 and 0 < size.lines <= 65535:
|
|
58
|
+
return size.columns, size.lines
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
47
62
|
def _restore_terminal(attributes: list | None) -> None:
|
|
48
63
|
if attributes is None:
|
|
49
64
|
return
|
|
@@ -85,6 +100,9 @@ def _run_tmux_client_with_watchdog(
|
|
|
85
100
|
expected_session_id: str | None = None,
|
|
86
101
|
) -> int:
|
|
87
102
|
"""Keep one monitor outside tmux so a frozen server cannot trap the TTY."""
|
|
103
|
+
from railmux.provider_paths import running_in_managed_windows_wrapper
|
|
104
|
+
|
|
105
|
+
managed_windows = running_in_managed_windows_wrapper()
|
|
88
106
|
attributes = None
|
|
89
107
|
if sys.stdin.isatty():
|
|
90
108
|
try:
|
|
@@ -93,7 +111,14 @@ def _run_tmux_client_with_watchdog(
|
|
|
93
111
|
pass
|
|
94
112
|
started_at = time.monotonic()
|
|
95
113
|
try:
|
|
96
|
-
|
|
114
|
+
popen_kwargs: dict[str, object] = {"env": env}
|
|
115
|
+
if managed_windows and expected_target is not None:
|
|
116
|
+
# A fast direct-client rejection is an implementation detail when
|
|
117
|
+
# the server-origin bridge succeeds. Suppress tmux's raw
|
|
118
|
+
# ``open terminal failed`` line and emit one Railmux-owned status
|
|
119
|
+
# below; bridge failures still receive an actionable error.
|
|
120
|
+
popen_kwargs["stderr"] = subprocess.DEVNULL
|
|
121
|
+
process: object = subprocess.Popen(argv, **popen_kwargs)
|
|
97
122
|
except OSError as exc:
|
|
98
123
|
print(f"error: could not start tmux client: {exc}", file=sys.stderr)
|
|
99
124
|
return 2
|
|
@@ -204,10 +229,6 @@ def _run_tmux_client_with_watchdog(
|
|
|
204
229
|
expected_session_id = tmux_server.target_session_id(
|
|
205
230
|
expected_target, "railmux", timeout=0.5)
|
|
206
231
|
|
|
207
|
-
from railmux.provider_paths import running_in_managed_windows_wrapper
|
|
208
|
-
|
|
209
|
-
managed_windows = running_in_managed_windows_wrapper()
|
|
210
|
-
|
|
211
232
|
can_relay = bool(
|
|
212
233
|
current_target == expected_target
|
|
213
234
|
and expected_session_id is not None
|
|
@@ -223,13 +244,7 @@ def _run_tmux_client_with_watchdog(
|
|
|
223
244
|
start_relay_client,
|
|
224
245
|
)
|
|
225
246
|
|
|
226
|
-
print(
|
|
227
|
-
"info: direct tmux attach was unavailable; reconnecting "
|
|
228
|
-
"through the Windows terminal bridge",
|
|
229
|
-
file=sys.stderr,
|
|
230
|
-
)
|
|
231
247
|
try:
|
|
232
|
-
tty.setraw(sys.stdin.fileno())
|
|
233
248
|
relay = start_relay_client(
|
|
234
249
|
target=expected_target,
|
|
235
250
|
session_id=expected_session_id,
|
|
@@ -237,6 +252,15 @@ def _run_tmux_client_with_watchdog(
|
|
|
237
252
|
stdin_fd=sys.stdin.fileno(),
|
|
238
253
|
stdout_fd=sys.stdout.fileno(),
|
|
239
254
|
)
|
|
255
|
+
print(
|
|
256
|
+
command_status(
|
|
257
|
+
"info",
|
|
258
|
+
"connected through the Windows terminal bridge",
|
|
259
|
+
stream=sys.stderr,
|
|
260
|
+
),
|
|
261
|
+
file=sys.stderr,
|
|
262
|
+
)
|
|
263
|
+
tty.setraw(sys.stdin.fileno())
|
|
240
264
|
process = relay
|
|
241
265
|
returncode, watchdog_failed = monitor_client(
|
|
242
266
|
asynchronous_probe=True)
|
|
@@ -296,6 +320,14 @@ def _run_tmux_client_with_watchdog(
|
|
|
296
320
|
),
|
|
297
321
|
consecutive_failures=1,
|
|
298
322
|
)
|
|
323
|
+
if not relay_attempted:
|
|
324
|
+
print(
|
|
325
|
+
"error: direct tmux attach was rejected and the "
|
|
326
|
+
"Windows terminal bridge was unavailable; the "
|
|
327
|
+
"existing workspace was left running; run "
|
|
328
|
+
"'railmux doctor' for diagnostics",
|
|
329
|
+
file=sys.stderr,
|
|
330
|
+
)
|
|
299
331
|
# Routine post-exit removal of a proof-authorized last-session socket
|
|
300
332
|
# is housekeeping. Only pre-launch recovery is user-visible.
|
|
301
333
|
windows_tmux_lifecycle.recover_abandoned_socket()
|
|
@@ -609,6 +641,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
609
641
|
launch_prefix,
|
|
610
642
|
raw_args,
|
|
611
643
|
env=client_env,
|
|
644
|
+
initial_size=_interactive_terminal_size(),
|
|
612
645
|
)
|
|
613
646
|
)
|
|
614
647
|
cmd = tmux_server.launcher_argv(launch_prefix, raw_args)
|
|
@@ -109,6 +109,10 @@ class _PendingHistory:
|
|
|
109
109
|
pane_id: str
|
|
110
110
|
target_lines: int
|
|
111
111
|
requested_at: float
|
|
112
|
+
# A first wheel-up waits for one cumulative snapshot before freezing the
|
|
113
|
+
# viewport. This avoids exposing a short hot suffix whose later deep page
|
|
114
|
+
# may not share a stable anchor while the provider is actively repainting.
|
|
115
|
+
initial_offset: int | None = None
|
|
112
116
|
|
|
113
117
|
|
|
114
118
|
class LocalHistoryView:
|
|
@@ -266,8 +270,6 @@ class LocalHistoryView:
|
|
|
266
270
|
self,
|
|
267
271
|
previous: HistorySnapshot,
|
|
268
272
|
incoming: HistorySnapshot,
|
|
269
|
-
*,
|
|
270
|
-
prefer_deeper: bool = False,
|
|
271
273
|
) -> HistorySnapshot:
|
|
272
274
|
"""Retain already-fetched history when a later hot capture shrinks."""
|
|
273
275
|
if not self._same_geometry(
|
|
@@ -276,28 +278,13 @@ class LocalHistoryView:
|
|
|
276
278
|
return incoming
|
|
277
279
|
delta = self._timeline_delta(previous.lines, incoming.lines)
|
|
278
280
|
if delta is None:
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
# cache; keeping only its live tail would silently discard all
|
|
287
|
-
# intermediate output above the current screen.
|
|
288
|
-
lines = incoming.lines
|
|
289
|
-
else:
|
|
290
|
-
# Never splice two captures whose timelines cannot be aligned.
|
|
291
|
-
# Retaining old history and replacing only the live viewport
|
|
292
|
-
# looked conservative, but it silently omitted every row
|
|
293
|
-
# produced between those two pieces. That false seam appeared
|
|
294
|
-
# as discontinuous SSH history until returning to the bottom
|
|
295
|
-
# forced a fresh deep capture. Keep the newest internally
|
|
296
|
-
# contiguous hot capture instead; the first wheel-up requests
|
|
297
|
-
# a cumulative deep page and can recover older rows safely.
|
|
298
|
-
# An already-frozen viewport owns its immutable snapshot and
|
|
299
|
-
# therefore remains undisturbed by this cache replacement.
|
|
300
|
-
lines = incoming.lines
|
|
281
|
+
# Never splice two captures whose timelines cannot be aligned.
|
|
282
|
+
# Retaining old history and replacing only the live viewport
|
|
283
|
+
# looked conservative, but it silently omitted every row produced
|
|
284
|
+
# between those two pieces. Keep the newest internally contiguous
|
|
285
|
+
# capture instead. An already-frozen viewport owns its immutable
|
|
286
|
+
# snapshot and therefore remains undisturbed by cache replacement.
|
|
287
|
+
lines = incoming.lines
|
|
301
288
|
else:
|
|
302
289
|
start = min(0, delta)
|
|
303
290
|
incoming_end = delta + len(incoming.lines)
|
|
@@ -325,8 +312,6 @@ class LocalHistoryView:
|
|
|
325
312
|
def _remember_content(
|
|
326
313
|
self,
|
|
327
314
|
snapshot: HistorySnapshot,
|
|
328
|
-
*,
|
|
329
|
-
prefer_deeper: bool = False,
|
|
330
315
|
) -> HistorySnapshot:
|
|
331
316
|
assert snapshot.pane_id is not None
|
|
332
317
|
previous = self.content_cache.get(snapshot.pane_id)
|
|
@@ -344,8 +329,7 @@ class LocalHistoryView:
|
|
|
344
329
|
stored = self._merge_content(previous, snapshot) if anchored else snapshot
|
|
345
330
|
self._unverified_after_reconnect.discard(snapshot.pane_id)
|
|
346
331
|
else:
|
|
347
|
-
stored = self._merge_content(
|
|
348
|
-
previous, snapshot, prefer_deeper=prefer_deeper)
|
|
332
|
+
stored = self._merge_content(previous, snapshot)
|
|
349
333
|
# Reinsert an existing pane to keep insertion order as recency order.
|
|
350
334
|
self.content_cache.pop(snapshot.pane_id, None)
|
|
351
335
|
self.content_cache[snapshot.pane_id] = stored
|
|
@@ -353,6 +337,15 @@ class LocalHistoryView:
|
|
|
353
337
|
del self.content_cache[next(iter(self.content_cache))]
|
|
354
338
|
return stored
|
|
355
339
|
|
|
340
|
+
def _replace_content(self, snapshot: HistorySnapshot) -> HistorySnapshot:
|
|
341
|
+
"""Install one verified contiguous capture without timeline merging."""
|
|
342
|
+
assert snapshot.pane_id is not None
|
|
343
|
+
self.content_cache.pop(snapshot.pane_id, None)
|
|
344
|
+
self.content_cache[snapshot.pane_id] = snapshot
|
|
345
|
+
while len(self.content_cache) > _HISTORY_CONTENT_PANES:
|
|
346
|
+
del self.content_cache[next(iter(self.content_cache))]
|
|
347
|
+
return snapshot
|
|
348
|
+
|
|
356
349
|
def invalidate_routes(self) -> bool:
|
|
357
350
|
"""Drop pointer authority without discarding bounded pane content."""
|
|
358
351
|
was_active = self.cancel()
|
|
@@ -470,29 +463,32 @@ class LocalHistoryView:
|
|
|
470
463
|
cached = self.content_cache.get(route.pane_id, route)
|
|
471
464
|
if not self._same_geometry(cached, route):
|
|
472
465
|
cached = route
|
|
466
|
+
loaded_limit = min(len(cached.lines), self.history_limit)
|
|
467
|
+
target_lines = min(self.history_limit, _HISTORY_INITIAL_LINES)
|
|
468
|
+
if (
|
|
469
|
+
cached.more_available
|
|
470
|
+
and loaded_limit < min(target_lines, _HISTORY_PREFETCH_LINES)
|
|
471
|
+
):
|
|
472
|
+
self.cancel_pane(route.pane_id)
|
|
473
|
+
request_id = self._allocate_request_id()
|
|
474
|
+
self._deep_pending[request_id] = _PendingHistory(
|
|
475
|
+
self.route_epoch,
|
|
476
|
+
route.pane_id,
|
|
477
|
+
target_lines,
|
|
478
|
+
time.monotonic() if now is None else now,
|
|
479
|
+
max(1, initial_offset),
|
|
480
|
+
)
|
|
481
|
+
self._deep_requests += 1
|
|
482
|
+
# A byte-budgeted or older peer may return less than the normal
|
|
483
|
+
# coherent routing capture. Keep the live pane intact until one
|
|
484
|
+
# cumulative response arrives rather than expose a short suffix.
|
|
485
|
+
return HistoryAction(
|
|
486
|
+
protocol_frame=encode_history_request(
|
|
487
|
+
request_id, event.x, event.y, target_lines
|
|
488
|
+
)
|
|
489
|
+
)
|
|
473
490
|
maximum = max(0, len(cached.lines) - cached.height)
|
|
474
491
|
if maximum == 0:
|
|
475
|
-
if cached.more_available:
|
|
476
|
-
self.cancel_pane(route.pane_id)
|
|
477
|
-
target_lines = min(self.history_limit, _HISTORY_INITIAL_LINES)
|
|
478
|
-
self.viewports[route.pane_id] = _HistoryViewport(
|
|
479
|
-
cached,
|
|
480
|
-
0,
|
|
481
|
-
min(len(cached.lines), self.history_limit),
|
|
482
|
-
)
|
|
483
|
-
request_id = self._allocate_request_id()
|
|
484
|
-
self._deep_pending[request_id] = _PendingHistory(
|
|
485
|
-
self.route_epoch,
|
|
486
|
-
route.pane_id,
|
|
487
|
-
target_lines,
|
|
488
|
-
time.monotonic() if now is None else now,
|
|
489
|
-
)
|
|
490
|
-
self._deep_requests += 1
|
|
491
|
-
return HistoryAction(
|
|
492
|
-
protocol_frame=encode_history_request(
|
|
493
|
-
request_id, event.x, event.y, target_lines
|
|
494
|
-
)
|
|
495
|
-
)
|
|
496
492
|
if cached.transcript_backed:
|
|
497
493
|
# Claude may not have written its first transcript record yet.
|
|
498
494
|
# This is a normal transient state, not a Railmux status event:
|
|
@@ -503,7 +499,6 @@ class LocalHistoryView:
|
|
|
503
499
|
forwarded_input=event.raw if cached.mouse_forwardable else b""
|
|
504
500
|
)
|
|
505
501
|
self.cancel_pane(route.pane_id)
|
|
506
|
-
loaded_limit = min(len(cached.lines), self.history_limit)
|
|
507
502
|
viewport = _HistoryViewport(
|
|
508
503
|
cached,
|
|
509
504
|
min(maximum, max(1, initial_offset)),
|
|
@@ -513,23 +508,15 @@ class LocalHistoryView:
|
|
|
513
508
|
),
|
|
514
509
|
)
|
|
515
510
|
self.viewports[route.pane_id] = viewport
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
render_history=True,
|
|
521
|
-
)
|
|
522
|
-
request_id = self._allocate_request_id()
|
|
523
|
-
self._deep_pending[request_id] = _PendingHistory(
|
|
524
|
-
self.route_epoch,
|
|
525
|
-
route.pane_id,
|
|
526
|
-
target_lines,
|
|
527
|
-
time.monotonic() if now is None else now,
|
|
528
|
-
)
|
|
529
|
-
self._deep_requests += 1
|
|
511
|
+
# A complete 300-row routing capture is already one coherent history
|
|
512
|
+
# source. Paint it synchronously, then fetch a cumulative page only as
|
|
513
|
+
# the viewport approaches its top. This removes an SSH round trip from
|
|
514
|
+
# the first wheel tick without reintroducing a hot/deep splice.
|
|
530
515
|
return HistoryAction(
|
|
531
|
-
protocol_frame=
|
|
532
|
-
|
|
516
|
+
protocol_frame=self._extend_history(
|
|
517
|
+
viewport,
|
|
518
|
+
now=now,
|
|
519
|
+
request_position=(event.x, event.y),
|
|
533
520
|
),
|
|
534
521
|
render_history=True,
|
|
535
522
|
)
|
|
@@ -539,6 +526,7 @@ class LocalHistoryView:
|
|
|
539
526
|
viewport: _HistoryViewport,
|
|
540
527
|
*,
|
|
541
528
|
now: float | None = None,
|
|
529
|
+
request_position: tuple[int, int] | None = None,
|
|
542
530
|
) -> bytes:
|
|
543
531
|
"""Request the next cumulative page when a viewport nears its top."""
|
|
544
532
|
snapshot = viewport.snapshot
|
|
@@ -572,7 +560,9 @@ class LocalHistoryView:
|
|
|
572
560
|
return b""
|
|
573
561
|
target_lines = min(
|
|
574
562
|
self.history_limit,
|
|
575
|
-
|
|
563
|
+
_HISTORY_INITIAL_LINES
|
|
564
|
+
if viewport.loaded_limit < _HISTORY_INITIAL_LINES
|
|
565
|
+
else viewport.loaded_limit + _HISTORY_PAGE_LINES,
|
|
576
566
|
)
|
|
577
567
|
request_id = self._allocate_request_id()
|
|
578
568
|
self._deep_pending[request_id] = _PendingHistory(
|
|
@@ -582,10 +572,14 @@ class LocalHistoryView:
|
|
|
582
572
|
requested_at,
|
|
583
573
|
)
|
|
584
574
|
self._deep_requests += 1
|
|
585
|
-
|
|
586
|
-
request_id,
|
|
575
|
+
request_x, request_y = request_position or (
|
|
587
576
|
snapshot.x + 1,
|
|
588
577
|
snapshot.y + 1,
|
|
578
|
+
)
|
|
579
|
+
return encode_history_request(
|
|
580
|
+
request_id,
|
|
581
|
+
request_x,
|
|
582
|
+
request_y,
|
|
589
583
|
target_lines,
|
|
590
584
|
)
|
|
591
585
|
|
|
@@ -602,6 +596,45 @@ class LocalHistoryView:
|
|
|
602
596
|
if direction > 0 and route.history_choice_required:
|
|
603
597
|
return HistoryAction(claude_history_prompt=event.raw)
|
|
604
598
|
viewport = self.viewports.get(route.pane_id)
|
|
599
|
+
initial_request = next(
|
|
600
|
+
(
|
|
601
|
+
(request_id, pending)
|
|
602
|
+
for request_id, pending in self._deep_pending.items()
|
|
603
|
+
if pending.pane_id == route.pane_id
|
|
604
|
+
and pending.initial_offset is not None
|
|
605
|
+
),
|
|
606
|
+
None,
|
|
607
|
+
)
|
|
608
|
+
if initial_request is not None:
|
|
609
|
+
request_id, pending = initial_request
|
|
610
|
+
requested_at = time.monotonic() if now is None else now
|
|
611
|
+
if requested_at - pending.requested_at >= _HISTORY_DEEP_TIMEOUT:
|
|
612
|
+
self._deep_pending.pop(request_id, None)
|
|
613
|
+
self._timeouts += 1
|
|
614
|
+
if direction < 0:
|
|
615
|
+
return HistoryAction()
|
|
616
|
+
assert pending.initial_offset is not None
|
|
617
|
+
distance = min(
|
|
618
|
+
self.history_limit,
|
|
619
|
+
pending.initial_offset + max(1, distance),
|
|
620
|
+
)
|
|
621
|
+
initial_request = None
|
|
622
|
+
if viewport is None and initial_request is not None:
|
|
623
|
+
if direction < 0:
|
|
624
|
+
# The live pane never left the screen, so reversing direction
|
|
625
|
+
# simply cancels the pending entry. A late response is ignored.
|
|
626
|
+
self.cancel_pane(route.pane_id)
|
|
627
|
+
return HistoryAction()
|
|
628
|
+
request_id, pending = initial_request
|
|
629
|
+
assert pending.initial_offset is not None
|
|
630
|
+
self._deep_pending[request_id] = replace(
|
|
631
|
+
pending,
|
|
632
|
+
initial_offset=min(
|
|
633
|
+
self.history_limit,
|
|
634
|
+
pending.initial_offset + max(1, distance),
|
|
635
|
+
),
|
|
636
|
+
)
|
|
637
|
+
return HistoryAction()
|
|
605
638
|
if viewport is not None:
|
|
606
639
|
maximum = max(0, len(viewport.snapshot.lines) - viewport.snapshot.height)
|
|
607
640
|
viewport.offset = max(
|
|
@@ -828,8 +861,26 @@ class LocalHistoryView:
|
|
|
828
861
|
return HistoryAction()
|
|
829
862
|
viewport = self.viewports.get(pending.pane_id)
|
|
830
863
|
if viewport is None:
|
|
831
|
-
|
|
832
|
-
|
|
864
|
+
if pending.initial_offset is None:
|
|
865
|
+
self._remember_content(snapshot)
|
|
866
|
+
return HistoryAction()
|
|
867
|
+
maximum = max(0, len(snapshot.lines) - snapshot.height)
|
|
868
|
+
if maximum == 0:
|
|
869
|
+
self._remember_content(snapshot)
|
|
870
|
+
return HistoryAction()
|
|
871
|
+
stored = self._replace_content(snapshot)
|
|
872
|
+
maximum = max(0, len(stored.lines) - stored.height)
|
|
873
|
+
viewport = _HistoryViewport(
|
|
874
|
+
stored,
|
|
875
|
+
min(maximum, pending.initial_offset),
|
|
876
|
+
min(self.history_limit, max(pending.target_lines, len(stored.lines))),
|
|
877
|
+
exhausted=not snapshot.more_available,
|
|
878
|
+
)
|
|
879
|
+
self.viewports[pending.pane_id] = viewport
|
|
880
|
+
return HistoryAction(
|
|
881
|
+
protocol_frame=self._extend_history(viewport),
|
|
882
|
+
render_history=True,
|
|
883
|
+
)
|
|
833
884
|
maximum = max(0, len(snapshot.lines) - snapshot.height)
|
|
834
885
|
if maximum == 0:
|
|
835
886
|
self._remember_content(snapshot)
|
|
@@ -843,7 +894,11 @@ class LocalHistoryView:
|
|
|
843
894
|
# snapshot instead of jumping to newer or unrelated text.
|
|
844
895
|
self._anchor_rejects += 1
|
|
845
896
|
return HistoryAction()
|
|
846
|
-
|
|
897
|
+
# The response is one cumulative capture and its exact visible anchor
|
|
898
|
+
# was just proved above. Replace the cached timeline as a whole instead
|
|
899
|
+
# of merging it with the older hot generation; this keeps every row's
|
|
900
|
+
# text and background styling from one server render.
|
|
901
|
+
stored = self._replace_content(snapshot)
|
|
847
902
|
stored_offset = self._aligned_offset(stored, anchor)
|
|
848
903
|
if stored_offset is None:
|
|
849
904
|
# The raw response was valid, so this can only be an ambiguous
|
|
@@ -892,7 +947,7 @@ class LocalHistoryView:
|
|
|
892
947
|
return matched_offset
|
|
893
948
|
|
|
894
949
|
# Agent status rows (for example a Codex spinner) can change between
|
|
895
|
-
# the
|
|
950
|
+
# the hot snapshot and a deep response. Requiring the
|
|
896
951
|
# entire visible viewport to remain byte-identical then strands the
|
|
897
952
|
# user at the hot-cache boundary. Align on a majority of stable,
|
|
898
953
|
# non-blank lines instead, but only when each evidence line occurs
|
|
@@ -1171,12 +1171,35 @@ def apply_path_open_request(
|
|
|
1171
1171
|
)
|
|
1172
1172
|
|
|
1173
1173
|
|
|
1174
|
-
def
|
|
1175
|
-
|
|
1174
|
+
def _render_history_lines(
|
|
1175
|
+
pyte: object,
|
|
1176
|
+
lines: Sequence[bytes],
|
|
1177
|
+
width: int,
|
|
1178
|
+
) -> tuple[bytes, ...]:
|
|
1179
|
+
"""Render physical tmux rows while retaining cross-row terminal style.
|
|
1180
|
+
|
|
1181
|
+
``tmux capture-pane -e`` is a terminal stream, not a collection of
|
|
1182
|
+
independently styled strings. In particular, tmux omits an SGR prefix
|
|
1183
|
+
when a row inherits the previous row's foreground, background, or text
|
|
1184
|
+
attributes. Keep one decoder alive for the ordered capture, but clear its
|
|
1185
|
+
one-row cell buffer between rows. :func:`render_rows` then makes every
|
|
1186
|
+
result independently paintable for the local history overlay.
|
|
1187
|
+
"""
|
|
1176
1188
|
screen = pyte.Screen(width, 1)
|
|
1177
1189
|
stream = pyte.ByteStream(screen)
|
|
1178
|
-
|
|
1179
|
-
|
|
1190
|
+
rendered: list[bytes] = []
|
|
1191
|
+
for line in lines:
|
|
1192
|
+
screen.buffer[0].clear()
|
|
1193
|
+
screen.cursor.x = 0
|
|
1194
|
+
screen.cursor.y = 0
|
|
1195
|
+
stream.feed(line)
|
|
1196
|
+
rendered.append(render_rows(screen)[0])
|
|
1197
|
+
return tuple(rendered)
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _render_history_line(pyte: object, line: bytes, width: int) -> bytes:
|
|
1201
|
+
"""Render one self-contained physical row with allowlisted SGR styling."""
|
|
1202
|
+
return _render_history_lines(pyte, (line,), width)[0]
|
|
1180
1203
|
|
|
1181
1204
|
|
|
1182
1205
|
def _read_transcript_tail(fd: int) -> tuple[str, bool]:
|
|
@@ -1352,7 +1375,8 @@ def _capture_pane_history(
|
|
|
1352
1375
|
# Retain the newest suffix when the styled representation reaches its byte
|
|
1353
1376
|
# budget. Iterate backwards so an oversized response never sacrifices the
|
|
1354
1377
|
# pane's current viewport merely to retain older scrollback.
|
|
1355
|
-
|
|
1378
|
+
rendered_raw = _render_history_lines(pyte, raw_lines, pane.width)
|
|
1379
|
+
current_raw = rendered_raw[-pane.height :]
|
|
1356
1380
|
transcript_entry = None
|
|
1357
1381
|
# ``None`` is retained only for old/test geometry that predates the
|
|
1358
1382
|
# provider field; every validated live marker carries an explicit value.
|
|
@@ -1383,21 +1407,14 @@ def _capture_pane_history(
|
|
|
1383
1407
|
transcript_lines = transcript_entry.rows
|
|
1384
1408
|
history_count = max(0, max_lines - pane.height)
|
|
1385
1409
|
source_lines = (
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
else ()
|
|
1389
|
-
) + tuple((line, False) for line in current_raw)
|
|
1410
|
+
transcript_lines[-history_count:] if history_count else ()
|
|
1411
|
+
) + current_raw
|
|
1390
1412
|
else:
|
|
1391
|
-
source_lines =
|
|
1413
|
+
source_lines = rendered_raw
|
|
1392
1414
|
newest_first: list[bytes] = []
|
|
1393
1415
|
packed_size = 2 # history line-count prefix
|
|
1394
1416
|
budget_truncated = False
|
|
1395
|
-
for
|
|
1396
|
-
rendered = (
|
|
1397
|
-
raw_line
|
|
1398
|
-
if rendered_already
|
|
1399
|
-
else _render_history_line(pyte, raw_line, pane.width)
|
|
1400
|
-
)
|
|
1417
|
+
for rendered in reversed(source_lines[-max_lines:]):
|
|
1401
1418
|
line_size = 4 + len(rendered)
|
|
1402
1419
|
if packed_size + line_size > _HISTORY_SNAPSHOT_RAW_BUDGET:
|
|
1403
1420
|
budget_truncated = True
|
|
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
10
|
import re
|
|
11
|
-
import signal
|
|
12
11
|
import shlex
|
|
13
12
|
import shutil
|
|
14
13
|
import subprocess
|
|
@@ -1849,7 +1848,13 @@ RAILMUX_HISTORY_GENERATION_OPTION = "@railmux_history_generation_v1"
|
|
|
1849
1848
|
# A confirmed branch transition prefixes the opaque SSH history cache epoch.
|
|
1850
1849
|
# Plain generations use native pane history; only this explicit marker permits
|
|
1851
1850
|
# the canonical transcript fallback that removes an abandoned rewind suffix.
|
|
1852
|
-
|
|
1851
|
+
# Only the evidence-gated v2 marker authorizes transcript-backed Codex
|
|
1852
|
+
# scrolling. The released v1 marker treated every live direct child rollout
|
|
1853
|
+
# as a rewind, but Codex can also create that shape while bootstrapping an
|
|
1854
|
+
# ordinary resume. Keeping the prefix versioned makes existing over-broad
|
|
1855
|
+
# markers fail back to raw pane history on upgrade.
|
|
1856
|
+
RAILMUX_CANONICAL_HISTORY_PREFIX = "canonical-v2:"
|
|
1857
|
+
RAILMUX_LEGACY_CANONICAL_HISTORY_PREFIX = "canonical:"
|
|
1853
1858
|
# tmux names function keys only through F12. Private restore routes use
|
|
1854
1859
|
# canonical xterm sequences in literal mode, so they cannot be confused with
|
|
1855
1860
|
# user input forwarded to an agent pane.
|
|
@@ -3432,45 +3437,6 @@ def exact_pane_alive(identity: PaneIdentity) -> bool:
|
|
|
3432
3437
|
)
|
|
3433
3438
|
|
|
3434
3439
|
|
|
3435
|
-
def reset_pane_view(identity: PaneIdentity) -> bool:
|
|
3436
|
-
"""Reset visible terminal state without deleting retained scrollback.
|
|
3437
|
-
|
|
3438
|
-
Revalidate the immutable session, pane process, and window before issuing
|
|
3439
|
-
tmux's terminal reset. Then send the pane's foreground process group an
|
|
3440
|
-
ordinary same-size ``SIGWINCH`` so its TUI repaints and reasserts terminal
|
|
3441
|
-
modes without a geometry change or injected keyboard input. The reset can
|
|
3442
|
-
push the old viewport into tmux history, but never destroys the retained
|
|
3443
|
-
prefix; managed history surfaces use the provider's canonical transcript.
|
|
3444
|
-
"""
|
|
3445
|
-
current = pane_identity(identity.pane_id)
|
|
3446
|
-
if (current is None or current.dead
|
|
3447
|
-
or current.pane_pid != identity.pane_pid
|
|
3448
|
-
or current.session_id != identity.session_id
|
|
3449
|
-
or current.window_id != identity.window_id):
|
|
3450
|
-
return False
|
|
3451
|
-
try:
|
|
3452
|
-
subprocess.check_call(
|
|
3453
|
-
["tmux", "send-keys", "-R", "-t", identity.pane_id],
|
|
3454
|
-
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
3455
|
-
)
|
|
3456
|
-
except (OSError, subprocess.CalledProcessError):
|
|
3457
|
-
return False
|
|
3458
|
-
|
|
3459
|
-
current = pane_identity(identity.pane_id)
|
|
3460
|
-
if (current is None or current.dead
|
|
3461
|
-
or current.pane_pid != identity.pane_pid
|
|
3462
|
-
or current.session_id != identity.session_id
|
|
3463
|
-
or current.window_id != identity.window_id):
|
|
3464
|
-
return True
|
|
3465
|
-
try:
|
|
3466
|
-
os.killpg(os.getpgid(current.pane_pid), signal.SIGWINCH)
|
|
3467
|
-
except (OSError, ValueError):
|
|
3468
|
-
# The presentation cache is already clean. A provider that exited or
|
|
3469
|
-
# cannot be signalled will repaint on its next ordinary terminal write.
|
|
3470
|
-
pass
|
|
3471
|
-
return True
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
3440
|
def kill_session_identity(identity: PaneIdentity) -> bool:
|
|
3475
3441
|
"""Kill an exact immutable tmux session, never a reused session name."""
|
|
3476
3442
|
current = pane_identity(identity.pane_id)
|