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