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