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