Skip to content

Commit 8be932a

Browse files
claudesinelaw
authored andcommitted
refactor(terminal): make live-vs-scrollback genuine per-split state
The previous fix made the *rendering* decision per-split but the mode itself was still tracked by two coupled window/buffer fields that could only describe one terminal at a time: `Window::terminal_mode: bool` and `TerminalBuffer::mode` (a per-buffer Live/Scrollback). So only the focused split could ever be in scrollback — an unfocused split snapped back to the live grid the moment it lost focus, and two splits on one terminal could never hold two different scroll positions. Replace both with a single source of truth: `TerminalBuffer` now owns a `scrollback_splits: HashSet<LeafId>`. A split showing a terminal is live iff its LeafId is absent and scrollback iff present. This makes illegal states unrepresentable: - Live has exactly one representation (absence), so no split can be in a contradictory live+scrollback state. - The set lives on `TerminalBuffer`, so only terminals can carry a mode at all. - It is keyed per split, so each split's mode is independent and survives losing focus. Everything else derives from it. `Window::terminal_mode` is deleted; `focused_terminal_live()` is `key_context == Terminal`, which `sync_terminal_mode_flags` — the single derivation authority — projects from the set for the focused split (also owning the `editing_disabled` edge via `sync_terminal_to_buffer`). The ~20 transition sites that used to hand-maintain the `(mode, terminal_mode, key_context)` trio inline now just flip the set and re-derive, collapsing into `enter_terminal_mode()` / `enter_terminal_scrollback()`. Rendering (`render_terminal_splits`, `resize_visible_terminals`, the orchestration scrollbar gate) reads the set directly; the renderer is handed the `scrollback_view_splits` set instead of a window flag. Lifecycle: - Splits close: `forget_split_terminal_modes` prunes the LeafId from every terminal (wired into all split-close paths). - Process exit: every split showing the dead terminal is moved to scrollback (no live grid for a gone PTY). - Workspace restore: terminals come back live by default (empty set); the read-only → live completion on focus is unchanged. Behaviour preserved: single-split terminal mode, #2485 (re-focus keeps a terminal's remembered mode — now per split), #2029 (file-explorer focus stops PTY routing), and remote reconnect all keep working. New: any number of splits can sit in independent scrollback while others stream live, focused or not. Tests: `test_unfocused_split_retains_scrollback_independently` drives the new guarantee end-to-end (screen-only asserts); the existing #2595 reproducer still passes. `issue_2029` and `remote_auto_reconnect` tests updated to the derived accessors. Docs note the per-split behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DouV1Nq3wzFPmtR5yNSsHA
1 parent 27cd605 commit 8be932a

23 files changed

Lines changed: 438 additions & 303 deletions

crates/fresh-editor/src/app/active_focus.rs

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,7 @@ impl Editor {
9696
/// can re-enable editing, truncate the stale screen tail, and resize the
9797
/// PTY. No-op otherwise.
9898
pub(super) fn complete_terminal_mode_side_effects(&mut self) {
99-
if self.active_window().terminal_mode
100-
&& self
101-
.active_window()
102-
.is_terminal_buffer(self.active_buffer())
99+
if self.active_window().focused_terminal_live()
103100
&& self.active_window().is_editing_disabled()
104101
{
105102
self.enter_terminal_mode();
@@ -179,34 +176,33 @@ impl Window {
179176
true
180177
}
181178

182-
/// Derive `terminal_mode`/`key_context` from the active buffer's remembered
183-
/// mode. The one place the flags follow focus — every focus path routes
184-
/// through it, so none can leave a re-focused terminal in the wrong mode
185-
/// (issue #2485). The Editor-level finish for entering live mode is
179+
/// Project the `key_context` Terminal↔Normal edge from the per-split
180+
/// live↔scrollback source of truth for the *focused* split. The one place
181+
/// the key context follows focus — every focus path routes through it, so
182+
/// none can leave a re-focused terminal in the wrong context (issue #2485).
183+
/// The Editor-level finish for entering live mode is
186184
/// [`Editor::complete_terminal_mode_side_effects`].
187185
///
188186
/// Owns only the Terminal↔Normal edge: while another surface holds focus
189-
/// (file explorer, prompt, popup) it manages `terminal_mode` itself, so the
190-
/// guard below leaves the flags untouched.
187+
/// (file explorer, prompt, popup) `key_context` is one of their values, so
188+
/// the guard below leaves it untouched.
191189
pub(super) fn sync_terminal_mode_flags(&mut self) {
192190
use crate::input::keybindings::KeyContext;
193191
if !matches!(self.key_context, KeyContext::Normal | KeyContext::Terminal) {
194192
return;
195193
}
196194
let active = self.active_buffer();
197-
if let Some(terminal) = self.terminal_buffer(active) {
198-
if terminal.is_live() {
199-
self.terminal_mode = true;
200-
self.key_context = KeyContext::Terminal;
201-
} else {
202-
// Refresh the file-backed scrollback view before keys stop
203-
// routing to the PTY.
195+
let leaf = self.effective_active_split();
196+
if self.is_terminal_buffer(active) {
197+
if self.split_terminal_scrollback(leaf, active) {
198+
// Refresh the file-backed scrollback view (also marks the
199+
// buffer read-only) before keys stop routing to the PTY.
204200
self.sync_terminal_to_buffer(active);
205-
self.terminal_mode = false;
206201
self.key_context = KeyContext::Normal;
202+
} else {
203+
self.key_context = KeyContext::Terminal;
207204
}
208-
} else if self.terminal_mode {
209-
self.terminal_mode = false;
205+
} else if self.key_context == KeyContext::Terminal {
210206
self.key_context = KeyContext::Normal;
211207
}
212208
}

crates/fresh-editor/src/app/async_dispatch.rs

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -732,9 +732,11 @@ impl Editor {
732732
// Terminal output received - check if we should auto-jump back to terminal mode
733733
tracing::trace!("Terminal output received for {}", terminal);
734734

735-
// If viewing scrollback for this terminal and jump_to_end_on_output is enabled,
736-
// automatically re-enter terminal mode
737-
if self.config.terminal.jump_to_end_on_output && !self.active_window().terminal_mode {
735+
// If the focused split is viewing this terminal in scrollback and
736+
// jump_to_end_on_output is enabled, snap it back to the live grid.
737+
if self.config.terminal.jump_to_end_on_output
738+
&& !self.active_window().focused_terminal_live()
739+
{
738740
// Check if active buffer is this terminal
739741
if let Some(active_terminal_id) =
740742
self.active_window().get_terminal_id(self.active_buffer())
@@ -745,8 +747,10 @@ impl Editor {
745747
}
746748
}
747749

748-
// When in terminal mode, ensure display stays at bottom (follows new output)
749-
if self.active_window().terminal_mode {
750+
// When the focused split's terminal is live, keep its grid pinned to the
751+
// bottom so it follows new output. (Unfocused live splits follow on their
752+
// own — their grid sits at display_offset 0.)
753+
if self.active_window().focused_terminal_live() {
750754
if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
751755
if let Ok(mut state) = handle.state.lock() {
752756
state.scroll_to_bottom();
@@ -986,21 +990,37 @@ impl Editor {
986990
.iter()
987991
.find(|(_, tb)| tb.terminal_id == terminal_id)
988992
{
989-
// A genuinely exited terminal becomes a read-only scrollback tab,
990-
// so mark it Scrollback — a later focus shows scrollback instead of
991-
// trying to drive a dead PTY. A terminal preserved for remote
992-
// reconnect keeps its live mode so it comes back live when the
993-
// carrier respawns it.
993+
// A genuinely exited terminal has no PTY left to drive, so EVERY
994+
// split showing it becomes read-only scrollback (not just the
995+
// focused one) — otherwise an unfocused split would keep rendering a
996+
// "live" grid of a dead terminal. A terminal preserved for remote
997+
// reconnect keeps its per-split live state so it comes back live
998+
// when the carrier respawns it.
994999
if !preserve_for_reconnect {
995-
self.active_window_mut().set_terminal_interaction_mode(
996-
buffer_id,
997-
crate::app::window::TerminalInteractionMode::Scrollback,
998-
);
1000+
let dead_splits: Vec<crate::model::event::LeafId> = self
1001+
.active_window()
1002+
.buffers
1003+
.splits()
1004+
.map(|(_, vs_map)| {
1005+
vs_map
1006+
.iter()
1007+
.filter(|(_, svs)| svs.active_buffer == buffer_id)
1008+
.map(|(leaf, _)| *leaf)
1009+
.collect()
1010+
})
1011+
.unwrap_or_default();
1012+
for leaf in dead_splits {
1013+
self.active_window_mut()
1014+
.set_split_terminal_scrollback(leaf, buffer_id, true);
1015+
}
9991016
}
10001017

1001-
// Exit terminal mode if this is the active buffer
1002-
if self.active_buffer() == buffer_id && self.active_window().terminal_mode {
1003-
self.active_window_mut().terminal_mode = false;
1018+
// If the focused split was driving this now-dead terminal, leave the
1019+
// Terminal key context (its derived live state is already false).
1020+
if self.active_buffer() == buffer_id
1021+
&& self.active_window().key_context
1022+
== crate::input::keybindings::KeyContext::Terminal
1023+
{
10041024
self.active_window_mut().key_context =
10051025
crate::input::keybindings::KeyContext::Normal;
10061026
}

crates/fresh-editor/src/app/buffer_close.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,9 @@ impl Editor {
232232
// The buffer's remembered mode was dropped when its `terminal_buffers`
233233
// entry was removed by the caller — nothing else to clean up here.
234234

235-
// Exit terminal mode if we were in it
236-
if self.active_window().terminal_mode {
237-
self.active_window_mut().terminal_mode = false;
235+
// Leave the Terminal key context if we were in it (the terminal buffer
236+
// and its per-split scrollback set are gone; nothing left to be live).
237+
if self.active_window().key_context == crate::input::keybindings::KeyContext::Terminal {
238238
self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
239239
}
240240
}
@@ -528,10 +528,10 @@ impl Editor {
528528
/// All three paths should behave identically; keep new logic here.
529529
/// Returns true if the tab was closed without needing a prompt.
530530
pub fn close_tab_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) -> bool {
531-
// If closing a terminal buffer while in terminal mode, exit terminal mode
532-
if self.active_window().terminal_mode && self.active_window().is_terminal_buffer(buffer_id)
531+
// If closing the focused terminal buffer, leave the Terminal key context.
532+
if self.active_window().key_context == crate::input::keybindings::KeyContext::Terminal
533+
&& self.active_window().is_terminal_buffer(buffer_id)
533534
{
534-
self.active_window_mut().terminal_mode = false;
535535
self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
536536
}
537537

@@ -900,10 +900,10 @@ impl Editor {
900900
/// Used internally by batch close operations
901901
/// Returns true if the tab was closed, false if it was skipped (e.g., modified buffer)
902902
fn close_tab_in_split_silent(&mut self, buffer_id: BufferId, split_id: LeafId) -> bool {
903-
// If closing a terminal buffer while in terminal mode, exit terminal mode
904-
if self.active_window().terminal_mode && self.active_window().is_terminal_buffer(buffer_id)
903+
// If closing the focused terminal buffer, leave the Terminal key context.
904+
if self.active_window().key_context == crate::input::keybindings::KeyContext::Terminal
905+
&& self.active_window().is_terminal_buffer(buffer_id)
905906
{
906-
self.active_window_mut().terminal_mode = false;
907907
self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
908908
}
909909

crates/fresh-editor/src/app/clipboard.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ impl Editor {
641641
// below assumes there's a real buffer view in front of us. If
642642
// we somehow landed here under one of those modes anyway,
643643
// hand off to the synchronous service-level paste.
644-
if self.active_window().prompt.is_some() || self.active_window().terminal_mode {
644+
if self.active_window().prompt.is_some() || self.active_window().focused_terminal_live() {
645645
if let Some(text) = self.clipboard.paste() {
646646
self.paste_text(text);
647647
}
@@ -1248,8 +1248,8 @@ impl Editor {
12481248
return;
12491249
}
12501250

1251-
// If in terminal mode, send paste to the terminal PTY
1252-
if self.active_window().terminal_mode {
1251+
// If the focused split is a live terminal, send paste to its PTY
1252+
if self.active_window().focused_terminal_live() {
12531253
self.active_window_mut()
12541254
.send_terminal_input(normalized.as_bytes());
12551255
return;

crates/fresh-editor/src/app/file_explorer.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,15 @@ impl Editor {
9191
/// writing `key_context = FileExplorer` is not enough — if the user
9292
/// was in a terminal, every keystroke would still be swallowed by
9393
/// the PTY and the explorer would only *look* focused (issue #2029).
94-
/// Clear `terminal_mode`; the terminal keeps its own remembered mode on
95-
/// its `TerminalBuffer` record, so re-focusing it later restores it.
94+
/// Moving the key context off the editor pane stops PTY routing; the
95+
/// terminal keeps its per-split live/scrollback state, so re-focusing it
96+
/// later restores it.
9697
pub(super) fn take_focus_for_file_explorer(&mut self) {
9798
let win = self.active_window_mut();
98-
// Stop routing keys to the PTY while the explorer holds focus. The
99-
// terminal keeps its own remembered live/scrollback mode, so
100-
// re-focusing it later restores that mode.
101-
win.terminal_mode = false;
99+
// Stop routing keys to the PTY while the explorer holds focus:
100+
// `focused_terminal_live()` is false in any non-editor key context.
101+
// The terminal's per-split scrollback set is untouched, so re-focusing
102+
// the pane restores each split's live/scrollback state.
102103
win.key_context = KeyContext::FileExplorer;
103104
}
104105

crates/fresh-editor/src/app/input.rs

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2330,39 +2330,27 @@ impl Editor {
23302330
self.close_terminal();
23312331
}
23322332
Action::FocusTerminal => {
2333-
// If viewing a terminal buffer, switch to terminal mode
2333+
// If viewing a terminal buffer, drop the focused split into live
2334+
// mode (clears its scrollback edge, re-enables PTY input,
2335+
// truncates the stale screen tail, resizes).
23342336
if self
23352337
.active_window()
23362338
.is_terminal_buffer(self.active_buffer())
23372339
{
2338-
// Mode change to live: remember it for the next focus.
2339-
let active = self.active_buffer();
2340-
self.active_window_mut().set_terminal_interaction_mode(
2341-
active,
2342-
crate::app::window::TerminalInteractionMode::Live,
2343-
);
2344-
self.active_window_mut().terminal_mode = true;
2345-
self.active_window_mut().key_context = KeyContext::Terminal;
2340+
self.enter_terminal_mode();
23462341
self.set_status_message(t!("status.terminal_mode_enabled").to_string());
23472342
}
23482343
}
23492344
Action::TerminalEscape => {
2350-
// Exit terminal mode back to editor
2351-
if self.active_window().terminal_mode {
2352-
// User dropped to read-only scrollback: remember that mode.
2353-
let active = self.active_buffer();
2354-
self.active_window_mut().set_terminal_interaction_mode(
2355-
active,
2356-
crate::app::window::TerminalInteractionMode::Scrollback,
2357-
);
2358-
self.active_window_mut().terminal_mode = false;
2359-
self.active_window_mut().key_context = KeyContext::Normal;
2345+
// Drop the focused live terminal split into read-only scrollback.
2346+
if self.active_window().focused_terminal_live() {
2347+
self.enter_terminal_scrollback();
23602348
self.set_status_message(t!("status.terminal_mode_disabled").to_string());
23612349
}
23622350
}
23632351
Action::ToggleKeyboardCapture => {
23642352
// Toggle keyboard capture mode in terminal
2365-
if self.active_window().terminal_mode {
2353+
if self.active_window().focused_terminal_live() {
23662354
self.active_window_mut().keyboard_capture =
23672355
!self.active_window_mut().keyboard_capture;
23682356
if self.active_window_mut().keyboard_capture {
@@ -2379,7 +2367,7 @@ impl Editor {
23792367
}
23802368
Action::TerminalPaste => {
23812369
// Paste clipboard contents into terminal as a single batch
2382-
if self.active_window().terminal_mode {
2370+
if self.active_window().focused_terminal_live() {
23832371
if let Some(text) = self.clipboard.paste() {
23842372
self.active_window_mut()
23852373
.send_terminal_input(text.as_bytes());
@@ -3100,9 +3088,8 @@ impl Editor {
31003088
.set_active_split(new_leaf);
31013089

31023090
// Mirror open_terminal's post-attach bookkeeping. The buffer was
3103-
// created via `create_terminal_buffer_detached`, so its remembered
3104-
// mode is already Live.
3105-
self.active_window_mut().terminal_mode = true;
3091+
// created via `create_terminal_buffer_detached` (empty scrollback set),
3092+
// so it is live in this split; focus the terminal pane.
31063093
self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Terminal;
31073094
self.active_window_mut().resize_visible_terminals();
31083095

0 commit comments

Comments
 (0)