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