fresh/app/terminal.rs
1//! Terminal integration for the Editor
2//!
3//! This module provides methods for the Editor to interact with the terminal system:
4//! - Opening new terminal sessions
5//! - Closing terminals
6//! - Rendering terminal content
7//! - Handling terminal input
8//!
9//! # Role in Incremental Streaming Architecture
10//!
11//! This module handles mode switching between terminal and scrollback modes.
12//! See `crate::services::terminal` for the full architecture diagram.
13//!
14//! ## Mode Switching Methods
15//!
16//! - [`Window::sync_terminal_to_buffer`]: Terminal → Scrollback mode
17//! - Appends visible screen (~50 lines) to backing file
18//! - Loads backing file as read-only buffer
19//! - Performance: O(screen_size) ≈ 5ms
20//!
21//! - [`Editor::enter_terminal_mode`]: Scrollback → Terminal mode
22//! - Truncates backing file to remove visible screen tail
23//! - Resumes live terminal rendering
24//! - Performance: O(1) ≈ 1ms
25
26use super::window::{TerminalBuffer, TerminalInteractionMode, Window};
27use super::{BufferId, BufferMetadata, Editor};
28use crate::model::event::LeafId;
29use crate::services::authority::TerminalWrapper;
30use crate::services::terminal::TerminalId;
31use crate::state::EditorState;
32use crate::view::split::SplitViewState;
33use rust_i18n::t;
34use std::path::PathBuf;
35use std::sync::Arc;
36
37/// Filesystem for terminal scrollback backing/log files.
38///
39/// The integrated terminal's PTY is always spawned on the **local** host —
40/// even an SSH terminal runs `ssh` as a *local* child process — and the PTY
41/// read loop renders scrollback into the backing file on the local disk via
42/// `std::fs` (`services/terminal/manager.rs`). Those files therefore always
43/// live on the local machine, at a path under the local `data_dir`,
44/// independent of the session's (possibly remote) authority filesystem.
45///
46/// Routing their create / append / truncate / read through the *remote*
47/// authority filesystem (issue #2424) made every scrollback-mode toggle do a
48/// blocking SSH round-trip against a path that only exists locally: it hung
49/// the UI for the round-trip and failed with "Failed to truncate terminal
50/// backing file", leaving the scrollback view empty. Always use this local
51/// handle for terminal backing/log files so it stays consistent with the read
52/// loop. This honours the "use the `FileSystem` trait" rule (it returns a
53/// trait object, never raw `std::fs` at the call site) while pinning the
54/// backend to local — the correct backend for a local artifact.
55pub(crate) fn terminal_backing_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
56 Arc::new(crate::model::filesystem::StdFileSystem)
57}
58
59/// How often [`Window::sync_terminal_titles`] polls each terminal's
60/// foreground process group for tmux-style tab auto-naming. Frequent enough
61/// to feel responsive when a command starts/exits, infrequent enough that
62/// the per-terminal `tcgetpgrp` + `/proc` read is negligible. Also drives
63/// the editor's periodic-redraw deadline so the tab refreshes while idle.
64pub(crate) const FG_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(1000);
65
66/// Combine the foreground process name with the program's OSC title into one
67/// tab label. The command leads (short, answers "what's running"); the OSC
68/// title follows as context, e.g. `python3 — root@host: ~/proj`.
69///
70/// Returns `None` only when both are absent, so the caller falls back to the
71/// default name. When the OSC title already names the command (e.g. vim's
72/// `file - VIM`), the command isn't prepended again to avoid `vim — … VIM`.
73fn combine_terminal_title(pty: Option<&str>, osc: Option<&str>) -> Option<String> {
74 match (pty, osc) {
75 (Some(p), Some(o)) => {
76 if o.to_lowercase().contains(&p.to_lowercase()) {
77 Some(o.to_string())
78 } else {
79 Some(format!("{p} \u{2014} {o}"))
80 }
81 }
82 (Some(p), None) => Some(p.to_string()),
83 (None, Some(o)) => Some(o.to_string()),
84 (None, None) => None,
85 }
86}
87
88impl Window {
89 /// Resolve the terminal wrapper used to spawn a new integrated
90 /// terminal in this window, applying the `terminal.shell` config
91 /// override on top of the authority's wrapper when appropriate.
92 ///
93 /// See `TerminalWrapper::with_user_shell_override` for the override
94 /// rules; this is just the per-window wiring that supplies the
95 /// active config.
96 pub(crate) fn resolved_terminal_wrapper(&self) -> TerminalWrapper {
97 self.authority()
98 .terminal_wrapper
99 .clone()
100 .with_user_shell_override(self.resources.config.terminal.shell.as_ref())
101 }
102
103 /// The activated-environment delta (venv/direnv/mise) to apply to a newly
104 /// spawned terminal, so it inherits the same env that LSP servers and
105 /// `spawnProcess` already get (issue #2355; see
106 /// docs/internal/uniform-env-activation-design.md). Captured only for a
107 /// **local** host shell: `manages_cwd` marks docker/ssh-style wrappers whose
108 /// inner shell runs on another host, where this locally-captured delta would
109 /// be both wrong and unreachable (the env this `CommandBuilder` sets lands on
110 /// the `docker`/`ssh` client process, not the remote shell). Those backends
111 /// apply their own delta in the wrapper (the per-backend apply paths in the
112 /// design doc). Empty when no env is active or capture fails — the terminal
113 /// degrades to the inherited env exactly as before.
114 pub(crate) fn terminal_env_delta(
115 &self,
116 wrapper: &TerminalWrapper,
117 ) -> crate::services::env_provider::EnvDelta {
118 if wrapper.manages_cwd {
119 return crate::services::env_provider::EnvDelta::default();
120 }
121 self.authority().env_provider.current_local_delta_blocking()
122 }
123
124 /// Apply the activated environment to a *re-parented* terminal wrapper
125 /// (SSH / container), the remote counterpart of [`Self::terminal_env_delta`]
126 /// (which handles the local host shell via `CommandBuilder.env`). For SSH,
127 /// rewrite the remote login-shell `exec` into a python3 launcher that
128 /// captures + applies the activation on the remote before handing off to the
129 /// user's shell, so the SSH terminal sees the same env LSP/`spawnProcess`
130 /// already get (issue #2355). Returns the wrapper unchanged when no env is
131 /// active or the wrapper isn't an SSH re-parent. (Container backends apply
132 /// their captured env through their own wrapper flags; see the design doc.)
133 pub(crate) fn apply_remote_terminal_env(
134 &self,
135 mut wrapper: TerminalWrapper,
136 ) -> TerminalWrapper {
137 use crate::services::remote::{ssh_remote_env_launcher, SSH_EXEC_LOGIN_SHELL};
138
139 if wrapper.command == "ssh" && self.authority().env_provider.is_active() {
140 let recipe = self.authority().env_provider.snippet();
141 if let Some(last) = wrapper.args.last_mut() {
142 if last.contains(SSH_EXEC_LOGIN_SHELL) {
143 *last = last.replace(SSH_EXEC_LOGIN_SHELL, &ssh_remote_env_launcher(&recipe));
144 }
145 }
146 }
147 wrapper
148 }
149
150 /// Get terminal dimensions appropriate for spawning a PTY in this
151 /// window. Derived from the window's cached screen size minus a
152 /// small constant for menu/status chrome.
153 pub(crate) fn get_terminal_dimensions(&self) -> (u16, u16) {
154 let cols = self.terminal_width.saturating_sub(2).max(40);
155 let rows = self.terminal_height.saturating_sub(4).max(10);
156 (cols, rows)
157 }
158
159 /// Spawn a new PTY-backed terminal session in this window and
160 /// record its log/backing files. Returns the terminal id on
161 /// success — does **not** create a buffer or attach to any
162 /// split. Callers are responsible for the rest of the wiring
163 /// (see `create_terminal_buffer_attached` /
164 /// `create_terminal_buffer_detached`).
165 ///
166 /// `cwd` defaults to this window's `root` when None. `persistent`
167 /// controls whether the backing files use stable names
168 /// (`fresh-terminal-N.{log,txt}`) so workspace restore can find
169 /// them, or per-spawn ephemeral suffixes
170 /// (`fresh-terminal-eph-N-<ts>.{log,txt}`); non-persistent
171 /// terminals are also added to `ephemeral_terminals` so the
172 /// workspace serialiser skips them.
173 ///
174 /// On spawn failure the error is logged and a status message is
175 /// set on this window; the caller gets `None` back.
176 pub fn spawn_terminal_session(
177 &mut self,
178 cwd: Option<PathBuf>,
179 persistent: bool,
180 command_override: Option<Vec<String>>,
181 ) -> Option<TerminalId> {
182 let (cols, rows) = self.get_terminal_dimensions();
183
184 // Per-window async bridge — terminal output flows back through
185 // the window that owns the PTY.
186 let bridge = self.bridge.clone();
187 self.terminal_manager.set_async_bridge(bridge);
188
189 let working_dir = cwd.unwrap_or_else(|| self.root.clone());
190 let terminal_root = self.resources.dir_context.terminal_dir_for(&working_dir);
191 if let Err(e) = terminal_backing_fs().create_dir_all(&terminal_root) {
192 tracing::warn!("Failed to create terminal directory: {}", e);
193 }
194
195 // Precompute paths using the next terminal ID so we capture
196 // from the first byte. Ephemeral terminals get a per-spawn
197 // suffix so there is no possibility of picking up scrollback
198 // a previous run (with the same numeric terminal ID) wrote
199 // to the same path.
200 let predicted_terminal_id = self.terminal_manager.next_terminal_id();
201 let name_stem = if persistent {
202 format!("fresh-terminal-{}", predicted_terminal_id.0)
203 } else {
204 let nanos = std::time::SystemTime::now()
205 .duration_since(std::time::UNIX_EPOCH)
206 .map(|d| d.as_nanos())
207 .unwrap_or(0);
208 format!("fresh-terminal-eph-{}-{}", predicted_terminal_id.0, nanos)
209 };
210 let log_path = terminal_root.join(format!("{}.log", name_stem));
211 let backing_path = terminal_root.join(format!("{}.txt", name_stem));
212 self.terminal_backing_files
213 .insert(predicted_terminal_id, backing_path.clone());
214
215 // When the caller supplies an explicit argv, build a wrapper
216 // that runs it *inside this session's backend* via the authority:
217 // local runs it directly as the PTY child; a container authority
218 // prepends `docker exec -it … <id>` so an agent terminal runs in the
219 // container rather than on the host (see `Authority::terminal_command`).
220 // Empty argv falls back to the interactive shell.
221 let wrapper = match command_override {
222 Some(argv) if !argv.is_empty() => self.authority().terminal_command(&argv),
223 _ => self.resolved_terminal_wrapper(),
224 };
225 let wrapper = self.apply_remote_terminal_env(wrapper);
226 let env_delta = self.terminal_env_delta(&wrapper);
227 match self.terminal_manager.spawn(
228 cols,
229 rows,
230 Some(working_dir),
231 Some(log_path.clone()),
232 Some(backing_path),
233 wrapper,
234 env_delta,
235 ) {
236 Ok(terminal_id) => {
237 self.terminal_log_files.insert(terminal_id, log_path);
238 // If the actual terminal id differs from the predicted
239 // one, move the backing-file entry to the real id and
240 // rename to the persistent (no-eph-suffix) form. This
241 // mirrors the pre-migration behaviour exactly.
242 if terminal_id != predicted_terminal_id {
243 self.terminal_backing_files.remove(&predicted_terminal_id);
244 let backing_path =
245 terminal_root.join(format!("fresh-terminal-{}.txt", terminal_id.0));
246 self.terminal_backing_files
247 .insert(terminal_id, backing_path);
248 }
249 if !persistent {
250 self.ephemeral_terminals.insert(terminal_id);
251 }
252 Some(terminal_id)
253 }
254 Err(e) => {
255 self.set_status_message(
256 t!("terminal.failed_to_open", error = e.to_string()).to_string(),
257 );
258 tracing::error!("Failed to open terminal: {}", e);
259 None
260 }
261 }
262 }
263
264 /// Create a buffer for a terminal session in this window, attached
265 /// to the specified split. Mirrors the pre-migration body of
266 /// `Editor::create_terminal_buffer_attached`.
267 pub fn create_terminal_buffer_attached(
268 &mut self,
269 terminal_id: TerminalId,
270 split_id: LeafId,
271 ) -> BufferId {
272 let buffer_id = self.alloc_buffer_id();
273 let large_file_threshold = self.resources.config.editor.large_file_threshold_bytes as usize;
274
275 // Rendered backing file for scrollback view (reuse if already
276 // recorded by `spawn_terminal_session`).
277 let backing_file = self
278 .terminal_backing_files
279 .get(&terminal_id)
280 .cloned()
281 .unwrap_or_else(|| {
282 let root = self.resources.dir_context.terminal_dir_for(&self.root);
283 if let Err(e) = terminal_backing_fs().create_dir_all(&root) {
284 tracing::warn!("Failed to create terminal directory: {}", e);
285 }
286 root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
287 });
288
289 // Ensure the file exists — but DON'T truncate if it already has
290 // content. The PTY read loop may have already started writing
291 // scrollback.
292 if !terminal_backing_fs().exists(&backing_file) {
293 if let Err(e) = terminal_backing_fs().write_file(&backing_file, &[]) {
294 tracing::warn!("Failed to create terminal backing file: {}", e);
295 }
296 }
297
298 self.terminal_backing_files
299 .insert(terminal_id, backing_file.clone());
300
301 let mut state = EditorState::new_with_path(
302 large_file_threshold,
303 terminal_backing_fs(),
304 backing_file.clone(),
305 );
306 state.margins.configure_for_line_numbers(false);
307 self.buffers.insert(buffer_id, state);
308
309 // Virtual metadata so the tab shows "*Terminal N*" and LSP
310 // stays off.
311 let metadata = BufferMetadata::virtual_buffer(
312 format!("*Terminal {}*", terminal_id.0),
313 "terminal".into(),
314 false,
315 );
316 self.buffer_metadata.insert(buffer_id, metadata);
317 self.terminal_buffers
318 .insert(buffer_id, TerminalBuffer::new_live(terminal_id));
319 self.event_logs
320 .insert(buffer_id, crate::model::event::EventLog::new());
321
322 if let Some(view_states) = self.split_view_states_mut() {
323 if let Some(view_state) = view_states.get_mut(&split_id) {
324 view_state.add_buffer(buffer_id);
325 // Terminal buffers should not wrap lines so escape
326 // sequences stay intact.
327 view_state.viewport.line_wrap_enabled = false;
328 // Disable line numbers + current-line highlight for the
329 // terminal buffer's per-buffer view state so exiting
330 // terminal mode doesn't suddenly add a gutter / row
331 // highlight. The render path overwrites the buffer's
332 // margin config every frame from this view-state flag,
333 // so setting it here is required even though
334 // `state.margins.configure_for_line_numbers(false)` was
335 // already called above.
336 let buf_state = view_state.ensure_buffer_state(buffer_id);
337 buf_state.show_line_numbers = false;
338 buf_state.highlight_current_line = false;
339 buf_state.viewport.line_wrap_enabled = false;
340 }
341 }
342
343 buffer_id
344 }
345
346 /// Plugin-facing terminal creation in this window. Handles all
347 /// the variants the JS `editor.createTerminal` API exposes:
348 ///
349 /// - `direction = None`: attach the terminal as a new tab in the
350 /// window's active split (or seed a fresh split layout rooted
351 /// at the terminal if the window has never been activated and
352 /// therefore has no layout yet).
353 /// - `direction = Some(dir)`: create a new horizontal/vertical
354 /// split off the active split and place the terminal there.
355 /// `ratio` controls the split's size (default 0.5). `focus`
356 /// controls whether the new split becomes the window's active
357 /// split.
358 ///
359 /// In all cases the leader pid is registered with the window's
360 /// `process_groups` tracker so cross-window signal operations
361 /// (Stop / Archive / Delete) can reach the spawned process group.
362 ///
363 /// Returns `(terminal_id, buffer_id, created_split_id)` on
364 /// success. `created_split_id` is `Some` when a split was created
365 /// (either explicitly via `direction = Some` or implicitly when
366 /// seeding a fresh layout in a never-activated window).
367 pub fn create_plugin_terminal(
368 &mut self,
369 cwd: Option<PathBuf>,
370 direction: Option<crate::model::event::SplitDirection>,
371 ratio: Option<f32>,
372 focus: bool,
373 persistent: bool,
374 command: Option<Vec<String>>,
375 title: Option<String>,
376 ) -> Result<(TerminalId, BufferId, Option<LeafId>), String> {
377 // Derive the auto-title from the command's executable name
378 // (basename of argv[0]). The host writes this into the
379 // terminal buffer's `BufferMetadata::name` so the tab reads
380 // e.g. "python3" instead of "*Terminal N*" when the plugin
381 // runs python3 directly. Explicit `title` overrides.
382 let auto_title = command.as_ref().and_then(|argv| {
383 argv.first().map(|cmd| {
384 std::path::Path::new(cmd)
385 .file_name()
386 .and_then(|os| os.to_str())
387 .unwrap_or(cmd.as_str())
388 .to_string()
389 })
390 });
391 let resolved_title = title.or(auto_title);
392 let terminal_id = self
393 .spawn_terminal_session(cwd, persistent, command)
394 .ok_or_else(|| "Failed to spawn terminal".to_string())?;
395
396 // Register the leader pid with this window's process_groups
397 // so window-level signal operations reach the spawned group.
398 if let Some(pid) = self.terminal_manager.get(terminal_id).and_then(|h| h.pid()) {
399 let label = format!("terminal #{}", terminal_id.0);
400 self.process_groups.register(pid, label);
401 }
402
403 // Compute split-creation behaviour. The two cases (with /
404 // without direction) diverge in whether we attach to the
405 // active split as a new tab or create a fresh split off it.
406 // The "never-activated, no layout yet" case is handled in
407 // both branches by seeding a SplitManager rooted at the new
408 // terminal buffer.
409 let active_split = self.buffers.splits().map(|(mgr, _)| mgr.active_split());
410
411 let (buffer_id, created_split_id) = if let Some(split_dir) = direction {
412 let buffer_id = self.create_terminal_buffer_detached(terminal_id);
413 match active_split {
414 Some(parent) => {
415 let split_ratio = ratio.unwrap_or(0.5);
416 let line_numbers = self.resources.config.editor.line_numbers;
417 let highlight_current_line =
418 self.resources.config.editor.highlight_current_line;
419 let rulers = self.resources.config.editor.rulers.clone();
420 let terminal_width = self.terminal_width;
421 let terminal_height = self.terminal_height;
422 let split_result = self
423 .split_manager_mut()
424 .expect("active split implies populated layout")
425 .split_active(split_dir, buffer_id, split_ratio);
426 match split_result {
427 Ok(new_split_id) => {
428 let mut view_state = SplitViewState::with_buffer(
429 terminal_width,
430 terminal_height,
431 buffer_id,
432 );
433 // Terminal-dedicated splits never show
434 // line numbers or current-line highlight
435 // — the buffer is a PTY scrollback view,
436 // not source code. (Pre-fix the config
437 // default was applied, so a default-on
438 // line-numbers user saw `1 │ Python …`
439 // in every orchestrator agent split.)
440 // Other splits in the window aren't
441 // affected because each `SplitViewState`
442 // is independent.
443 let _ = line_numbers;
444 let _ = highlight_current_line;
445 view_state
446 .apply_config_defaults(false, false, false, false, None, rulers, 0);
447 // Terminal output is ANSI-sequenced and
448 // assumes a fixed column count; wrapping
449 // would mangle cursor positioning.
450 view_state.viewport.line_wrap_enabled = false;
451 self.split_view_states_mut()
452 .expect("active split implies populated layout")
453 .insert(new_split_id, view_state);
454 if focus {
455 self.split_manager_mut()
456 .expect("active split implies populated layout")
457 .set_active_split(new_split_id);
458 }
459 (buffer_id, Some(new_split_id))
460 }
461 Err(e) => {
462 tracing::error!(
463 "Failed to create split for terminal: {e}; \
464 falling back to attaching to active split"
465 );
466 // Graceful fallback: attach to the active
467 // split so the buffer isn't orphaned.
468 if let Some(view_state) = self
469 .split_view_states_mut()
470 .and_then(|m| m.get_mut(&parent))
471 {
472 view_state.add_buffer(buffer_id);
473 view_state.viewport.line_wrap_enabled = false;
474 }
475 self.set_active_buffer(buffer_id);
476 (buffer_id, None)
477 }
478 }
479 }
480 None => {
481 // Never-activated window with no layout — seed
482 // one rooted at the terminal buffer. First dive
483 // picks it up and the terminal is the active leaf.
484 let manager = crate::view::split::SplitManager::new(buffer_id);
485 let active_leaf = manager.active_split();
486 let mut view_states = std::collections::HashMap::new();
487 let mut vs = SplitViewState::with_buffer(
488 self.terminal_width,
489 self.terminal_height,
490 buffer_id,
491 );
492 vs.viewport.line_wrap_enabled = false;
493 view_states.insert(active_leaf, vs);
494 self.buffers.set_splits((manager, view_states));
495 (buffer_id, Some(active_leaf))
496 }
497 }
498 } else {
499 match active_split {
500 Some(split_id) => {
501 let buffer_id = self.create_terminal_buffer_attached(terminal_id, split_id);
502 // Switch tabs to the terminal. Window-side
503 // mutation only — the editor-wide
504 // `buffer_activated` hook is fired by the
505 // Editor wrapper iff this window is the
506 // editor-active one.
507 self.set_active_buffer(buffer_id);
508 (buffer_id, None)
509 }
510 None => {
511 let buffer_id = self.create_terminal_buffer_detached(terminal_id);
512 let manager = crate::view::split::SplitManager::new(buffer_id);
513 let active_leaf = manager.active_split();
514 let mut view_states = std::collections::HashMap::new();
515 let mut vs = SplitViewState::with_buffer(
516 self.terminal_width,
517 self.terminal_height,
518 buffer_id,
519 );
520 vs.viewport.line_wrap_enabled = false;
521 view_states.insert(active_leaf, vs);
522 self.buffers.set_splits((manager, view_states));
523 (buffer_id, Some(active_leaf))
524 }
525 }
526 };
527
528 // Override the auto-generated `*Terminal N*` display name
529 // when the plugin requested an explicit title (or one was
530 // derived from `command[0]`). Disambiguates against other
531 // terminals in this window using a `name (k)` suffix so two
532 // simultaneous python3 sessions read as "python3" and
533 // "python3 (2)" instead of colliding.
534 if let Some(title) = resolved_title {
535 let final_name = self.disambiguate_terminal_title(&title, buffer_id);
536 if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
537 meta.display_name = final_name;
538 }
539 // Mark this tab as explicitly titled so foreground-process
540 // auto-naming leaves it alone (an OSC title still overrides).
541 self.terminal_explicit_titles.insert(buffer_id);
542 }
543
544 // When the new terminal ended up as this window's active
545 // buffer, switch the window into terminal mode so the live
546 // grid renders immediately. Without this, the renderer
547 // skips the grid (see `render_terminal_splits` — it defers
548 // to the file-backed scrollback view whenever the active
549 // tab is a terminal buffer but the window is not in
550 // terminal mode) and the user sees a blank tab until the
551 // next event flips `terminal_mode` — typically the next
552 // printable keystroke via `should_enter_terminal_mode`.
553 // Mirrors `open_terminal_in_window`'s post-spawn flip. The buffer was
554 // inserted with `TerminalBuffer::new_live`, so its remembered mode is
555 // already Live; just flip the focus flags if it's the active buffer.
556 if self.active_buffer() == buffer_id {
557 self.terminal_mode = true;
558 self.key_context = crate::input::keybindings::KeyContext::Terminal;
559 }
560
561 self.resize_visible_terminals();
562 Ok((terminal_id, buffer_id, created_split_id))
563 }
564
565 /// Pick the next free `name (k)` variant of `desired` for this
566 /// window's set of terminal buffers. `for_buffer` is the
567 /// freshly-created buffer being titled — its own metadata is
568 /// excluded from the scan so we don't collide with ourselves
569 /// when callers pre-set it.
570 ///
571 /// Returns `desired` verbatim when no collision exists, otherwise
572 /// `desired (2)`, `desired (3)`, … as needed.
573 fn disambiguate_terminal_title(&self, desired: &str, for_buffer: BufferId) -> String {
574 // Collect existing terminal-buffer display names that share
575 // the desired prefix. Only inspect buffers that are actually
576 // terminals — non-terminal buffers happen to use the same
577 // metadata map but their names don't collide semantically.
578 let used: std::collections::HashSet<&str> = self
579 .terminal_buffers
580 .keys()
581 .filter(|bid| **bid != for_buffer)
582 .filter_map(|bid| {
583 self.buffer_metadata
584 .get(bid)
585 .map(|m| m.display_name.as_str())
586 })
587 .collect();
588 if !used.contains(desired) {
589 return desired.to_string();
590 }
591 // Linear scan from k=2 upward. Two simultaneous duplicates is
592 // already rare; ten is unheard of, so the loop bound is fine.
593 for k in 2..=1024 {
594 let candidate = format!("{} ({})", desired, k);
595 if !used.contains(candidate.as_str()) {
596 return candidate;
597 }
598 }
599 // Fall back to `desired (∞)` if for some reason 1024 names
600 // are taken — still unique because the loop exhausted the
601 // numeric variants we considered. Practically unreachable.
602 format!("{} (n)", desired)
603 }
604
605 /// Refresh terminal buffers' tab titles, tmux-style. Runs every frame,
606 /// but the expensive part — reading each terminal's foreground process
607 /// group (`tcgetpgrp` + `/proc`) — is throttled to [`FG_POLL_INTERVAL`]
608 /// and cached; the cached name is re-applied to the tab on every frame
609 /// so the title is responsive to renders without re-running the syscall.
610 ///
611 /// The tab label **combines** two sources (see [`combine_terminal_title`]):
612 ///
613 /// - **Foreground process name** — the command currently in the
614 /// terminal's foreground process group (e.g. `python3` while a REPL
615 /// runs, `bash` at the prompt). Mirrors tmux's
616 /// `#{pane_current_command}`; read on Linux, `None` elsewhere.
617 /// - **OSC title** — what a program set via OSC 0/1/2 (e.g. a shell's
618 /// `user@host: ~/dir` prompt title, or vim's `file - VIM`).
619 ///
620 /// e.g. `python3 — root@host: ~/proj`. When only one is present that one
621 /// is used; when neither is, the default `*Terminal N*` stands.
622 ///
623 /// Terminals with an explicit (plugin-/command-derived) title are left
624 /// untouched — like a tmux manual rename, an intentional name opts out
625 /// of auto-naming.
626 ///
627 /// Both parts are sanitized (control characters stripped, length capped)
628 /// the same way as the host window title, and applied without the
629 /// `name (k)` disambiguation used for plugin titles.
630 pub fn sync_terminal_titles(&mut self) {
631 // Gated by config: when off, tabs keep their static `*Terminal N*`
632 // (or plugin) names. Clearing the cache lets a later enable start
633 // fresh.
634 if !self.config().editor.terminal_auto_title {
635 self.terminal_fg_cache.clear();
636 return;
637 }
638
639 // Refresh the foreground-name cache. A terminal is re-read when the
640 // poll interval has elapsed, or eagerly while it has no cached name
641 // yet (its first prompt may not have a foreground pgid the instant
642 // it spawns, and renders are event-driven — so keep trying until it
643 // resolves rather than waiting a full interval).
644 let now = std::time::Instant::now();
645 let interval_due = self
646 .terminal_fg_poll_at
647 .is_none_or(|last| now.duration_since(last) >= FG_POLL_INTERVAL);
648 if interval_due {
649 self.terminal_fg_poll_at = Some(now);
650 }
651 for (buffer_id, tb) in self.terminal_buffers.iter() {
652 if self.terminal_explicit_titles.contains(buffer_id) {
653 continue;
654 }
655 if !interval_due && self.terminal_fg_cache.contains_key(buffer_id) {
656 continue;
657 }
658 let name = self
659 .terminal_manager
660 .get(tb.terminal_id)
661 .and_then(|h| h.foreground_process_name())
662 .map(|n| crate::services::terminal_title::sanitize_title(&n))
663 .filter(|n| !n.is_empty());
664 match name {
665 Some(n) => {
666 self.terminal_fg_cache.insert(*buffer_id, n);
667 }
668 None => {
669 self.terminal_fg_cache.remove(buffer_id);
670 }
671 }
672 }
673
674 // Apply a title to every (non-explicit) terminal tab every frame,
675 // combining the cached foreground name with the current OSC title.
676 // Snapshot first so the mutable `buffer_metadata` borrow doesn't
677 // overlap the immutable reads above.
678 let mut updates: Vec<(BufferId, String)> = Vec::new();
679 for (buffer_id, tb) in self.terminal_buffers.iter() {
680 if self.terminal_explicit_titles.contains(buffer_id) {
681 continue;
682 }
683 let pty = self.terminal_fg_cache.get(buffer_id).cloned();
684 let osc = self
685 .terminal_manager
686 .get(tb.terminal_id)
687 .and_then(|handle| {
688 let osc = handle.state.lock().ok()?.title().to_string();
689 let sanitized = crate::services::terminal_title::sanitize_title(&osc);
690 (!sanitized.is_empty()).then_some(sanitized)
691 });
692 let name = combine_terminal_title(pty.as_deref(), osc.as_deref())
693 .unwrap_or_else(|| format!("*Terminal {}*", tb.terminal_id.0));
694 updates.push((*buffer_id, name));
695 }
696
697 for (buffer_id, title) in updates {
698 if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
699 if meta.display_name != title {
700 meta.display_name = title;
701 }
702 }
703 }
704 }
705
706 /// Open a new terminal in this window: spawn the PTY, create
707 /// the buffer, attach to the active split, switch this window's
708 /// active buffer to it, enable terminal mode, and resize the PTY
709 /// to match the split's content area. Returns `(terminal_id,
710 /// buffer_id)` on success.
711 ///
712 /// Editor-wide effects (the `buffer_activated` plugin hook, the
713 /// status-bar exit-key message) are NOT fired here — that's the
714 /// caller's responsibility, gated on whether this window is the
715 /// editor-active one. See `Editor::open_terminal` for the
716 /// active-window wrapper that does both.
717 pub fn open_terminal_in_window(&mut self) -> Option<(TerminalId, BufferId)> {
718 // `None` command override — `Open Terminal` always spawns the
719 // user's shell, never a one-off command. Plugin-driven
720 // terminals route through `create_plugin_terminal` instead.
721 let terminal_id = self.spawn_terminal_session(None, true, None)?;
722 let split_id = self
723 .buffers
724 .splits()
725 .map(|(mgr, _)| mgr.active_split())
726 .expect("window must have a populated split layout");
727 let buffer_id = self.create_terminal_buffer_attached(terminal_id, split_id);
728 // Window-side activation: per-window mutation only — the
729 // editor-wide plugin hook fires in the Editor wrapper.
730 self.set_active_buffer(buffer_id);
731 // The buffer was inserted with `TerminalBuffer::new_live`, so its
732 // remembered mode is already Live.
733 self.terminal_mode = true;
734 self.key_context = crate::input::keybindings::KeyContext::Terminal;
735 self.resize_visible_terminals();
736 Some((terminal_id, buffer_id))
737 }
738
739 /// Create a buffer for a terminal session in this window without
740 /// attaching to any split (used during session restore).
741 pub fn create_terminal_buffer_detached(&mut self, terminal_id: TerminalId) -> BufferId {
742 let buffer_id = self.alloc_buffer_id();
743 let large_file_threshold = self.resources.config.editor.large_file_threshold_bytes as usize;
744
745 let backing_file = self
746 .terminal_backing_files
747 .get(&terminal_id)
748 .cloned()
749 .unwrap_or_else(|| {
750 let root = self.resources.dir_context.terminal_dir_for(&self.root);
751 if let Err(e) = terminal_backing_fs().create_dir_all(&root) {
752 tracing::warn!("Failed to create terminal directory: {}", e);
753 }
754 root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
755 });
756
757 if !terminal_backing_fs().exists(&backing_file) {
758 if let Err(e) = terminal_backing_fs().write_file(&backing_file, &[]) {
759 tracing::warn!("Failed to create terminal backing file: {}", e);
760 }
761 }
762
763 let mut state = EditorState::new_with_path(
764 large_file_threshold,
765 terminal_backing_fs(),
766 backing_file.clone(),
767 );
768 state.margins.configure_for_line_numbers(false);
769 self.buffers.insert(buffer_id, state);
770
771 let metadata = BufferMetadata::virtual_buffer(
772 format!("*Terminal {}*", terminal_id.0),
773 "terminal".into(),
774 false,
775 );
776 self.buffer_metadata.insert(buffer_id, metadata);
777 self.terminal_buffers
778 .insert(buffer_id, TerminalBuffer::new_live(terminal_id));
779 self.event_logs
780 .insert(buffer_id, crate::model::event::EventLog::new());
781
782 buffer_id
783 }
784
785 /// The terminal the user interacted with most recently: the latest
786 /// split in the focus LRU whose current buffer is a terminal. Falls
787 /// back to the newest open terminal when no split currently shows
788 /// one (e.g. the terminal sits in a background tab), and `None`
789 /// when the window has no terminals at all.
790 pub fn last_focused_terminal(&self) -> Option<TerminalId> {
791 if let Some((mgr, _)) = self.buffers.splits() {
792 let terminal_of_leaf = |leaf: LeafId| {
793 mgr.get_buffer_id(leaf.into())
794 .and_then(|buffer_id| self.terminal_buffers.get(&buffer_id))
795 .map(|tb| tb.terminal_id)
796 };
797 if let Some(leaf) = mgr.last_focused_where(|leaf| terminal_of_leaf(leaf).is_some()) {
798 return terminal_of_leaf(leaf);
799 }
800 }
801 self.terminal_buffers
802 .values()
803 .map(|tb| tb.terminal_id)
804 .max_by_key(|t| t.0)
805 }
806
807 /// Respawn this window's dead embedded terminals through its *current*
808 /// authority, reusing each terminal's backing/log files so scrollback
809 /// continues across the gap.
810 ///
811 /// Called after a live remote reconnect re-points the window's authority
812 /// (`Editor::set_session_authority`): the embedded `ssh -t` PTYs died with
813 /// the carrier (a separate channel from the agent connection, which has its
814 /// own auto-reconnect), so without this they'd sit dead until manually
815 /// reopened. Each respawn re-runs the terminal's stored launch/resume argv
816 /// through `Authority::terminal_command`, so the new PTY runs on the remote
817 /// backend by construction — never the local host.
818 ///
819 /// Only terminals whose handle is missing or no longer alive are respawned;
820 /// a still-live terminal is left untouched (respawning it would orphan its
821 /// PTY). Terminal ids change on respawn — the manager allocates fresh ones —
822 /// so every terminal-id-keyed entry (buffer→terminal binding, backing/log
823 /// files, launch/resume commands, ephemeral marker) is remapped to the new
824 /// id and the dead handle is torn down.
825 ///
826 /// Returns the number of terminals actually revived (dead handles that were
827 /// respawned), so callers can tailor a status message and skip it when the
828 /// window had no terminals to restore.
829 pub fn respawn_terminals_through_authority(&mut self) -> usize {
830 // Snapshot the (buffer, old terminal id) pairs up front — the loop
831 // mutates `terminal_buffers` as it remaps ids.
832 let bindings: Vec<(BufferId, TerminalId)> = self
833 .terminal_buffers
834 .iter()
835 .map(|(b, tb)| (*b, tb.terminal_id))
836 .collect();
837
838 let mut revived = 0usize;
839 for (buffer_id, old_id) in bindings {
840 // Leave a still-live terminal alone; only revive the dead ones.
841 let handle = self.terminal_manager.get(old_id);
842 if handle.is_some_and(|h| h.is_alive()) {
843 continue;
844 }
845
846 // Size + cwd carry over from the dead handle (so the reborn PTY
847 // matches the split), falling back to the window's dimensions.
848 let (cols, rows) = handle
849 .map(|h| h.size())
850 .unwrap_or_else(|| self.get_terminal_dimensions());
851 let cwd = handle.and_then(|h| h.cwd());
852
853 // Reuse the same backing/log files so the new PTY appends to the
854 // existing scrollback rather than starting blank.
855 let backing_path = self.terminal_backing_files.get(&old_id).cloned();
856 let log_path = self.terminal_log_files.get(&old_id).cloned();
857
858 // Same argv precedence as workspace restore: an agent-resume argv
859 // first (rejoin the conversation), then the launch command, else
860 // the plain interactive shell.
861 let resume_argv = self
862 .terminal_resume_commands
863 .get(&old_id)
864 .filter(|argv| !argv.is_empty() && self.resources.config.terminal.resume_agents)
865 .cloned();
866 let launch_argv = self
867 .terminal_commands
868 .get(&old_id)
869 .filter(|argv| !argv.is_empty())
870 .cloned();
871 let spawn_argv = resume_argv.or(launch_argv);
872 let wrapper = match spawn_argv.as_deref() {
873 Some(argv) => self.authority().terminal_command(argv),
874 None => self.resolved_terminal_wrapper(),
875 };
876 let wrapper = self.apply_remote_terminal_env(wrapper);
877 let env_delta = self.terminal_env_delta(&wrapper);
878
879 let new_id = match self.terminal_manager.spawn(
880 cols,
881 rows,
882 cwd,
883 log_path,
884 backing_path,
885 wrapper,
886 env_delta,
887 ) {
888 Ok(id) => id,
889 Err(e) => {
890 tracing::warn!("reconnect: failed to respawn terminal {:?}: {}", old_id, e);
891 continue;
892 }
893 };
894
895 // The dead PTY's handle is now superseded — tear it down.
896 self.terminal_manager.close(old_id);
897
898 // Remap every terminal-id-keyed entry from old_id → new_id.
899 if new_id != old_id {
900 // Remap the PTY id but preserve the buffer's remembered mode.
901 if let Some(tb) = self.terminal_buffers.get_mut(&buffer_id) {
902 tb.terminal_id = new_id;
903 }
904 if let Some(p) = self.terminal_backing_files.remove(&old_id) {
905 self.terminal_backing_files.insert(new_id, p);
906 }
907 if let Some(p) = self.terminal_log_files.remove(&old_id) {
908 self.terminal_log_files.insert(new_id, p);
909 }
910 if let Some(c) = self.terminal_commands.remove(&old_id) {
911 self.terminal_commands.insert(new_id, c);
912 }
913 if let Some(c) = self.terminal_resume_commands.remove(&old_id) {
914 self.terminal_resume_commands.insert(new_id, c);
915 }
916 if self.ephemeral_terminals.remove(&old_id) {
917 self.ephemeral_terminals.insert(new_id);
918 }
919 }
920
921 // Register the reborn leader pid so window-level signal operations
922 // (Stop / Archive / Delete) reach the new process group.
923 if let Some(pid) = self.terminal_manager.get(new_id).and_then(|h| h.pid()) {
924 self.process_groups
925 .register(pid, format!("terminal #{}", new_id.0));
926 }
927
928 revived += 1;
929 }
930
931 // Size the freshly-spawned PTYs to their splits' content areas.
932 self.resize_visible_terminals();
933
934 revived
935 }
936}
937
938impl Editor {
939 /// Spawn a new PTY-backed terminal session in the active window
940 /// using its `root` as cwd. Editor-side thin wrapper; per-window
941 /// body lives in `Window::spawn_terminal_session`.
942 ///
943 /// Used by `open_terminal` (regular spawn into the active split)
944 /// and by `Action::OpenTerminalInDock` (which needs the buffer
945 /// id *before* it has a split to attach to, so the dock leaf can
946 /// be seeded with the terminal directly rather than with a
947 /// placeholder buffer that would linger as a phantom tab).
948 pub(crate) fn spawn_terminal_session(&mut self) -> Option<TerminalId> {
949 // No command override — see comment on `Window::open_terminal_in_window`.
950 self.active_window_mut()
951 .spawn_terminal_session(None, true, None)
952 }
953
954 /// Open a new terminal in the active window's current split, fire
955 /// the editor-wide `buffer_activated` plugin hook, and post a
956 /// status-bar message with the terminal-mode exit key.
957 ///
958 /// Window-side body lives in `Window::open_terminal_in_window`;
959 /// this router adds only the cross-cutting effects that require
960 /// editor-level state (the plugin hook + status message).
961 pub fn open_terminal(&mut self) {
962 let Some((terminal_id, buffer_id)) = self.active_window_mut().open_terminal_in_window()
963 else {
964 return;
965 };
966
967 // Editor-wide: refresh the plugin-state snapshot so plugin
968 // hooks see the new active buffer, then fire `buffer_activated`.
969 #[cfg(feature = "plugins")]
970 self.update_plugin_state_snapshot();
971 #[cfg(feature = "plugins")]
972 self.plugin_manager.read().unwrap().run_hook(
973 "buffer_activated",
974 crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
975 );
976
977 // Status bar with the terminal-mode exit key. Looked up here
978 // (not in Window) because the keybinding resolver is shared
979 // editor state read through the `Arc<RwLock<…>>`.
980 let exit_key = self
981 .keybindings
982 .read()
983 .unwrap()
984 .find_keybinding_for_action(
985 "terminal_escape",
986 crate::input::keybindings::KeyContext::Terminal,
987 )
988 .unwrap_or_else(|| "Ctrl+Space".to_string());
989 self.set_status_message(
990 t!("terminal.opened", id = terminal_id.0, exit_key = exit_key).to_string(),
991 );
992 tracing::info!(
993 "Opened terminal {:?} with buffer {:?}",
994 terminal_id,
995 buffer_id
996 );
997 }
998
999 /// Open a new terminal in a fresh split created from the active pane.
1000 ///
1001 /// `SplitDirection::Vertical` places the terminal in a pane to the
1002 /// right; `SplitDirection::Horizontal` places it below. Unlike
1003 /// `open_terminal` (which attaches a terminal tab to the *current*
1004 /// split), this seeds a brand-new split leaf with the terminal buffer
1005 /// directly — mirroring `Action::OpenTerminalInDock` — so the new pane
1006 /// shows only the terminal, with no phantom tab carrying the
1007 /// previously-active buffer.
1008 pub fn open_terminal_split(&mut self, direction: crate::model::event::SplitDirection) {
1009 // Splitting the layout is a commitment gesture for any preview tab.
1010 // Promote before touching the split tree so the "preview is anchored
1011 // to a single split" invariant holds across the operation (mirrors
1012 // `split_pane_impl`).
1013 self.active_window_mut().promote_current_preview();
1014
1015 // Spawn the PTY first so we have a real terminal buffer to seed the
1016 // new leaf with — otherwise the leaf would carry the user's
1017 // previously-active buffer as a placeholder that would linger as a
1018 // phantom tab.
1019 let Some(terminal_id) = self.spawn_terminal_session() else {
1020 return;
1021 };
1022 let buffer_id = self.create_terminal_buffer_detached(terminal_id);
1023
1024 // Split the active pane, placing the new terminal leaf after
1025 // (right for Vertical, below for Horizontal).
1026 let new_leaf = self
1027 .windows
1028 .get_mut(&self.active_window)
1029 .and_then(|w| w.split_manager_mut())
1030 .expect("active window must have a populated split layout")
1031 .split_active(direction, buffer_id, 0.5);
1032 let new_leaf = match new_leaf {
1033 Ok(leaf) => leaf,
1034 Err(e) => {
1035 self.set_status_message(t!("split.error", error = e.to_string()).to_string());
1036 return;
1037 }
1038 };
1039
1040 let mut view_state =
1041 SplitViewState::with_buffer(self.terminal_width, self.terminal_height, buffer_id);
1042 // Terminal-dedicated splits never show line numbers or current-line
1043 // highlight (mirrors the dock + plugin-terminal split setup).
1044 view_state.apply_config_defaults(
1045 false,
1046 false,
1047 self.active_window().resolve_line_wrap_for_buffer(buffer_id),
1048 self.config.editor.wrap_indent,
1049 self.active_window()
1050 .resolve_wrap_column_for_buffer(buffer_id),
1051 self.config.editor.rulers.clone(),
1052 0,
1053 );
1054 // Terminals don't wrap — keep escape sequences intact.
1055 view_state.viewport.line_wrap_enabled = false;
1056
1057 self.windows
1058 .get_mut(&self.active_window)
1059 .and_then(|w| w.split_view_states_mut())
1060 .expect("active window must have a populated split layout")
1061 .insert(new_leaf, view_state);
1062 self.windows
1063 .get_mut(&self.active_window)
1064 .and_then(|w| w.split_manager_mut())
1065 .expect("active window must have a populated split layout")
1066 .set_active_split(new_leaf);
1067
1068 // Mirror open_terminal's post-attach bookkeeping. The new terminal was
1069 // inserted with `TerminalBuffer::new_live` so its remembered mode is
1070 // already Live; the previously-active terminal keeps its own remembered
1071 // mode, so closing this split later restores it correctly (#2485).
1072 self.active_window_mut().terminal_mode = true;
1073 self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Terminal;
1074 self.active_window_mut().resize_visible_terminals();
1075
1076 // A new split changes every sibling pane's size. Reflow through the
1077 // single layout funnel so existing terminals fit their new panes.
1078 self.relayout();
1079
1080 // Editor-wide: refresh the plugin-state snapshot so plugin hooks see
1081 // the new active buffer, then fire `buffer_activated`.
1082 #[cfg(feature = "plugins")]
1083 self.update_plugin_state_snapshot();
1084 #[cfg(feature = "plugins")]
1085 self.plugin_manager.read().unwrap().run_hook(
1086 "buffer_activated",
1087 crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
1088 );
1089
1090 let exit_key = self
1091 .keybindings
1092 .read()
1093 .unwrap()
1094 .find_keybinding_for_action(
1095 "terminal_escape",
1096 crate::input::keybindings::KeyContext::Terminal,
1097 )
1098 .unwrap_or_else(|| "Ctrl+Space".to_string());
1099 self.set_status_message(
1100 t!("terminal.opened", id = terminal_id.0, exit_key = exit_key).to_string(),
1101 );
1102 tracing::info!(
1103 "Opened terminal {:?} into new split leaf {:?} (buffer {:?})",
1104 terminal_id,
1105 new_leaf,
1106 buffer_id
1107 );
1108 }
1109
1110 /// Editor-side thin wrapper. Delegates to the active window's
1111 /// `Window::create_terminal_buffer_detached` (used during session
1112 /// restore by `input.rs`).
1113 pub(crate) fn create_terminal_buffer_detached(&mut self, terminal_id: TerminalId) -> BufferId {
1114 self.active_window_mut()
1115 .create_terminal_buffer_detached(terminal_id)
1116 }
1117
1118 /// Close the current terminal (if viewing a terminal buffer)
1119 pub fn close_terminal(&mut self) {
1120 let buffer_id = self.active_buffer();
1121
1122 if let Some(terminal_id) = self.active_window().get_terminal_id(buffer_id) {
1123 // Close the terminal
1124 self.active_window_mut().terminal_manager.close(terminal_id);
1125 self.active_window_mut().terminal_buffers.remove(&buffer_id);
1126 self.active_window_mut()
1127 .ephemeral_terminals
1128 .remove(&terminal_id);
1129
1130 // Clean up backing/rendering file
1131 let backing_file = self
1132 .active_window_mut()
1133 .terminal_backing_files
1134 .remove(&terminal_id);
1135 if let Some(ref path) = backing_file {
1136 // Best-effort cleanup of temporary terminal files.
1137 #[allow(clippy::let_underscore_must_use)]
1138 let _ = terminal_backing_fs().remove_file(path);
1139 }
1140 // Clean up raw log file
1141 if let Some(log_file) = self
1142 .active_window_mut()
1143 .terminal_log_files
1144 .remove(&terminal_id)
1145 {
1146 if backing_file.as_ref() != Some(&log_file) {
1147 // Best-effort cleanup of temporary terminal files.
1148 #[allow(clippy::let_underscore_must_use)]
1149 let _ = terminal_backing_fs().remove_file(&log_file);
1150 }
1151 }
1152
1153 // Exit terminal mode
1154 self.active_window_mut().terminal_mode = false;
1155 self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
1156
1157 // Close the buffer
1158 if let Err(e) = self.close_buffer(buffer_id) {
1159 tracing::warn!("Failed to close terminal buffer: {}", e);
1160 }
1161
1162 self.set_status_message(t!("terminal.closed", id = terminal_id.0).to_string());
1163 } else {
1164 self.set_status_message(t!("status.not_viewing_terminal").to_string());
1165 }
1166 }
1167
1168 /// Send the current selection (or the cursor's line when nothing is
1169 /// selected) to the most recently focused terminal, terminated with
1170 /// a newline so shells/REPLs execute it — the "Run Selected Text In
1171 /// Active Terminal" workflow from VS Code (issue #1871). The
1172 /// terminal is then focused (jumping to its split or bringing its
1173 /// tab forward) in terminal mode, so the user lands at the prompt.
1174 pub fn send_selection_to_terminal(&mut self) {
1175 // Only meaningful from an editor buffer; a terminal buffer has
1176 // no text selection to send.
1177 if self
1178 .active_window()
1179 .is_terminal_buffer(self.active_buffer())
1180 {
1181 return;
1182 }
1183
1184 let Some(terminal_id) = self.active_window().last_focused_terminal() else {
1185 self.set_status_message(t!("terminal.no_terminal_open").to_string());
1186 return;
1187 };
1188
1189 let text = self.selection_or_cursor_line_text();
1190
1191 // Same normalization as the terminal paste path (CRLF/CR →
1192 // LF), plus a terminating newline so the last line runs.
1193 let mut normalized = text.replace("\r\n", "\n").replace('\r', "\n");
1194 if !normalized.ends_with('\n') {
1195 normalized.push('\n');
1196 }
1197
1198 if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
1199 handle.write(normalized.as_bytes());
1200 self.focus_terminal_buffer(terminal_id);
1201 // After `enter_terminal_mode`'s generic message — the send
1202 // destination is the more useful thing to surface.
1203 self.set_status_message(t!("terminal.sent_selection", id = terminal_id.0).to_string());
1204 }
1205 }
1206
1207 /// Focus the buffer of the given terminal: jump to the split that
1208 /// shows it, or — when it sits in a background tab — focus its host
1209 /// split and bring the tab forward; then enable terminal mode so
1210 /// keystrokes go to the prompt.
1211 fn focus_terminal_buffer(&mut self, terminal_id: TerminalId) {
1212 let Some(buffer_id) = self
1213 .active_window()
1214 .terminal_buffers
1215 .iter()
1216 .find_map(|(buffer, tb)| (tb.terminal_id == terminal_id).then_some(*buffer))
1217 else {
1218 return;
1219 };
1220
1221 // Prefer a split currently showing the terminal; otherwise the
1222 // split holding it as a background tab. `focus_split` handles
1223 // both (it delegates to the tab-switch path when the target is
1224 // the active split).
1225 let target_split = self.active_window().buffers.splits().and_then(|(mgr, vs)| {
1226 mgr.splits_for_buffer(buffer_id)
1227 .into_iter()
1228 .next()
1229 .or_else(|| {
1230 vs.iter()
1231 .find(|(_, view_state)| view_state.has_buffer(buffer_id))
1232 .map(|(split_id, _)| *split_id)
1233 })
1234 });
1235 if let Some(split_id) = target_split {
1236 self.focus_split(split_id, buffer_id);
1237 } else {
1238 self.switch_buffer(buffer_id);
1239 }
1240
1241 // `focus_split` enables terminal mode for the cross-split case,
1242 // but a tab switch resumes it only when the terminal was left in
1243 // terminal mode. Enter it explicitly — this also re-enables
1244 // editing and scrolls a previously-synced scrollback view back
1245 // to the live prompt.
1246 self.enter_terminal_mode();
1247 }
1248
1249 /// Text that "send to terminal" operates on, mirroring
1250 /// `copy_selection`'s precedence: block selection first, then
1251 /// regular selections (joined by newline), else each cursor's
1252 /// current line (without its line ending).
1253 fn selection_or_cursor_line_text(&mut self) -> String {
1254 if self
1255 .active_cursors()
1256 .iter()
1257 .any(|(_, cursor)| cursor.has_block_selection())
1258 {
1259 return self.copy_block_selection_text();
1260 }
1261
1262 let ranges: Vec<_> = self
1263 .active_cursors()
1264 .iter()
1265 .filter_map(|(_, cursor)| cursor.selection_range())
1266 .collect();
1267 if !ranges.is_empty() {
1268 let state = self.active_state_mut();
1269 let mut text = String::new();
1270 for range in ranges {
1271 if !text.is_empty() {
1272 text.push('\n');
1273 }
1274 text.push_str(&state.get_text_range(range.start, range.end));
1275 }
1276 return text;
1277 }
1278
1279 let estimated_line_length = 80;
1280 let positions: Vec<_> = self
1281 .active_cursors()
1282 .iter()
1283 .map(|(_, cursor)| cursor.position)
1284 .collect();
1285 let state = self.active_state_mut();
1286 let mut text = String::new();
1287 for pos in positions {
1288 let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
1289 if let Some((_start, content)) = iter.next_line() {
1290 if !text.is_empty() {
1291 text.push('\n');
1292 }
1293 text.push_str(content.trim_end_matches(['\n', '\r']));
1294 }
1295 }
1296 text
1297 }
1298
1299 // `is_terminal_buffer` and `get_terminal_id` moved to `impl Window`
1300 // (in `window.rs`). Editor callers reach them via
1301 // `self.active_window().is_terminal_buffer(...)` /
1302 // `.get_terminal_id(...)`.
1303
1304 // `get_active_terminal_state`, `send_terminal_input`,
1305 // `send_terminal_key`, `send_terminal_mouse`, and
1306 // `is_terminal_in_alternate_screen` live on `impl Window` — they
1307 // only touch this window's `terminal_buffers` + `terminal_manager`.
1308 // Call them via `self.active_window()` / `self.active_window_mut()`.
1309
1310 /// Handle terminal input when in terminal mode
1311 pub fn handle_terminal_key(
1312 &mut self,
1313 code: crossterm::event::KeyCode,
1314 modifiers: crossterm::event::KeyModifiers,
1315 ) -> bool {
1316 // Check for escape sequences to exit terminal mode
1317 // Ctrl+Space, Ctrl+], or Ctrl+` to exit (Ctrl+\ sends SIGQUIT on Unix)
1318 if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
1319 match code {
1320 crossterm::event::KeyCode::Char(' ')
1321 | crossterm::event::KeyCode::Char(']')
1322 | crossterm::event::KeyCode::Char('`') => {
1323 // Exit terminal mode and sync buffer. The user dropped to
1324 // read-only scrollback: remember that mode so re-focusing
1325 // the terminal keeps it in scrollback.
1326 let __b = self.active_buffer();
1327 self.active_window_mut()
1328 .set_terminal_interaction_mode(__b, TerminalInteractionMode::Scrollback);
1329 self.active_window_mut().terminal_mode = false;
1330 self.active_window_mut().key_context =
1331 crate::input::keybindings::KeyContext::Normal;
1332 self.active_window_mut().sync_terminal_to_buffer(__b);
1333 self.set_status_message(
1334 "Terminal mode disabled - read only (Ctrl+Space to resume)".to_string(),
1335 );
1336 return true;
1337 }
1338 _ => {}
1339 }
1340 }
1341
1342 // Send the key to the terminal
1343 self.active_window_mut().send_terminal_key(code, modifiers);
1344 true
1345 }
1346
1347 /// Re-enter terminal mode from read-only buffer view
1348 ///
1349 /// This truncates the backing file to remove the visible screen tail
1350 /// that was appended when we exited terminal mode, leaving only the
1351 /// incrementally-streamed scrollback history.
1352 pub fn enter_terminal_mode(&mut self) {
1353 if self
1354 .active_window()
1355 .is_terminal_buffer(self.active_buffer())
1356 {
1357 // Resuming into live mode is a mode change: remember it so the
1358 // terminal comes back live the next time it is focused.
1359 let __active = self.active_buffer();
1360 self.active_window_mut()
1361 .set_terminal_interaction_mode(__active, TerminalInteractionMode::Live);
1362 self.active_window_mut().terminal_mode = true;
1363 self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Terminal;
1364
1365 // Re-enable editing when in terminal mode (input goes to PTY)
1366 let __buffer_id = self.active_buffer();
1367 if let Some(state) = self
1368 .windows
1369 .get_mut(&self.active_window)
1370 .map(|w| &mut w.buffers)
1371 .expect("active window present")
1372 .get_mut(&__buffer_id)
1373 {
1374 state.editing_disabled = false;
1375 state.margins.configure_for_line_numbers(false);
1376 }
1377 let __active_split = self.split_manager().active_split();
1378 if let Some(view_state) = self.split_view_states_mut().get_mut(&__active_split) {
1379 view_state.viewport.line_wrap_enabled = false;
1380 }
1381
1382 // Truncate backing file to remove visible screen tail and scroll to bottom
1383 if let Some(terminal_id) = self.active_window().get_terminal_id(self.active_buffer()) {
1384 // Truncate backing file to remove visible screen that was appended
1385 if let Some(backing_path) = self
1386 .active_window()
1387 .terminal_backing_files
1388 .get(&terminal_id)
1389 {
1390 if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
1391 if let Ok(state) = handle.state.lock() {
1392 let truncate_pos = state.backing_file_history_end();
1393 // Always truncate to remove appended visible screen
1394 // (even if truncate_pos is 0, meaning no scrollback yet)
1395 if let Err(e) =
1396 terminal_backing_fs().set_file_length(backing_path, truncate_pos)
1397 {
1398 tracing::warn!("Failed to truncate terminal backing file: {}", e);
1399 }
1400 }
1401 }
1402 }
1403
1404 // Scroll terminal to bottom when re-entering
1405 if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
1406 if let Ok(mut state) = handle.state.lock() {
1407 state.scroll_to_bottom();
1408 }
1409 }
1410 }
1411
1412 // Ensure terminal PTY is sized correctly for current split dimensions
1413 self.active_window_mut().resize_visible_terminals();
1414
1415 self.set_status_message(t!("status.terminal_mode_enabled").to_string());
1416 }
1417 }
1418
1419 /// Get terminal content for rendering
1420 pub fn get_terminal_content(
1421 &self,
1422 buffer_id: BufferId,
1423 ) -> Option<Vec<Vec<crate::services::terminal::TerminalCell>>> {
1424 let terminal_id = self.active_window().get_terminal_id(buffer_id)?;
1425 let handle = self.active_window().terminal_manager.get(terminal_id)?;
1426 let state = handle.state.lock().ok()?;
1427
1428 let (_, rows) = state.size();
1429 let mut content = Vec::with_capacity(rows as usize);
1430
1431 for row in 0..rows {
1432 content.push(state.get_line(row));
1433 }
1434
1435 Some(content)
1436 }
1437}
1438
1439impl Window {
1440 /// Get the terminal state for the active buffer (if it's a terminal buffer).
1441 pub fn get_active_terminal_state(
1442 &self,
1443 ) -> Option<std::sync::MutexGuard<'_, crate::services::terminal::TerminalState>> {
1444 let terminal_id = self.get_terminal_id(self.active_buffer())?;
1445 let handle = self.terminal_manager.get(terminal_id)?;
1446 handle.state.lock().ok()
1447 }
1448
1449 /// Send input bytes to this window's active terminal (no-op if the
1450 /// active buffer is not a terminal).
1451 pub fn send_terminal_input(&mut self, data: &[u8]) {
1452 if let Some(terminal_id) = self.get_terminal_id(self.active_buffer()) {
1453 if let Some(handle) = self.terminal_manager.get(terminal_id) {
1454 handle.write(data);
1455 }
1456 }
1457 }
1458
1459 /// Send a key event to this window's active terminal. Picks
1460 /// "application cursor" vs "normal cursor" escape sequences
1461 /// based on the terminal's current state.
1462 pub fn send_terminal_key(
1463 &mut self,
1464 code: crossterm::event::KeyCode,
1465 modifiers: crossterm::event::KeyModifiers,
1466 ) {
1467 let app_cursor = self
1468 .get_active_terminal_state()
1469 .map(|s| s.is_app_cursor())
1470 .unwrap_or(false);
1471 if let Some(bytes) =
1472 crate::services::terminal::pty::key_to_pty_bytes(code, modifiers, app_cursor)
1473 {
1474 self.send_terminal_input(&bytes);
1475 }
1476 }
1477
1478 /// Send a mouse event to this window's active terminal.
1479 pub fn send_terminal_mouse(
1480 &mut self,
1481 col: u16,
1482 row: u16,
1483 kind: crate::input::handler::TerminalMouseEventKind,
1484 modifiers: crossterm::event::KeyModifiers,
1485 ) {
1486 use crate::input::handler::TerminalMouseEventKind;
1487
1488 // Check if terminal uses SGR mouse encoding.
1489 let use_sgr = self
1490 .get_active_terminal_state()
1491 .map(|s| s.uses_sgr_mouse())
1492 .unwrap_or(true);
1493
1494 // Alternate-scroll mode converts the wheel into arrow keys so the
1495 // wheel scrolls pagers like `less`/`man` that don't track the mouse.
1496 // It must be suppressed whenever the program is itself tracking the
1497 // mouse: such a program (e.g. Claude Code in its full-screen
1498 // "no-flicker" mode) requested mouse reporting precisely so it can
1499 // scroll its own viewport from wheel events. Forwarding synthesized
1500 // Up/Down arrows instead leaks them into the program's input — for
1501 // Claude Code that cycles prompt/message history rather than
1502 // scrolling. This mirrors xterm/alacritty, where alternate scroll is
1503 // inactive while any mouse-tracking mode is on.
1504 //
1505 // Note `ALTERNATE_SCROLL` is on by default in alacritty_terminal, so
1506 // this branch would otherwise fire for every wheel event forwarded to
1507 // an alternate-screen program — the `wants_mouse` guard is what keeps
1508 // mouse-aware programs receiving real wheel reports.
1509 let wants_mouse = self
1510 .get_active_terminal_state()
1511 .map(|s| s.wants_mouse_events())
1512 .unwrap_or(false);
1513 let uses_alt_scroll = !wants_mouse
1514 && self
1515 .get_active_terminal_state()
1516 .map(|s| s.uses_alternate_scroll())
1517 .unwrap_or(false);
1518
1519 if uses_alt_scroll {
1520 match kind {
1521 TerminalMouseEventKind::ScrollUp => {
1522 for _ in 0..3 {
1523 self.send_terminal_input(b"\x1b[A");
1524 }
1525 return;
1526 }
1527 TerminalMouseEventKind::ScrollDown => {
1528 for _ in 0..3 {
1529 self.send_terminal_input(b"\x1b[B");
1530 }
1531 return;
1532 }
1533 _ => {}
1534 }
1535 }
1536
1537 let bytes = if use_sgr {
1538 encode_sgr_mouse(col, row, kind, modifiers)
1539 } else {
1540 encode_x10_mouse(col, row, kind, modifiers)
1541 };
1542
1543 if let Some(bytes) = bytes {
1544 self.send_terminal_input(&bytes);
1545 }
1546 }
1547
1548 /// Check if the given terminal buffer in this window is in
1549 /// alternate-screen mode (vim/less/htop etc.).
1550 pub fn is_terminal_in_alternate_screen(&self, buffer_id: BufferId) -> bool {
1551 if let Some(terminal_id) = self.get_terminal_id(buffer_id) {
1552 if let Some(handle) = self.terminal_manager.get(terminal_id) {
1553 if let Ok(state) = handle.state.lock() {
1554 return state.is_alternate_screen();
1555 }
1556 }
1557 }
1558 false
1559 }
1560
1561 /// Resize a single terminal buffer's PTY (only if `buffer_id`
1562 /// belongs to this window's terminal_buffers map).
1563 pub fn resize_terminal(&mut self, buffer_id: BufferId, cols: u16, rows: u16) {
1564 if let Some(terminal_id) = self.get_terminal_id(buffer_id) {
1565 if let Some(handle) = self.terminal_manager.get_mut(terminal_id) {
1566 handle.resize(cols, rows);
1567 }
1568 }
1569 }
1570
1571 /// The rect the editor splits lay out into, mirroring the renderer
1572 /// (`render.rs::compute_dock_split` + the file-explorer split): the
1573 /// editor-global dock claims the leftmost `dock_cols`, then the file
1574 /// explorer claims a slice of the remaining chrome, and the splits get
1575 /// what's left. `dock_cols` is pushed down by `Editor::relayout`.
1576 /// Computing the file-explorer width against the post-dock chrome
1577 /// width (not the full screen) matches the renderer exactly, so split
1578 /// geometry derived from this lines up with the cells actually drawn.
1579 pub(crate) fn editor_content_area(&self) -> ratatui::layout::Rect {
1580 let chrome_width = self.terminal_width.saturating_sub(self.dock_cols);
1581 let file_explorer_width = if self.file_explorer_visible {
1582 self.file_explorer_width.to_cols(chrome_width)
1583 } else {
1584 0
1585 };
1586 let editor_x = match self.file_explorer_side {
1587 crate::config::FileExplorerSide::Left => {
1588 self.dock_cols.saturating_add(file_explorer_width)
1589 }
1590 crate::config::FileExplorerSide::Right => self.dock_cols,
1591 };
1592 let editor_width = chrome_width.saturating_sub(file_explorer_width);
1593 ratatui::layout::Rect::new(
1594 editor_x,
1595 1, // menu bar
1596 editor_width,
1597 self.terminal_height.saturating_sub(2), // menu bar + status bar
1598 )
1599 }
1600
1601 /// Resize all this window's visible terminal PTYs to match their
1602 /// current split dimensions. Reads the window's cached
1603 /// `terminal_width` / `terminal_height` for the screen size.
1604 pub fn resize_visible_terminals(&mut self) {
1605 let editor_area = self.editor_content_area();
1606
1607 let Some((mgr, _)) = self.buffers.splits() else {
1608 return;
1609 };
1610 let visible_buffers = mgr.get_visible_buffers(editor_area);
1611
1612 let active_buffer = self.active_buffer();
1613 for (_split_id, buffer_id, split_area) in visible_buffers {
1614 if self.terminal_buffers.contains_key(&buffer_id) {
1615 // The active split's terminal hides its scrollbar while in
1616 // terminal mode (live PTY grid), so the grid reclaims that
1617 // column; the read-only scrollback view shown after exiting
1618 // keeps its scrollbar. Mirror the renderer's
1619 // `terminal_showing_live_grid` test so the PTY width matches
1620 // the rendered `content_rect`.
1621 let showing_live_grid = buffer_id == active_buffer && self.terminal_mode;
1622 let scrollbar_cols = if showing_live_grid { 0 } else { 1 };
1623 // Tab bar takes 1 row; reserve 1 row for chrome and the
1624 // scrollbar column (when shown) on the right.
1625 let content_height = split_area.height.saturating_sub(2);
1626 let content_width = split_area.width.saturating_sub(1 + scrollbar_cols);
1627
1628 if content_width > 0 && content_height > 0 {
1629 self.resize_terminal(buffer_id, content_width, content_height);
1630 }
1631 }
1632 }
1633 }
1634
1635 /// Sync terminal content to the active terminal buffer's text view
1636 /// for read-only viewing / selection.
1637 ///
1638 /// Incremental streaming architecture:
1639 /// 1. Scrollback has already been streamed to the backing file during PTY reads.
1640 /// 2. We append the visible screen (~50 lines) to the backing file.
1641 /// 3. Reload the buffer from the backing file (lazy load for large files).
1642 ///
1643 /// Performance: O(screen_size) instead of O(total_history).
1644 pub fn sync_terminal_to_buffer(&mut self, buffer_id: BufferId) {
1645 let Some(terminal_id) = self.get_terminal_id(buffer_id) else {
1646 return;
1647 };
1648 // Get the backing file path
1649 let backing_file = match self.terminal_backing_files.get(&terminal_id) {
1650 Some(path) => path.clone(),
1651 None => return,
1652 };
1653
1654 // Append visible screen to backing file
1655 // The scrollback has already been incrementally streamed by the PTY read loop.
1656 // Capture the file size *just before* the append so the viewport
1657 // can anchor to it below — that byte offset is the first byte of
1658 // the visible screen we're about to append, which is exactly
1659 // where the live PTY grid drew its row 0.
1660 let mut history_end_byte: Option<u64> = None;
1661 if let Some(handle) = self.terminal_manager.get(terminal_id) {
1662 if let Ok(mut state) = handle.state.lock() {
1663 use std::io::BufWriter;
1664
1665 // Flush any scrollback that has scrolled off but isn't in the
1666 // file yet — in particular the lines a resize spilled from the
1667 // screen into history. The PTY read loop also flushes on output,
1668 // but an idle terminal that was only resized has pending lines;
1669 // capturing them here guarantees the scroll-back view is complete.
1670 if let Ok(mut file) = terminal_backing_fs().open_file_for_append(&backing_file) {
1671 let mut writer = BufWriter::new(&mut *file);
1672 if let Err(e) = state.flush_new_scrollback(&mut writer) {
1673 tracing::error!("Failed to flush terminal scrollback: {}", e);
1674 }
1675 }
1676
1677 // Record the current file size as the history end point
1678 // (before appending visible screen) so we can truncate back to it
1679 if let Ok(metadata) = terminal_backing_fs().metadata(&backing_file) {
1680 state.set_backing_file_history_end(metadata.size);
1681 history_end_byte = Some(metadata.size);
1682 }
1683
1684 // Open backing file in append mode to add visible screen
1685 if let Ok(mut file) = terminal_backing_fs().open_file_for_append(&backing_file) {
1686 let mut writer = BufWriter::new(&mut *file);
1687 if let Err(e) = state.append_visible_screen(&mut writer) {
1688 tracing::error!("Failed to append visible screen to backing file: {}", e);
1689 }
1690 }
1691 }
1692 }
1693
1694 // Reload buffer from the backing file (reusing existing file loading).
1695 // Force text mode: raw PTY scrollback can contain control bytes that
1696 // would otherwise trip binary detection, dropping ANSI colors and
1697 // showing escape-code fragments in scrollback mode (#2449).
1698 let large_file_threshold = self.resources.config.editor.large_file_threshold_bytes as usize;
1699 if let Ok(new_state) = EditorState::from_file_with_languages_force_text(
1700 &backing_file,
1701 self.terminal_width,
1702 self.terminal_height,
1703 large_file_threshold,
1704 &self.resources.grammar_registry,
1705 &self.resources.config.languages,
1706 terminal_backing_fs(),
1707 ) {
1708 let total_bytes = new_state.buffer.total_bytes();
1709 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1710 *state = new_state;
1711 // Terminal buffers should never be considered "modified"
1712 state.buffer.set_modified(false);
1713 }
1714 // Anchor the viewport at the first byte of the appended
1715 // visible screen and place the cursor there too. The scroll-
1716 // back view now opens with the just-appended PTY rows at the
1717 // top — exactly where the live grid drew them — so exit is
1718 // pixel-identical to the last terminal-mode tick even when
1719 // most of the screen is blank (post-`clear` / `reset`). The
1720 // old `cursor = total_bytes` + `ensure_cursor_visible` path
1721 // anchored the bottom row instead, which pulled older
1722 // scrollback into rows the PTY had drawn blank.
1723 let anchor_byte = history_end_byte
1724 .map(|h| (h as usize).min(total_bytes))
1725 .unwrap_or(total_bytes);
1726 if let Some((mgr, view_states)) = self.buffers.splits_mut() {
1727 let active_split = mgr.active_split();
1728 if let Some(view_state) = view_states.get_mut(&active_split) {
1729 view_state.cursors.primary_mut().position = anchor_byte;
1730 view_state.viewport.top_byte = anchor_byte;
1731 view_state.viewport.top_view_line_offset = 0;
1732 view_state.viewport.left_column = 0;
1733 }
1734 }
1735 }
1736
1737 // Mark buffer as editing-disabled while in non-terminal mode
1738 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1739 state.editing_disabled = true;
1740 state.margins.configure_for_line_numbers(false);
1741 }
1742
1743 // Refresh line-wrap state for the scroll-back view and arm the
1744 // skip_ensure_visible flag so the next render does *not* run
1745 // `Viewport::ensure_visible` against the cursor we just pinned.
1746 // Without this the renderer would notice that the cursor sits
1747 // on the viewport's top row, treat that as "above the scroll
1748 // margin", and scroll `top_byte` up by `scroll_offset` lines —
1749 // pulling pre-existing scrollback above the appended visible
1750 // screen and undoing the anchor. The flag is consumed
1751 // (cleared) by the first navigation / scroll action, so normal
1752 // scrolling still works after that.
1753 //
1754 // Also force the per-buffer gutter / current-line-highlight off
1755 // here as the exit-path's last line of defense. Spawn /
1756 // workspace-restore code paths each have their own setup, and a
1757 // single missed spot leaks a gutter pop-in on exit — pinning
1758 // them on this path covers any terminal regardless of how its
1759 // view state was created.
1760 if let Some((mgr, view_states)) = self.buffers.splits_mut() {
1761 let active_split = mgr.active_split();
1762 // The active split's view state may not yet have a keyed
1763 // entry for the terminal buffer (e.g. user just pressed
1764 // Alt+] into a split that has the terminal as a tab but
1765 // never displayed it before). ensure_buffer_state will
1766 // create one with defaults (show_line_numbers=true) the
1767 // very first time — so we have to *immediately* override
1768 // those defaults here, otherwise the next render flashes
1769 // a gutter for restored terminals.
1770 //
1771 // Also force the gutter / current-line-highlight off on
1772 // every other split that has this terminal as a tab. A
1773 // single missed BufferViewState (e.g. created lazily by
1774 // workspace restore + Alt+]) leaks a gutter pop-in.
1775 for vs in view_states.values_mut() {
1776 if vs.has_buffer(buffer_id) {
1777 let buf_state = vs.ensure_buffer_state(buffer_id);
1778 buf_state.show_line_numbers = false;
1779 buf_state.highlight_current_line = false;
1780 // Scrollback is stored as unwrapped logical lines, so soft-wrap
1781 // the read-only view to reflow long lines to the current width.
1782 // (Visible-screen rows are ≤ the view width and so never wrap,
1783 // keeping the exit frame aligned with the live grid.)
1784 buf_state.viewport.line_wrap_enabled = true;
1785 }
1786 }
1787 if let Some(view_state) = view_states.get_mut(&active_split) {
1788 view_state.viewport.line_wrap_enabled = true;
1789 view_state.viewport.set_skip_ensure_visible();
1790 let buf_state = view_state.ensure_buffer_state(buffer_id);
1791 buf_state.show_line_numbers = false;
1792 buf_state.highlight_current_line = false;
1793 }
1794 }
1795 }
1796
1797 /// Render terminal content for terminal buffers in this window's
1798 /// split areas. Overlays the live PTY grid (colors, attributes,
1799 /// optional cursor) on top of the buffer's regular text content
1800 /// inside `content_rect`.
1801 ///
1802 /// `cursor_visible_if_active` controls whether the cursor is
1803 /// painted at all. The active-window render passes `true` so a
1804 /// focused terminal in `terminal_mode` blinks normally; the
1805 /// preview path passes `false` so the picker preview stays
1806 /// read-only.
1807 ///
1808 /// Window-local in every respect — reads `terminal_buffers`,
1809 /// `terminal_manager`, `terminal_mode`, `active_buffer()`, and
1810 /// `resources.theme` from `self`. The caller picks the window
1811 /// (active vs previewed); this method never reaches back to an
1812 /// `Editor` or to any other window.
1813 pub fn render_terminal_splits(
1814 &self,
1815 frame: &mut ratatui::Frame,
1816 split_areas: &[(
1817 crate::model::event::LeafId,
1818 BufferId,
1819 ratatui::layout::Rect,
1820 ratatui::layout::Rect,
1821 usize,
1822 usize,
1823 )],
1824 cursor_visible_if_active: bool,
1825 ) {
1826 for (_split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1827 split_areas
1828 {
1829 let Some(terminal_id) = self.get_terminal_id(*buffer_id) else {
1830 continue;
1831 };
1832 // When the user's current tab is a terminal but they're
1833 // *not* in terminal mode, the buffer is showing the
1834 // synced scrollback view — defer to the normal text
1835 // rendering so the user can scroll. The live grid only
1836 // overlays when terminal mode is active, or when the
1837 // tab isn't the active one (so a split's hidden tab
1838 // still gets live updates).
1839 let is_active = *buffer_id == self.active_buffer();
1840 if is_active && !self.terminal_mode {
1841 continue;
1842 }
1843 let Some(handle) = self.terminal_manager.get(terminal_id) else {
1844 continue;
1845 };
1846 let Ok(state) = handle.state.lock() else {
1847 continue;
1848 };
1849 let cursor_pos = state.cursor_position();
1850 let cursor_visible = state.cursor_visible()
1851 && is_active
1852 && self.terminal_mode
1853 && cursor_visible_if_active;
1854 let (_, rows) = state.size();
1855 let mut content = Vec::with_capacity(rows as usize);
1856 for row in 0..rows {
1857 content.push(state.get_line(row));
1858 }
1859 // Ctrl+hover underline: highlight the link span when it's in this
1860 // terminal buffer.
1861 let link_highlight = self
1862 .terminal_link_hover
1863 .as_ref()
1864 .and_then(|h| (h.buffer_id == *buffer_id).then(|| (h.row, h.cols.clone())));
1865 frame.render_widget(ratatui::widgets::Clear, *content_rect);
1866 let theme = self.resources.theme.read().unwrap();
1867 render::render_terminal_content(
1868 &content,
1869 cursor_pos,
1870 cursor_visible,
1871 *content_rect,
1872 frame.buffer_mut(),
1873 theme.terminal_fg,
1874 theme.terminal_bg,
1875 link_highlight,
1876 );
1877 }
1878 }
1879}
1880
1881impl Editor {
1882 /// Check if terminal mode is active (for testing)
1883 pub fn is_terminal_mode(&self) -> bool {
1884 self.active_window().terminal_mode
1885 }
1886
1887 /// Check if keyboard capture is enabled in terminal mode (for testing)
1888 pub fn is_keyboard_capture(&self) -> bool {
1889 self.active_window().keyboard_capture
1890 }
1891
1892 /// Set terminal jump_to_end_on_output config option (for testing)
1893 pub fn set_terminal_jump_to_end_on_output(&mut self, value: bool) {
1894 self.config_mut().terminal.jump_to_end_on_output = value;
1895 }
1896
1897 /// Get read-only access to the active window's terminal manager
1898 /// (for testing). After Step 0d, terminal state lives on each
1899 /// window — this routes to the active one.
1900 pub fn terminal_manager(&self) -> &crate::services::terminal::TerminalManager {
1901 &self
1902 .windows
1903 .get(&self.active_window)
1904 .expect("active window must exist")
1905 .terminal_manager
1906 }
1907
1908 /// Get read-only access to the active window's terminal backing
1909 /// files map (for testing).
1910 pub fn terminal_backing_files(
1911 &self,
1912 ) -> &std::collections::HashMap<crate::services::terminal::TerminalId, std::path::PathBuf> {
1913 &self
1914 .windows
1915 .get(&self.active_window)
1916 .expect("active window must exist")
1917 .terminal_backing_files
1918 }
1919
1920 /// Get the currently active buffer ID
1921 pub fn active_buffer_id(&self) -> BufferId {
1922 self.active_buffer()
1923 }
1924
1925 /// Get buffer content as a string (for testing)
1926 pub fn get_buffer_content(&self, buffer_id: BufferId) -> Option<String> {
1927 self.windows
1928 .get(&self.active_window)
1929 .map(|w| &w.buffers)
1930 .expect("active window present")
1931 .get(&buffer_id)
1932 .and_then(|state| state.buffer.to_string())
1933 }
1934
1935 /// Get cursor position for a buffer (for testing)
1936 pub fn get_cursor_position(&self, buffer_id: BufferId) -> Option<usize> {
1937 // Find cursor from any split view state that has this buffer
1938 self.windows
1939 .get(&self.active_window)
1940 .and_then(|w| w.buffers.splits())
1941 .map(|(_, vs)| vs)
1942 .expect("active window must have a populated split layout")
1943 .values()
1944 .find_map(|vs| {
1945 if vs.keyed_states.contains_key(&buffer_id) {
1946 Some(vs.keyed_states.get(&buffer_id)?.cursors.primary().position)
1947 } else {
1948 None
1949 }
1950 })
1951 .or_else(|| {
1952 // Fallback: check active cursors
1953 self.windows
1954 .get(&self.active_window)
1955 .and_then(|w| w.buffers.splits())
1956 .map(|(_, vs)| vs)
1957 .expect("active window must have a populated split layout")
1958 .values()
1959 .map(|vs| vs.cursors.primary().position)
1960 .next()
1961 })
1962 }
1963
1964 // `render_terminal_splits` moved to `impl Window`. Active-window
1965 // callers reach it via `self.active_window().render_terminal_splits(...)`;
1966 // the picker preview path reaches it via the previewed window
1967 // directly, so the live PTY grid renders into the preview embed
1968 // without going through the active-window state.
1969}
1970
1971/// Terminal rendering utilities
1972pub mod render {
1973 use crate::services::terminal::TerminalCell;
1974 use ratatui::buffer::Buffer;
1975 use ratatui::layout::Rect;
1976 use ratatui::style::{Color, Modifier, Style};
1977
1978 /// Render terminal content to a ratatui buffer
1979 #[allow(clippy::too_many_arguments)]
1980 pub fn render_terminal_content(
1981 content: &[Vec<TerminalCell>],
1982 cursor_pos: (u16, u16),
1983 cursor_visible: bool,
1984 area: Rect,
1985 buf: &mut Buffer,
1986 default_fg: Color,
1987 default_bg: Color,
1988 link_highlight: Option<(u16, std::ops::Range<usize>)>,
1989 ) {
1990 // Fill the rendered area with the theme's terminal bg first so any
1991 // cells past the PTY grid (e.g. transiently smaller than the rect
1992 // mid-resize) show the theme background rather than leaking the
1993 // host terminal's default bg. Issue #1890.
1994 buf.set_style(area, Style::default().fg(default_fg).bg(default_bg));
1995
1996 for (row_idx, row) in content.iter().enumerate() {
1997 if row_idx as u16 >= area.height {
1998 break;
1999 }
2000
2001 let y = area.y + row_idx as u16;
2002
2003 for (col_idx, cell) in row.iter().enumerate() {
2004 if col_idx as u16 >= area.width {
2005 break;
2006 }
2007
2008 let x = area.x + col_idx as u16;
2009
2010 // Build style from cell attributes, using theme defaults
2011 let mut style = Style::default().fg(default_fg).bg(default_bg);
2012
2013 // Override with cell-specific colors if present
2014 if let Some((r, g, b)) = cell.fg {
2015 style = style.fg(Color::Rgb(r, g, b));
2016 }
2017
2018 if let Some((r, g, b)) = cell.bg {
2019 style = style.bg(Color::Rgb(r, g, b));
2020 }
2021
2022 // Apply modifiers
2023 if cell.bold {
2024 style = style.add_modifier(Modifier::BOLD);
2025 }
2026 if cell.italic {
2027 style = style.add_modifier(Modifier::ITALIC);
2028 }
2029 if cell.underline {
2030 style = style.add_modifier(Modifier::UNDERLINED);
2031 }
2032 if cell.inverse {
2033 style = style.add_modifier(Modifier::REVERSED);
2034 }
2035
2036 // Ctrl+hover link highlight: underline the link span so it
2037 // reads as clickable.
2038 if let Some((link_row, ref cols)) = link_highlight {
2039 if row_idx as u16 == link_row && cols.contains(&col_idx) {
2040 style = style.add_modifier(Modifier::UNDERLINED);
2041 }
2042 }
2043
2044 // Check if this is the cursor position
2045 if cursor_visible
2046 && row_idx as u16 == cursor_pos.1
2047 && col_idx as u16 == cursor_pos.0
2048 {
2049 style = style.add_modifier(Modifier::REVERSED);
2050 }
2051
2052 buf.set_string(x, y, cell.c.to_string(), style);
2053 }
2054 }
2055 }
2056
2057 #[cfg(test)]
2058 mod tests {
2059 use super::*;
2060 use crate::services::terminal::TerminalCell;
2061
2062 #[test]
2063 fn cells_past_pty_grid_get_theme_bg() {
2064 // PTY grid is 2x2, render area is 4x3 — the cells outside
2065 // the grid must still carry the theme's terminal_bg so the
2066 // nostalgia theme's blue fully covers the terminal pane
2067 // (issue #1890).
2068 let area = Rect::new(0, 0, 4, 3);
2069 let mut buf = Buffer::empty(area);
2070 let row = vec![TerminalCell::default(), TerminalCell::default()];
2071 let content = vec![row.clone(), row];
2072
2073 let default_bg = Color::Rgb(0, 0, 170);
2074 let default_fg = Color::Rgb(255, 255, 85);
2075
2076 render_terminal_content(
2077 &content,
2078 (0, 0),
2079 false,
2080 area,
2081 &mut buf,
2082 default_fg,
2083 default_bg,
2084 None,
2085 );
2086
2087 for y in area.top()..area.bottom() {
2088 for x in area.left()..area.right() {
2089 assert_eq!(
2090 buf[(x, y)].bg,
2091 default_bg,
2092 "cell ({x}, {y}) bg should be the theme terminal_bg",
2093 );
2094 }
2095 }
2096 }
2097
2098 /// The Ctrl+hover link highlight underlines exactly the cells in the
2099 /// given (row, col-range) span and leaves the rest untouched.
2100 #[test]
2101 fn link_highlight_underlines_only_its_span() {
2102 // One 6-wide row of text "abcdef".
2103 let area = Rect::new(0, 0, 6, 1);
2104 let mut buf = Buffer::empty(area);
2105 let row: Vec<TerminalCell> = "abcdef"
2106 .chars()
2107 .map(|c| TerminalCell {
2108 c,
2109 ..Default::default()
2110 })
2111 .collect();
2112 let content = vec![row];
2113
2114 render_terminal_content(
2115 &content,
2116 (0, 0),
2117 false,
2118 area,
2119 &mut buf,
2120 Color::White,
2121 Color::Black,
2122 Some((0, 2..5)), // underline columns 2,3,4
2123 );
2124
2125 for x in 0..area.width {
2126 let underlined = buf[(x, 0)].modifier.contains(Modifier::UNDERLINED);
2127 let expected = (2..5).contains(&(x as usize));
2128 assert_eq!(
2129 underlined, expected,
2130 "cell col {x} underline = {underlined}, expected {expected}",
2131 );
2132 }
2133 }
2134 }
2135}
2136
2137/// Encode a mouse event in SGR format (modern protocol).
2138/// Format: CSI < Cb ; Cx ; Cy M (press) or CSI < Cb ; Cx ; Cy m (release)
2139fn encode_sgr_mouse(
2140 col: u16,
2141 row: u16,
2142 kind: crate::input::handler::TerminalMouseEventKind,
2143 modifiers: crossterm::event::KeyModifiers,
2144) -> Option<Vec<u8>> {
2145 use crate::input::handler::{TerminalMouseButton, TerminalMouseEventKind};
2146
2147 // SGR uses 1-based coordinates
2148 let cx = col + 1;
2149 let cy = row + 1;
2150
2151 // Build button code
2152 let (button_code, is_release) = match kind {
2153 TerminalMouseEventKind::Down(btn) => {
2154 let code = match btn {
2155 TerminalMouseButton::Left => 0,
2156 TerminalMouseButton::Middle => 1,
2157 TerminalMouseButton::Right => 2,
2158 };
2159 (code, false)
2160 }
2161 TerminalMouseEventKind::Up(btn) => {
2162 let code = match btn {
2163 TerminalMouseButton::Left => 0,
2164 TerminalMouseButton::Middle => 1,
2165 TerminalMouseButton::Right => 2,
2166 };
2167 (code, true)
2168 }
2169 TerminalMouseEventKind::Drag(btn) => {
2170 let code = match btn {
2171 TerminalMouseButton::Left => 32, // 0 + 32 (motion flag)
2172 TerminalMouseButton::Middle => 33, // 1 + 32
2173 TerminalMouseButton::Right => 34, // 2 + 32
2174 };
2175 (code, false)
2176 }
2177 TerminalMouseEventKind::Moved => (35, false), // 3 + 32 (no button + motion)
2178 TerminalMouseEventKind::ScrollUp => (64, false),
2179 TerminalMouseEventKind::ScrollDown => (65, false),
2180 };
2181
2182 // Add modifier flags
2183 let mut cb = button_code;
2184 if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
2185 cb += 4;
2186 }
2187 if modifiers.contains(crossterm::event::KeyModifiers::ALT) {
2188 cb += 8;
2189 }
2190 if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
2191 cb += 16;
2192 }
2193
2194 // Build escape sequence
2195 let terminator = if is_release { 'm' } else { 'M' };
2196 Some(format!("\x1b[<{};{};{}{}", cb, cx, cy, terminator).into_bytes())
2197}
2198
2199/// Encode a mouse event in X10/normal format (legacy protocol).
2200/// Format: CSI M Cb Cx Cy (with 32 added to all values for ASCII safety)
2201fn encode_x10_mouse(
2202 col: u16,
2203 row: u16,
2204 kind: crate::input::handler::TerminalMouseEventKind,
2205 modifiers: crossterm::event::KeyModifiers,
2206) -> Option<Vec<u8>> {
2207 use crate::input::handler::{TerminalMouseButton, TerminalMouseEventKind};
2208
2209 // X10 uses 1-based coordinates with 32 offset for ASCII safety
2210 // Maximum coordinate is 223 (255 - 32)
2211 let cx = (col.min(222) + 1 + 32) as u8;
2212 let cy = (row.min(222) + 1 + 32) as u8;
2213
2214 // Build button code
2215 let button_code: u8 = match kind {
2216 TerminalMouseEventKind::Down(btn) | TerminalMouseEventKind::Drag(btn) => match btn {
2217 TerminalMouseButton::Left => 0,
2218 TerminalMouseButton::Middle => 1,
2219 TerminalMouseButton::Right => 2,
2220 },
2221 TerminalMouseEventKind::Up(_) => 3, // Release is button 3 in X10
2222 TerminalMouseEventKind::Moved => 3 + 32,
2223 TerminalMouseEventKind::ScrollUp => 64,
2224 TerminalMouseEventKind::ScrollDown => 65,
2225 };
2226
2227 // Add modifier flags and motion flag for drag
2228 let mut cb = button_code;
2229 if matches!(kind, TerminalMouseEventKind::Drag(_)) {
2230 cb += 32; // Motion flag
2231 }
2232 if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
2233 cb += 4;
2234 }
2235 if modifiers.contains(crossterm::event::KeyModifiers::ALT) {
2236 cb += 8;
2237 }
2238 if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
2239 cb += 16;
2240 }
2241
2242 // Add 32 offset for ASCII safety
2243 let cb = cb + 32;
2244
2245 Some(vec![0x1b, b'[', b'M', cb, cx, cy])
2246}
2247
2248#[cfg(test)]
2249mod title_tests {
2250 use super::combine_terminal_title;
2251
2252 #[test]
2253 fn combines_command_and_osc_title() {
2254 assert_eq!(
2255 combine_terminal_title(Some("python3"), Some("root@host: ~/proj")).as_deref(),
2256 Some("python3 \u{2014} root@host: ~/proj")
2257 );
2258 }
2259
2260 #[test]
2261 fn uses_single_source_when_only_one_present() {
2262 assert_eq!(
2263 combine_terminal_title(Some("bash"), None).as_deref(),
2264 Some("bash")
2265 );
2266 assert_eq!(
2267 combine_terminal_title(None, Some("root@host: ~/proj")).as_deref(),
2268 Some("root@host: ~/proj")
2269 );
2270 }
2271
2272 #[test]
2273 fn does_not_duplicate_command_already_in_osc_title() {
2274 // vim sets its own OSC title; don't prepend "vim — … VIM".
2275 assert_eq!(
2276 combine_terminal_title(Some("vim"), Some("README.md (~/proj) - VIM")).as_deref(),
2277 Some("README.md (~/proj) - VIM")
2278 );
2279 }
2280
2281 #[test]
2282 fn none_when_neither_present() {
2283 assert_eq!(combine_terminal_title(None, None), None);
2284 }
2285}