railmux 0.3.3.dev2__tar.gz → 0.3.3.dev3__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 (160) hide show
  1. {railmux-0.3.3.dev2/src/railmux.egg-info → railmux-0.3.3.dev3}/PKG-INFO +19 -11
  2. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/README.md +18 -10
  3. railmux-0.3.3.dev3/src/railmux/__init__.py +1 -0
  4. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/mouse_manager.py +8 -0
  5. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/tmux_ctl.py +82 -7
  6. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/transcript.py +83 -28
  7. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ui/app.py +141 -15
  8. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ui/modals.py +1 -1
  9. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3/src/railmux.egg-info}/PKG-INFO +19 -11
  10. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_focus_highlight.py +135 -8
  11. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_mouse_manager.py +17 -0
  12. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_release_workflow.py +22 -8
  13. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_soft_quit.py +42 -0
  14. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_tmux_ctl.py +28 -0
  15. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_tmux_integration.py +1 -0
  16. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_transcript.py +84 -0
  17. railmux-0.3.3.dev2/src/railmux/__init__.py +0 -1
  18. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/LICENSE +0 -0
  19. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/MANIFEST.in +0 -0
  20. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/pyproject.toml +0 -0
  21. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/setup.cfg +0 -0
  22. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/__main__.py +0 -0
  23. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/atomic_file.py +0 -0
  24. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/background_index.py +0 -0
  25. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/cli.py +0 -0
  26. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/codex_index.py +0 -0
  27. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/config.py +0 -0
  28. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/config_cli.py +0 -0
  29. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/diagnostics.py +0 -0
  30. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/discovery.py +0 -0
  31. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/display_transport.py +0 -0
  32. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/fast_display_client.py +0 -0
  33. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/fast_display_history.py +0 -0
  34. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/fast_display_input.py +0 -0
  35. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/fast_display_protocol.py +0 -0
  36. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/fast_display_server.py +0 -0
  37. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/favorites.py +0 -0
  38. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/fuzzy.py +0 -0
  39. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/help_workspace.py +0 -0
  40. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/launcher.py +0 -0
  41. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/legacy_sessions.py +0 -0
  42. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/local_clipboard.py +0 -0
  43. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/local_open.py +0 -0
  44. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/models.py +0 -0
  45. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/modes.py +0 -0
  46. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/orphan_marker.py +0 -0
  47. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/pane_surface.py +0 -0
  48. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/path_codec.py +0 -0
  49. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/remote_config.py +0 -0
  50. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/renames.py +0 -0
  51. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/restart_state.py +0 -0
  52. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/runtime_config.py +0 -0
  53. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/scroll_agent.py +0 -0
  54. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/scroll_manager.py +0 -0
  55. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/selection_isolation.py +0 -0
  56. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/self_update.py +0 -0
  57. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/session_cache.py +0 -0
  58. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/session_index.py +0 -0
  59. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/setting_contracts.py +0 -0
  60. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/settings.py +0 -0
  61. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ssh_args.py +0 -0
  62. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ssh_compat.py +0 -0
  63. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ssh_display_diagnostics.py +0 -0
  64. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ssh_doctor.py +0 -0
  65. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/system_deps.py +0 -0
  66. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/terminal_status.py +0 -0
  67. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/tmux_binding_manager.py +0 -0
  68. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/tmux_health.py +0 -0
  69. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/tmux_server.py +0 -0
  70. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/tool_panes.py +0 -0
  71. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ui/__init__.py +0 -0
  72. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ui/_widgets.py +0 -0
  73. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ui/keymap.py +0 -0
  74. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ui/projects_pane.py +0 -0
  75. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ui/running_pane.py +0 -0
  76. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ui/sessions_pane.py +0 -0
  77. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ui/sidebar.py +0 -0
  78. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ui/statusbar.py +0 -0
  79. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux/ui/workspace.py +0 -0
  80. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux.egg-info/SOURCES.txt +0 -0
  81. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux.egg-info/dependency_links.txt +0 -0
  82. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux.egg-info/entry_points.txt +0 -0
  83. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux.egg-info/requires.txt +0 -0
  84. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/src/railmux.egg-info/top_level.txt +0 -0
  85. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_agent_workspace.py +0 -0
  86. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_atomic_file.py +0 -0
  87. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_background_index.py +0 -0
  88. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_cli.py +0 -0
  89. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_codex_delete.py +0 -0
  90. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_codex_index.py +0 -0
  91. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_codex_lineage.py +0 -0
  92. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_codex_transcript.py +0 -0
  93. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_config.py +0 -0
  94. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_config_cli.py +0 -0
  95. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_denested_transport_benchmark.py +0 -0
  96. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_diagnostics.py +0 -0
  97. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_discovery.py +0 -0
  98. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_display_transport.py +0 -0
  99. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_fast_full_window.py +0 -0
  100. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_fullscreen_binding.py +0 -0
  101. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_fuzzy.py +0 -0
  102. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_help_agent.py +0 -0
  103. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_help_workspace.py +0 -0
  104. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_history_cleanup.py +0 -0
  105. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_keymap.py +0 -0
  106. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_launcher.py +0 -0
  107. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_layout_profile.py +0 -0
  108. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_legacy_sessions.py +0 -0
  109. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_local_clipboard.py +0 -0
  110. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_local_open.py +0 -0
  111. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_modals.py +0 -0
  112. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_mode_selection.py +0 -0
  113. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_modes.py +0 -0
  114. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_new_project.py +0 -0
  115. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_options.py +0 -0
  116. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_orphan_recovery.py +0 -0
  117. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_pane_surface.py +0 -0
  118. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_path_codec.py +0 -0
  119. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_projects_pane.py +0 -0
  120. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_release_notes.py +0 -0
  121. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_remote_config.py +0 -0
  122. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_renames.py +0 -0
  123. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_restart_state.py +0 -0
  124. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_running_pane.py +0 -0
  125. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_running_sort.py +0 -0
  126. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_runtime_config.py +0 -0
  127. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_scroll_agent.py +0 -0
  128. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_scroll_manager.py +0 -0
  129. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_selection_isolation.py +0 -0
  130. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_self_update.py +0 -0
  131. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_session_cache.py +0 -0
  132. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_session_index.py +0 -0
  133. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_sessions_pane.py +0 -0
  134. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_setting_contracts.py +0 -0
  135. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_settings.py +0 -0
  136. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_sidebar.py +0 -0
  137. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_ssh_args.py +0 -0
  138. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_ssh_compat.py +0 -0
  139. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_ssh_display_benchmark.py +0 -0
  140. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_ssh_display_diagnostics.py +0 -0
  141. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_ssh_doctor.py +0 -0
  142. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_status.py +0 -0
  143. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_statusbar.py +0 -0
  144. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_system_deps.py +0 -0
  145. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_terminal_size.py +0 -0
  146. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_terminal_status.py +0 -0
  147. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_termux_local_touch.py +0 -0
  148. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_tmux_binding_manager.py +0 -0
  149. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_tmux_health.py +0 -0
  150. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_tmux_server.py +0 -0
  151. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_tmux_status.py +0 -0
  152. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_tool_panes.py +0 -0
  153. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_utf8_state.py +0 -0
  154. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_web_demo_recorder.py +0 -0
  155. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_wheel_smoke.py +0 -0
  156. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_widgets.py +0 -0
  157. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tests/test_yolo_prompt.py +0 -0
  158. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tools/denested_transport_benchmark.py +0 -0
  159. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tools/release_notes.py +0 -0
  160. {railmux-0.3.3.dev2 → railmux-0.3.3.dev3}/tools/wheel_smoke.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.3.3.dev3
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
@@ -169,12 +169,14 @@ for focus and layout limits.
169
169
  |--------|--------|
170
170
  | Left-click (non-running) | Preview session history in the Target pane |
171
171
  | Left-click (running) | Switch the Target pane to that session |
172
+ | Wheel up over a live agent | Open its canonical current-branch history |
172
173
  | Double-click | Open/attach in the Target pane and move focus there |
173
174
  | Right-click | Context menu (Open, Preview, Info, Rename, Star, Copy title, Kill, Term, Delete) |
174
175
 
175
176
  Unavailable context-menu actions are hidden for the selected session state.
176
- Single-click and `␣` act in the Target pane without moving keyboard focus;
177
- double-click and `Enter` also move focus there. The terminal must report mouse
177
+ Single-click and `␣` act in the Target pane without moving keyboard focus, but
178
+ `␣` always means history while a click on a running row means live attach.
179
+ Double-click and `Enter` also move focus there. The terminal must report mouse
178
180
  buttons to applications for these actions to reach Railmux. Right-click
179
181
  reporting is sometimes a separate setting from ordinary mouse reporting; see
180
182
  [FAQ 2](#2-mouse-buttons-or-f8f9-dont-work--whats-wrong).
@@ -207,7 +209,7 @@ see [FAQ 1](#1-how-do-i-copy-text-from-the-agent-pane).
207
209
  | `T` | Return to that agent's managed Vim viewer, when open |
208
210
  | `m` | Switch agent mode (Claude Code ⇄ Codex) |
209
211
  | `o` | Open persistent Railmux options |
210
- | `␣` | Preview stopped or switch running target (like single-click) |
212
+ | `␣` | Preview canonical history for the selected stopped or running session |
211
213
  | `F8` | Cycle agent layout: single → side-by-side → stacked |
212
214
  | `F9` | Fullscreen the focused agent, managed terminal, or Vim (toggle) |
213
215
  | `?` | Full help popup with all keybindings |
@@ -299,7 +301,8 @@ to single leaves Pane 2's agent running in the background.
299
301
 
300
302
  In a split, focus an agent pane to make it the Target pane. After focus returns
301
303
  to the sidebar, single-click or `␣` acts in that pane without moving keyboard
302
- focus; double-click or `Enter` opens there and transfers focus. The status bar
304
+ focus; a running-row click attaches live while `␣` previews history.
305
+ Double-click or `Enter` opens there and transfers focus. The status bar
303
306
  at the bottom-left shows the current layout and Target pane:
304
307
 
305
308
  | Symbol | Meaning |
@@ -363,15 +366,20 @@ sessions move ahead of the other results.
363
366
  ## History preview
364
367
 
365
368
  For a stopped session, left-click or press `␣` to view conversation history in
366
- the Target pane without starting or resuming the agent. Preview is read-only: it
367
- cannot send a message or change the session. User and assistant messages, tool
368
- calls, and abbreviated tool output are colour-coded, while internal context and
369
- encrypted reasoning are hidden.
369
+ the Target pane without starting or resuming the agent. For a running session,
370
+ press `␣`, choose **Preview**, or wheel up over its live pane. Preview is
371
+ read-only: it cannot send a message or change the session. User and assistant
372
+ messages, tool calls, and abbreviated tool output are colour-coded, while
373
+ internal context and encrypted reasoning are hidden. Codex rewind lineages and
374
+ Claude Code `parentUuid` branches are projected onto the provider's current
375
+ conversation: the retained prefix and replacement suffix remain visible, while
376
+ the abandoned suffix is hidden.
370
377
 
371
378
  Preview opens at the latest activity in `less`; large sessions are limited to
372
379
  their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
373
- matches, and `q` to exit and restore the pane. Double-click to skip preview and
374
- open the session directly.
380
+ matches, and `q` to exit. If preview began from a live pane, `q` restores that
381
+ exact session; otherwise it restores the prior Target pane. Double-click to
382
+ skip preview and open the session directly.
375
383
 
376
384
  ## Status indicators
377
385
 
@@ -127,12 +127,14 @@ for focus and layout limits.
127
127
  |--------|--------|
128
128
  | Left-click (non-running) | Preview session history in the Target pane |
129
129
  | Left-click (running) | Switch the Target pane to that session |
130
+ | Wheel up over a live agent | Open its canonical current-branch history |
130
131
  | Double-click | Open/attach in the Target pane and move focus there |
131
132
  | Right-click | Context menu (Open, Preview, Info, Rename, Star, Copy title, Kill, Term, Delete) |
132
133
 
133
134
  Unavailable context-menu actions are hidden for the selected session state.
134
- Single-click and `␣` act in the Target pane without moving keyboard focus;
135
- double-click and `Enter` also move focus there. The terminal must report mouse
135
+ Single-click and `␣` act in the Target pane without moving keyboard focus, but
136
+ `␣` always means history while a click on a running row means live attach.
137
+ Double-click and `Enter` also move focus there. The terminal must report mouse
136
138
  buttons to applications for these actions to reach Railmux. Right-click
137
139
  reporting is sometimes a separate setting from ordinary mouse reporting; see
138
140
  [FAQ 2](#2-mouse-buttons-or-f8f9-dont-work--whats-wrong).
@@ -165,7 +167,7 @@ see [FAQ 1](#1-how-do-i-copy-text-from-the-agent-pane).
165
167
  | `T` | Return to that agent's managed Vim viewer, when open |
166
168
  | `m` | Switch agent mode (Claude Code ⇄ Codex) |
167
169
  | `o` | Open persistent Railmux options |
168
- | `␣` | Preview stopped or switch running target (like single-click) |
170
+ | `␣` | Preview canonical history for the selected stopped or running session |
169
171
  | `F8` | Cycle agent layout: single → side-by-side → stacked |
170
172
  | `F9` | Fullscreen the focused agent, managed terminal, or Vim (toggle) |
171
173
  | `?` | Full help popup with all keybindings |
@@ -257,7 +259,8 @@ to single leaves Pane 2's agent running in the background.
257
259
 
258
260
  In a split, focus an agent pane to make it the Target pane. After focus returns
259
261
  to the sidebar, single-click or `␣` acts in that pane without moving keyboard
260
- focus; double-click or `Enter` opens there and transfers focus. The status bar
262
+ focus; a running-row click attaches live while `␣` previews history.
263
+ Double-click or `Enter` opens there and transfers focus. The status bar
261
264
  at the bottom-left shows the current layout and Target pane:
262
265
 
263
266
  | Symbol | Meaning |
@@ -321,15 +324,20 @@ sessions move ahead of the other results.
321
324
  ## History preview
322
325
 
323
326
  For a stopped session, left-click or press `␣` to view conversation history in
324
- the Target pane without starting or resuming the agent. Preview is read-only: it
325
- cannot send a message or change the session. User and assistant messages, tool
326
- calls, and abbreviated tool output are colour-coded, while internal context and
327
- encrypted reasoning are hidden.
327
+ the Target pane without starting or resuming the agent. For a running session,
328
+ press `␣`, choose **Preview**, or wheel up over its live pane. Preview is
329
+ read-only: it cannot send a message or change the session. User and assistant
330
+ messages, tool calls, and abbreviated tool output are colour-coded, while
331
+ internal context and encrypted reasoning are hidden. Codex rewind lineages and
332
+ Claude Code `parentUuid` branches are projected onto the provider's current
333
+ conversation: the retained prefix and replacement suffix remain visible, while
334
+ the abandoned suffix is hidden.
328
335
 
329
336
  Preview opens at the latest activity in `less`; large sessions are limited to
330
337
  their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
331
- matches, and `q` to exit and restore the pane. Double-click to skip preview and
332
- open the session directly.
338
+ matches, and `q` to exit. If preview began from a live pane, `q` restores that
339
+ exact session; otherwise it restores the prior Target pane. Double-click to
340
+ skip preview and open the session directly.
333
341
 
334
342
  ## Status indicators
335
343
 
@@ -0,0 +1 @@
1
+ __version__ = "0.3.3.dev3"
@@ -157,6 +157,14 @@ class RootWheelForwardingManager:
157
157
  state["backup"], token=token)
158
158
  self._remove_state()
159
159
  state = None
160
+ elif not tmux_ctl.root_wheel_bindings_are_current(token):
161
+ # The ownership transaction can survive a soft upgrade
162
+ # in the same tmux pane. Refresh only an exactly-owned
163
+ # wrapper so the new pane-local history route becomes
164
+ # active without treating it as a user binding.
165
+ if not tmux_ctl.set_root_wheel_forwarding(
166
+ state["backup"], token):
167
+ return False
160
168
  if state is not None:
161
169
  state["owners"] = sorted(
162
170
  {*state["owners"], self._owner_pane_id})
@@ -1817,6 +1817,18 @@ _PREFIX_TARGET_KEY = "Tab"
1817
1817
  _PREFIX_TARGET_MARKER = "railmux-target-toggle-v1"
1818
1818
  RAILMUX_CONTROLLER_OPTION = "@railmux_controller_pane"
1819
1819
  RAILMUX_TARGET_OPTION = "@railmux_target_pane"
1820
+ RAILMUX_HISTORY_PREVIEW_OPTION = "@railmux_history_preview_v1"
1821
+ # tmux names function keys only through F12. Private controller routes use
1822
+ # canonical xterm sequences in literal mode, so they cannot be confused with
1823
+ # user input forwarded to an agent pane.
1824
+ RAILMUX_HISTORY_PREVIEW_KEYS = {
1825
+ "primary": ("F21", "\x1b[35~"),
1826
+ "secondary": ("F22", "\x1b[36~"),
1827
+ }
1828
+ RAILMUX_HISTORY_RESTORE_KEYS = {
1829
+ "primary": ("F23", "\x1b[42~"),
1830
+ "secondary": ("F24", "\x1b[43~"),
1831
+ }
1820
1832
  RAILMUX_SELECTION_KEY_OPTION = "@railmux_selection_key"
1821
1833
  RAILMUX_SELECTION_PEER_OPTION = "@railmux_selection_peer"
1822
1834
  RAILMUX_SELECTION_FROZEN_OPTION = "@railmux_selection_frozen_by"
@@ -2145,28 +2157,69 @@ def set_root_wheel_forwarding(
2145
2157
  backup: RootWheelBindingBackup,
2146
2158
  token: str,
2147
2159
  ) -> bool:
2148
- """Forward both wheel directions to mouse-aware pane applications.
2160
+ """Open canonical live history on wheel-up, otherwise preserve tmux input.
2149
2161
 
2150
2162
  The literal marker makes crash recovery able to distinguish Railmux's
2151
2163
  wrapper from a binding the user installed while Railmux was running.
2164
+ A pane-local primary/secondary marker routes the first live-agent wheel-up
2165
+ to Railmux's controller; the transcript viewer clears that marker, so its
2166
+ subsequent wheel events are forwarded normally.
2152
2167
  Non-mouse-aware panes retain the exact stock fallback; WheelDownPane was
2153
2168
  unbound by default and therefore has no false branch.
2154
2169
  """
2155
2170
  marker = f"{_ROOT_WHEEL_MARKER}-{token}"
2156
- condition = (
2171
+ mouse_condition = (
2157
2172
  "#{&&:#{mouse_any_flag},"
2158
2173
  f"#{{==:{marker},{marker}}}}}"
2159
2174
  )
2175
+ controller_present = f"#{{!=:#{{{RAILMUX_CONTROLLER_OPTION}}},}}"
2176
+
2177
+ def _preview_condition(slot_key: str) -> str:
2178
+ return (
2179
+ "#{&&:"
2180
+ f"#{{==:#{{{RAILMUX_HISTORY_PREVIEW_OPTION}}},{slot_key}}},"
2181
+ f"{controller_present}}}"
2182
+ )
2183
+
2184
+ def _send_preview(slot_key: str) -> str:
2185
+ _key, sequence = RAILMUX_HISTORY_PREVIEW_KEYS[slot_key]
2186
+ return (
2187
+ f"send-keys -l -t '#{{{RAILMUX_CONTROLLER_OPTION}}}' -- "
2188
+ f"'{sequence}'"
2189
+ )
2190
+
2160
2191
  try:
2161
2192
  for key in _ROOT_WHEEL_KEYS:
2193
+ binding = backup.get(key)
2194
+ fallback = _binding_command(binding) if binding is not None else ""
2195
+ mouse_fallback = (
2196
+ f"if-shell -F -t = '{mouse_condition}' 'send-keys -M'"
2197
+ )
2198
+ if fallback:
2199
+ mouse_fallback += f" '{fallback}'"
2162
2200
  args = [
2163
2201
  "tmux", "bind-key", "-T", "root", key,
2164
- "if-shell", "-F", "-t", "=", condition,
2165
- "send-keys -M",
2166
2202
  ]
2167
- binding = backup.get(key)
2168
- if binding is not None:
2169
- args.append(_binding_command(binding))
2203
+ if key == "WheelUpPane":
2204
+ secondary_fallback = (
2205
+ "if-shell -F -t = "
2206
+ f"'{_preview_condition('secondary')}' "
2207
+ f"\"{_send_preview('secondary')}\" "
2208
+ f"\"{mouse_fallback}\""
2209
+ )
2210
+ args.extend([
2211
+ "if-shell", "-F", "-t", "=",
2212
+ _preview_condition("primary"),
2213
+ _send_preview("primary"),
2214
+ secondary_fallback,
2215
+ ])
2216
+ else:
2217
+ args.extend([
2218
+ "if-shell", "-F", "-t", "=", mouse_condition,
2219
+ "send-keys -M",
2220
+ ])
2221
+ if fallback:
2222
+ args.append(fallback)
2170
2223
  subprocess.check_call(
2171
2224
  args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
2172
2225
  except (ValueError, subprocess.CalledProcessError, FileNotFoundError):
@@ -2186,6 +2239,28 @@ def root_wheel_bindings_owned_by(token: str) -> bool:
2186
2239
  )
2187
2240
 
2188
2241
 
2242
+ def root_wheel_bindings_are_current(token: str) -> bool:
2243
+ """Whether the owned wrapper includes canonical-history routing."""
2244
+ bindings = read_root_wheel_bindings()
2245
+ up = bindings.get("WheelUpPane")
2246
+ marker = f"{_ROOT_WHEEL_MARKER}-{token}"
2247
+ return (
2248
+ all(
2249
+ binding is not None
2250
+ and marker in binding
2251
+ and "mouse_any_flag" in binding
2252
+ and "send-keys -M" in binding
2253
+ for binding in bindings.values()
2254
+ )
2255
+ and up is not None
2256
+ and RAILMUX_HISTORY_PREVIEW_OPTION in up
2257
+ and all(
2258
+ sequence in up or sequence.replace("\x1b", r"\033") in up
2259
+ for _key, sequence in RAILMUX_HISTORY_PREVIEW_KEYS.values()
2260
+ )
2261
+ )
2262
+
2263
+
2189
2264
  def root_wheel_binding_is_original_or_owned(
2190
2265
  key: str,
2191
2266
  binding: str | None,
@@ -295,6 +295,81 @@ def _render_claude_native_tool_results(
295
295
  yield RESET
296
296
 
297
297
 
298
+ def _claude_current_branch(records: list[dict]) -> list[dict]:
299
+ """Project an append-only Claude JSONL onto its current main branch.
300
+
301
+ Claude Code links semantic records with ``uuid`` / ``parentUuid``. Editing
302
+ or rewinding a prompt appends a second child instead of removing the old
303
+ suffix, so file order is not conversation order. The newest non-sidechain
304
+ UUID is the active tip; walking its parents retains the shared prefix and
305
+ the replacement suffix while excluding abandoned and sub-agent branches.
306
+
307
+ UUID-less metadata is retained for the normal renderer filters. If the
308
+ input is a bounded tail, walking stops safely at the first missing parent.
309
+ """
310
+ by_uuid: dict[str, dict] = {}
311
+ tip: str | None = None
312
+ for record in records:
313
+ uuid = record.get("uuid")
314
+ if not isinstance(uuid, str) or not uuid:
315
+ continue
316
+ by_uuid[uuid] = record
317
+ if record.get("isSidechain") is not True:
318
+ tip = uuid
319
+ if tip is None:
320
+ return records
321
+
322
+ current: set[str] = set()
323
+ cursor: str | None = tip
324
+ while cursor and cursor not in current:
325
+ record = by_uuid.get(cursor)
326
+ if record is None:
327
+ break
328
+ current.add(cursor)
329
+ parent = record.get("parentUuid")
330
+ cursor = parent if isinstance(parent, str) and parent else None
331
+
332
+ return [
333
+ record for record in records
334
+ if not isinstance(record.get("uuid"), str)
335
+ or not record.get("uuid")
336
+ or record["uuid"] in current
337
+ ]
338
+
339
+
340
+ def _render_claude_records(
341
+ records: list[dict], *, claude_native: bool,
342
+ ):
343
+ """Render only the active Claude branch from buffered JSONL records."""
344
+ calls: dict[str, str] = {}
345
+ for record in _claude_current_branch(records):
346
+ rtype = record.get("type", "")
347
+ if rtype in _SKIP_TYPES:
348
+ continue
349
+ if _is_real_user(record):
350
+ rendered = (
351
+ _render_claude_native_user(record)
352
+ if claude_native
353
+ else _render_user(record)
354
+ )
355
+ if rendered:
356
+ yield rendered
357
+ if rtype == "user":
358
+ renderer = (
359
+ _render_claude_native_tool_results
360
+ if claude_native
361
+ else _render_claude_tool_results
362
+ )
363
+ yield from renderer(record, calls)
364
+ elif rtype == "assistant":
365
+ renderer = (
366
+ _render_claude_native_assistant
367
+ if claude_native
368
+ else _render_assistant_blocks
369
+ )
370
+ yield from renderer(record, calls)
371
+
372
+
298
373
  def format_transcript(
299
374
  source: Path | object,
300
375
  fmt: str | None = None,
@@ -326,7 +401,10 @@ def format_transcript(
326
401
  # call_id -> tool name, so Codex tool outputs can be labelled with the
327
402
  # call that produced them (issue #3).
328
403
  codex_calls: dict[str, str] = {}
329
- claude_calls: dict[str, str] = {}
404
+ # Claude rewind/edit branches live in one append-only JSONL, so those
405
+ # records must be buffered until the active tip is known. Production
406
+ # callers bound the input to the latest 2,000 records.
407
+ claude_records: list[dict] = []
330
408
  try:
331
409
  if isinstance(source, Path):
332
410
  fh = open(source, "r", encoding="utf-8")
@@ -360,33 +438,10 @@ def format_transcript(
360
438
  if _codex:
361
439
  yield from _render_codex(record, codex_calls)
362
440
  continue
363
-
364
- # -- Claude format (legacy) --------------------------------
365
- if rtype in _SKIP_TYPES:
366
- continue
367
-
368
- if _is_real_user(record):
369
- rendered = (
370
- _render_claude_native_user(record)
371
- if claude_native
372
- else _render_user(record)
373
- )
374
- if rendered:
375
- yield rendered
376
- if rtype == "user":
377
- renderer = (
378
- _render_claude_native_tool_results
379
- if claude_native
380
- else _render_claude_tool_results
381
- )
382
- yield from renderer(record, claude_calls)
383
- elif rtype == "assistant":
384
- renderer = (
385
- _render_claude_native_assistant
386
- if claude_native
387
- else _render_assistant_blocks
388
- )
389
- yield from renderer(record, claude_calls)
441
+ claude_records.append(record)
442
+ if _codex is False:
443
+ yield from _render_claude_records(
444
+ claude_records, claude_native=claude_native)
390
445
  except OSError:
391
446
  yield f"{YELLOW}Could not read: {source}{RESET}\n"
392
447
  finally:
@@ -1072,7 +1072,7 @@ class App:
1072
1072
  session_id: str | None,
1073
1073
  tmux_name: str | None,
1074
1074
  ) -> None:
1075
- """Stamp the exact Claude transcript used by fast SSH local history."""
1075
+ """Stamp transcript/history routing for the displayed live agent."""
1076
1076
  pane_id = slot.pane_id
1077
1077
  if pane_id is None or not pane_id.startswith("%"):
1078
1078
  return
@@ -1098,6 +1098,19 @@ class App:
1098
1098
  tmux_server.TRANSCRIPT_SOURCE_OPTION,
1099
1099
  marker,
1100
1100
  )
1101
+ history_slot = None
1102
+ if (
1103
+ running is not None
1104
+ and running.session_type in ("claude", "codex")
1105
+ and logical_id is not None
1106
+ and not running.is_placeholder
1107
+ ):
1108
+ history_slot = slot.key
1109
+ tmux_ctl.set_pane_user_option(
1110
+ pane_id,
1111
+ tmux_ctl.RAILMUX_HISTORY_PREVIEW_OPTION,
1112
+ history_slot,
1113
+ )
1101
1114
 
1102
1115
  def _paint_slot_active_target(
1103
1116
  self,
@@ -1900,11 +1913,16 @@ class App:
1900
1913
  self._on_session_preview(session)
1901
1914
 
1902
1915
  def _on_session_preview(self, session: SessionMeta) -> None:
1903
- """Show session history in the right pane without launching Claude.
1916
+ """Show the provider's canonical current-branch history.
1904
1917
 
1905
- Stopped-row clicks, Space, and the context action preview history. Live
1906
- rows are filtered by _on_session_row_preview before reaching this path.
1918
+ Stopped-row clicks, Space, context Preview, and live-pane wheel-up use
1919
+ this read-only projection. A Codex rewind lineage is represented by its
1920
+ newest rollout; Claude branches are projected by ``transcript``.
1907
1921
  """
1922
+ if session.session_type == "codex":
1923
+ representative = self._codex_representative(session.session_id)
1924
+ if representative is not None:
1925
+ session = representative
1908
1926
  slot = self._agent_workspace().target
1909
1927
  self._cancel_pending_double_focus()
1910
1928
  if not self._has_less:
@@ -1939,7 +1957,65 @@ class App:
1939
1957
  slot, session.session_id, None, **target_kwargs)
1940
1958
  if not self._show_attention_status(session.attention):
1941
1959
  self._set_status(
1942
- f"≡ Previewing {session.display_title} (history)")
1960
+ f"≡ Previewing {session.display_title} (current history)")
1961
+
1962
+ def _preview_running_history(self, entry: RunningEntry) -> None:
1963
+ """Preview the exact live entry's canonical provider history."""
1964
+ running = self._by_tmux(entry.tmux_name)
1965
+ if (running is None or running.is_placeholder
1966
+ or not self._agent_session_alive(entry.tmux_name)):
1967
+ self._set_status("Live history is no longer available.", "warn")
1968
+ return
1969
+ session_id = running.logical_session_id
1970
+ if session_id is None:
1971
+ self._set_status("Waiting for the provider history file…", "tip")
1972
+ return
1973
+ session = self._find_session_meta(
1974
+ session_id, running.project, running.session_type)
1975
+ if session is None:
1976
+ self._set_status("Waiting for the provider history file…", "tip")
1977
+ return
1978
+ slot = (self._agent_workspace().slot_for_agent(entry.tmux_name)
1979
+ or self._agent_workspace().target)
1980
+ self._set_workspace_target(slot.key)
1981
+ self._on_session_preview(session)
1982
+
1983
+ def _preview_slot_history(self, slot: AgentSlot) -> None:
1984
+ """Handle a pane-local wheel-up routed through the controller."""
1985
+ tmux_name = slot.agent_tmux_name
1986
+ if tmux_name is None:
1987
+ return
1988
+ running = self._by_tmux(tmux_name)
1989
+ if running is None:
1990
+ return
1991
+ self._preview_running_history(RunningEntry(
1992
+ tmux_name=tmux_name,
1993
+ label=running.label,
1994
+ status=running.status,
1995
+ ))
1996
+
1997
+ def _restore_history_slot(self, slot: AgentSlot) -> None:
1998
+ """Return a closed live-history viewer to the exact saved agent."""
1999
+ if not slot.in_history_mode:
2000
+ return
2001
+ restore = slot.restore_state
2002
+ if (restore is None or restore.kind != "agent"
2003
+ or restore.tmux_name is None):
2004
+ return
2005
+ running = self._by_tmux(restore.tmux_name)
2006
+ if (running is None
2007
+ or not self._agent_session_alive(restore.tmux_name)):
2008
+ self._set_status("The previewed live session has ended.", "warn")
2009
+ return
2010
+ self._on_running_select(
2011
+ RunningEntry(
2012
+ tmux_name=running.tmux_name,
2013
+ label=running.label,
2014
+ status=running.status,
2015
+ ),
2016
+ steal_focus=False,
2017
+ slot=slot,
2018
+ )
1943
2019
 
1944
2020
  def _save_restore_state(self, slot: AgentSlot | None = None) -> None:
1945
2021
  """Remember what's in the right pane before taking it over for history."""
@@ -1981,6 +2057,7 @@ class App:
1981
2057
  import shlex
1982
2058
  import sys as _sys
1983
2059
  mouse = self._less_mouse_flag
2060
+ slot = slot or self._agent_workspace().target
1984
2061
  # Tail the last 2000 lines so large sessions appear instantly. Tailing
1985
2062
  # drops the leading ``session_meta`` record, so a long Codex rollout
1986
2063
  # would otherwise be auto-detected as Claude and render blank. Pass the
@@ -1998,7 +2075,17 @@ class App:
1998
2075
  f"{python} -m railmux.transcript --format {fmt} "
1999
2076
  f"--preview-limit 2000 - | "
2000
2077
  f"{less_env} less -R +G {mouse}").rstrip()
2001
- slot = slot or self._agent_workspace().target
2078
+ restore = slot.restore_state
2079
+ controller = getattr(self, "_railmux_pane_id", None)
2080
+ if (restore is not None and restore.kind == "agent" and controller
2081
+ and slot.key in tmux_ctl.RAILMUX_HISTORY_RESTORE_KEYS):
2082
+ _restore_key, restore_sequence = (
2083
+ tmux_ctl.RAILMUX_HISTORY_RESTORE_KEYS[slot.key])
2084
+ cmd += (
2085
+ "; tmux send-keys -l -t "
2086
+ f"{shlex.quote(controller)} -- "
2087
+ f"{shlex.quote(restore_sequence)}"
2088
+ )
2002
2089
  # In swap mode ``slot.pane_id`` is the real provider pane. Return it
2003
2090
  # home before the destructive ``respawn-pane -k`` below; only the
2004
2091
  # display placeholder may be replaced by the transcript viewer.
@@ -3697,7 +3784,7 @@ class App:
3697
3784
  return None
3698
3785
 
3699
3786
  def _preview_focused_target(self) -> None:
3700
- """Mirror a single-click for the focused Sessions/Running target."""
3787
+ """Preview canonical history for the focused session or live entry."""
3701
3788
  target = self._focused_pane_menu_target()
3702
3789
  if target is None:
3703
3790
  self._set_status(
@@ -3706,9 +3793,9 @@ class App:
3706
3793
  return
3707
3794
  kind, value, _label = target
3708
3795
  if kind == "session" and isinstance(value, SessionMeta):
3709
- self._on_session_row_preview(value)
3796
+ self._on_session_preview(value)
3710
3797
  elif kind == "running" and isinstance(value, RunningEntry):
3711
- self._on_running_select(value, steal_focus=False)
3798
+ self._preview_running_history(value)
3712
3799
 
3713
3800
  def _close_secondary_split(self, *, announce: bool = True) -> bool:
3714
3801
  workspace = self._agent_workspace()
@@ -7313,6 +7400,27 @@ class App:
7313
7400
  if key == COMPACT_RESIZE_KEY.lower():
7314
7401
  self._handle_remote_compact_prepare()
7315
7402
  return
7403
+ for slot_key, (preview_key, _sequence) in (
7404
+ tmux_ctl.RAILMUX_HISTORY_PREVIEW_KEYS.items()):
7405
+ if key == preview_key.lower():
7406
+ if (getattr(self, "_loop", None) is not None
7407
+ and self._loop.widget is not self._frame):
7408
+ return
7409
+ workspace = self._agent_workspace()
7410
+ self._preview_slot_history(
7411
+ workspace.primary
7412
+ if slot_key == AgentWorkspace.PRIMARY
7413
+ else workspace.secondary)
7414
+ return
7415
+ for slot_key, (restore_key, _sequence) in (
7416
+ tmux_ctl.RAILMUX_HISTORY_RESTORE_KEYS.items()):
7417
+ if key == restore_key.lower():
7418
+ workspace = self._agent_workspace()
7419
+ self._restore_history_slot(
7420
+ workspace.primary
7421
+ if slot_key == AgentWorkspace.PRIMARY
7422
+ else workspace.secondary)
7423
+ return
7316
7424
  # F9 is routed here by a global tmux binding and must remain available
7317
7425
  # while a modal is open (notably Help's fullscreen copy workflow).
7318
7426
  if key == "f9":
@@ -8518,7 +8626,7 @@ class App:
8518
8626
  # Index visible projects by real_path. A Claude placeholder becomes
8519
8627
  # resolvable once its first real session makes the project visible;
8520
8628
  # Codex New Project can use its in-memory synthetic project earlier.
8521
- by_path = {p.real_path: p for p in projects}
8629
+ by_path = {self._path_key(p.real_path): p for p in projects}
8522
8630
  claimed = self._running_session_ids() | set(self._running)
8523
8631
  resolved_any = False
8524
8632
  for r in placeholders:
@@ -8526,7 +8634,11 @@ class App:
8526
8634
  # first rollout makes that cwd visible in the Codex index. Claude
8527
8635
  # placeholders continue to wait for discovery to supply the real
8528
8636
  # encoded project directory.
8529
- project = by_path.get(r.placeholder_path) or r.project
8637
+ placeholder_key = (
8638
+ self._path_key(r.placeholder_path)
8639
+ if r.placeholder_path is not None else None
8640
+ )
8641
+ project = by_path.get(placeholder_key) or r.project
8530
8642
  if project is None:
8531
8643
  continue
8532
8644
  registered_mode = self._modes().for_tmux_name(r.tmux_name)
@@ -8599,11 +8711,25 @@ class App:
8599
8711
  if not self._write_orphan_marker(resolved_marker):
8600
8712
  continue
8601
8713
  r.orphan = resolved_marker
8714
+ # Codex metadata always carries its synthetic ``-cx-`` project
8715
+ # key, while the Projects pane reuses a discovered Claude project
8716
+ # object when the same cwd exists in both providers. Preserve the
8717
+ # currently-published project identity across promotion: replacing
8718
+ # it with the metadata object for one tick makes the later
8719
+ # encoded-name match fail, briefly clearing Sessions to its
8720
+ # "Select a project" state before path-based selection recovers.
8721
+ promoted_project = (
8722
+ by_path.get(self._path_key(candidate.project.real_path))
8723
+ or project
8724
+ or candidate.project
8725
+ )
8602
8726
  # Re-key the entry from the placeholder to the real session_id.
8603
8727
  del self._running[r.key]
8604
8728
  r.key = candidate.session_id
8605
- r.label = f"{candidate.project.display_name}/{candidate.display_title}"
8606
- r.project = candidate.project
8729
+ r.label = (
8730
+ f"{promoted_project.display_name}/{candidate.display_title}"
8731
+ )
8732
+ r.project = promoted_project
8607
8733
  r.placeholder_path = None
8608
8734
  r.created_at = 0.0
8609
8735
  # Publish the same provider generation atomically to Running. The
@@ -8622,7 +8748,7 @@ class App:
8622
8748
  self._set_slot_active_target(
8623
8749
  displayed_slot, candidate.session_id, r.tmux_name)
8624
8750
  if displayed_slot is self._agent_workspace().target:
8625
- self._set_current_project(candidate.project)
8751
+ self._set_current_project(promoted_project)
8626
8752
  if resolved_any:
8627
8753
  # Placeholder launch time may already have consumed the ordinary
8628
8754
  # one-minute sort budget. Its first authoritative provider
@@ -9302,7 +9428,7 @@ class App:
9302
9428
  (_context_menu_label("Open", "↵"),
9303
9429
  lambda s=session: self._do_context_open(s)),
9304
9430
  (_context_menu_label("Preview", "␣"), lambda s=session:
9305
- self._on_session_row_preview(s)),
9431
+ self._on_session_preview(s)),
9306
9432
  (_context_menu_label("Info", "i"),
9307
9433
  lambda s=session: self._do_context_info(s)),
9308
9434
  (_context_menu_label("Rename", "r"),