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 /// Editor-side thin wrapper. Delegates to the active window. New code
600 /// on `impl Window` should call `Window::resolved_terminal_wrapper`
601 /// directly.
602 pub(crate) fn resolved_terminal_wrapper(&self) -> TerminalWrapper {
603 self.active_window().resolved_terminal_wrapper()
604 }
605
606 /// Spawn a new PTY-backed terminal session in the active window
607 /// using its `root` as cwd. Editor-side thin wrapper; per-window
608 /// body lives in `Window::spawn_terminal_session`.
609 ///
610 /// Used by `open_terminal` (regular spawn into the active split)
611 /// and by `Action::OpenTerminalInDock` (which needs the buffer
612 /// id *before* it has a split to attach to, so the dock leaf can
613 /// be seeded with the terminal directly rather than with a
614 /// placeholder buffer that would linger as a phantom tab).
615 pub(crate) fn spawn_terminal_session(&mut self) -> Option<TerminalId> {
616 // No command override — see comment on `Window::open_terminal_in_window`.
617 self.active_window_mut()
618 .spawn_terminal_session(None, true, None)
619 }
620
621 /// Open a new terminal in the active window's current split, fire
622 /// the editor-wide `buffer_activated` plugin hook, and post a
623 /// status-bar message with the terminal-mode exit key.
624 ///
625 /// Window-side body lives in `Window::open_terminal_in_window`;
626 /// this router adds only the cross-cutting effects that require
627 /// editor-level state (the plugin hook + status message).
628 pub fn open_terminal(&mut self) {
629 let Some((terminal_id, buffer_id)) = self.active_window_mut().open_terminal_in_window()
630 else {
631 return;
632 };
633
634 // Editor-wide: refresh the plugin-state snapshot so plugin
635 // hooks see the new active buffer, then fire `buffer_activated`.
636 #[cfg(feature = "plugins")]
637 self.update_plugin_state_snapshot();
638 #[cfg(feature = "plugins")]
639 self.plugin_manager.read().unwrap().run_hook(
640 "buffer_activated",
641 crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
642 );
643
644 // Status bar with the terminal-mode exit key. Looked up here
645 // (not in Window) because the keybinding resolver is shared
646 // editor state read through the `Arc<RwLock<…>>`.
647 let exit_key = self
648 .keybindings
649 .read()
650 .unwrap()
651 .find_keybinding_for_action(
652 "terminal_escape",
653 crate::input::keybindings::KeyContext::Terminal,
654 )
655 .unwrap_or_else(|| "Ctrl+Space".to_string());
656 self.set_status_message(
657 t!("terminal.opened", id = terminal_id.0, exit_key = exit_key).to_string(),
658 );
659 tracing::info!(
660 "Opened terminal {:?} with buffer {:?}",
661 terminal_id,
662 buffer_id
663 );
664 }
665
666 /// Editor-side thin wrapper. Delegates to the active window's
667 /// `Window::create_terminal_buffer_detached` (used during session
668 /// restore by `input.rs`).
669 pub(crate) fn create_terminal_buffer_detached(&mut self, terminal_id: TerminalId) -> BufferId {
670 self.active_window_mut()
671 .create_terminal_buffer_detached(terminal_id)
672 }
673
674 /// Close the current terminal (if viewing a terminal buffer)
675 pub fn close_terminal(&mut self) {
676 let buffer_id = self.active_buffer();
677
678 if let Some(&terminal_id) = self.active_window().terminal_buffers.get(&buffer_id) {
679 // Close the terminal
680 self.active_window_mut().terminal_manager.close(terminal_id);
681 self.active_window_mut().terminal_buffers.remove(&buffer_id);
682 self.active_window_mut()
683 .ephemeral_terminals
684 .remove(&terminal_id);
685
686 // Clean up backing/rendering file
687 let backing_file = self
688 .active_window_mut()
689 .terminal_backing_files
690 .remove(&terminal_id);
691 if let Some(ref path) = backing_file {
692 // Best-effort cleanup of temporary terminal files.
693 #[allow(clippy::let_underscore_must_use)]
694 let _ = self.authority.filesystem.remove_file(path);
695 }
696 // Clean up raw log file
697 if let Some(log_file) = self
698 .active_window_mut()
699 .terminal_log_files
700 .remove(&terminal_id)
701 {
702 if backing_file.as_ref() != Some(&log_file) {
703 // Best-effort cleanup of temporary terminal files.
704 #[allow(clippy::let_underscore_must_use)]
705 let _ = self.authority.filesystem.remove_file(&log_file);
706 }
707 }
708
709 // Exit terminal mode
710 self.active_window_mut().terminal_mode = false;
711 self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
712
713 // Close the buffer
714 if let Err(e) = self.close_buffer(buffer_id) {
715 tracing::warn!("Failed to close terminal buffer: {}", e);
716 }
717
718 self.set_status_message(t!("terminal.closed", id = terminal_id.0).to_string());
719 } else {
720 self.set_status_message(t!("status.not_viewing_terminal").to_string());
721 }
722 }
723
724 // `is_terminal_buffer` and `get_terminal_id` moved to `impl Window`
725 // (in `window.rs`). Editor callers reach them via
726 // `self.active_window().is_terminal_buffer(...)` /
727 // `.get_terminal_id(...)`.
728
729 // `get_active_terminal_state`, `send_terminal_input`,
730 // `send_terminal_key`, `send_terminal_mouse`, and
731 // `is_terminal_in_alternate_screen` live on `impl Window` — they
732 // only touch this window's `terminal_buffers` + `terminal_manager`.
733 // Call them via `self.active_window()` / `self.active_window_mut()`.
734
735 /// Handle terminal input when in terminal mode
736 pub fn handle_terminal_key(
737 &mut self,
738 code: crossterm::event::KeyCode,
739 modifiers: crossterm::event::KeyModifiers,
740 ) -> bool {
741 // Check for escape sequences to exit terminal mode
742 // Ctrl+Space, Ctrl+], or Ctrl+` to exit (Ctrl+\ sends SIGQUIT on Unix)
743 if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
744 match code {
745 crossterm::event::KeyCode::Char(' ')
746 | crossterm::event::KeyCode::Char(']')
747 | crossterm::event::KeyCode::Char('`') => {
748 // Exit terminal mode and sync buffer
749 self.active_window_mut().terminal_mode = false;
750 self.active_window_mut().key_context =
751 crate::input::keybindings::KeyContext::Normal;
752 {
753 let __b = self.active_buffer();
754 self.active_window_mut().sync_terminal_to_buffer(__b);
755 };
756 self.set_status_message(
757 "Terminal mode disabled - read only (Ctrl+Space to resume)".to_string(),
758 );
759 return true;
760 }
761 _ => {}
762 }
763 }
764
765 // Send the key to the terminal
766 self.active_window_mut().send_terminal_key(code, modifiers);
767 true
768 }
769
770 /// Re-enter terminal mode from read-only buffer view
771 ///
772 /// This truncates the backing file to remove the visible screen tail
773 /// that was appended when we exited terminal mode, leaving only the
774 /// incrementally-streamed scrollback history.
775 pub fn enter_terminal_mode(&mut self) {
776 if self
777 .active_window()
778 .is_terminal_buffer(self.active_buffer())
779 {
780 self.active_window_mut().terminal_mode = true;
781 self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Terminal;
782
783 // Re-enable editing when in terminal mode (input goes to PTY)
784 let __buffer_id = self.active_buffer();
785 if let Some(state) = self
786 .windows
787 .get_mut(&self.active_window)
788 .map(|w| &mut w.buffers)
789 .expect("active window present")
790 .get_mut(&__buffer_id)
791 {
792 state.editing_disabled = false;
793 state.margins.configure_for_line_numbers(false);
794 }
795 let __active_split = self.split_manager().active_split();
796 if let Some(view_state) = self.split_view_states_mut().get_mut(&__active_split) {
797 view_state.viewport.line_wrap_enabled = false;
798 }
799
800 // Truncate backing file to remove visible screen tail and scroll to bottom
801 if let Some(&terminal_id) = self
802 .active_window()
803 .terminal_buffers
804 .get(&self.active_buffer())
805 {
806 // Truncate backing file to remove visible screen that was appended
807 if let Some(backing_path) = self
808 .active_window()
809 .terminal_backing_files
810 .get(&terminal_id)
811 {
812 if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
813 if let Ok(state) = handle.state.lock() {
814 let truncate_pos = state.backing_file_history_end();
815 // Always truncate to remove appended visible screen
816 // (even if truncate_pos is 0, meaning no scrollback yet)
817 if let Err(e) = self
818 .authority
819 .filesystem
820 .set_file_length(backing_path, truncate_pos)
821 {
822 tracing::warn!("Failed to truncate terminal backing file: {}", e);
823 }
824 }
825 }
826 }
827
828 // Scroll terminal to bottom when re-entering
829 if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
830 if let Ok(mut state) = handle.state.lock() {
831 state.scroll_to_bottom();
832 }
833 }
834 }
835
836 // Ensure terminal PTY is sized correctly for current split dimensions
837 self.active_window_mut().resize_visible_terminals();
838
839 self.set_status_message(t!("status.terminal_mode_enabled").to_string());
840 }
841 }
842
843 /// Get terminal content for rendering
844 pub fn get_terminal_content(
845 &self,
846 buffer_id: BufferId,
847 ) -> Option<Vec<Vec<crate::services::terminal::TerminalCell>>> {
848 let terminal_id = self.active_window().terminal_buffers.get(&buffer_id)?;
849 let handle = self.active_window().terminal_manager.get(*terminal_id)?;
850 let state = handle.state.lock().ok()?;
851
852 let (_, rows) = state.size();
853 let mut content = Vec::with_capacity(rows as usize);
854
855 for row in 0..rows {
856 content.push(state.get_line(row));
857 }
858
859 Some(content)
860 }
861}
862
863impl Window {
864 /// Get the terminal state for the active buffer (if it's a terminal buffer).
865 pub fn get_active_terminal_state(
866 &self,
867 ) -> Option<std::sync::MutexGuard<'_, crate::services::terminal::TerminalState>> {
868 let terminal_id = self.terminal_buffers.get(&self.active_buffer())?;
869 let handle = self.terminal_manager.get(*terminal_id)?;
870 handle.state.lock().ok()
871 }
872
873 /// Send input bytes to this window's active terminal (no-op if the
874 /// active buffer is not a terminal).
875 pub fn send_terminal_input(&mut self, data: &[u8]) {
876 if let Some(&terminal_id) = self.terminal_buffers.get(&self.active_buffer()) {
877 if let Some(handle) = self.terminal_manager.get(terminal_id) {
878 handle.write(data);
879 }
880 }
881 }
882
883 /// Send a key event to this window's active terminal. Picks
884 /// "application cursor" vs "normal cursor" escape sequences
885 /// based on the terminal's current state.
886 pub fn send_terminal_key(
887 &mut self,
888 code: crossterm::event::KeyCode,
889 modifiers: crossterm::event::KeyModifiers,
890 ) {
891 let app_cursor = self
892 .get_active_terminal_state()
893 .map(|s| s.is_app_cursor())
894 .unwrap_or(false);
895 if let Some(bytes) =
896 crate::services::terminal::pty::key_to_pty_bytes(code, modifiers, app_cursor)
897 {
898 self.send_terminal_input(&bytes);
899 }
900 }
901
902 /// Send a mouse event to this window's active terminal.
903 pub fn send_terminal_mouse(
904 &mut self,
905 col: u16,
906 row: u16,
907 kind: crate::input::handler::TerminalMouseEventKind,
908 modifiers: crossterm::event::KeyModifiers,
909 ) {
910 use crate::input::handler::TerminalMouseEventKind;
911
912 // Check if terminal uses SGR mouse encoding.
913 let use_sgr = self
914 .get_active_terminal_state()
915 .map(|s| s.uses_sgr_mouse())
916 .unwrap_or(true);
917
918 // For alternate scroll mode, convert scroll to arrow keys.
919 let uses_alt_scroll = self
920 .get_active_terminal_state()
921 .map(|s| s.uses_alternate_scroll())
922 .unwrap_or(false);
923
924 if uses_alt_scroll {
925 match kind {
926 TerminalMouseEventKind::ScrollUp => {
927 for _ in 0..3 {
928 self.send_terminal_input(b"\x1b[A");
929 }
930 return;
931 }
932 TerminalMouseEventKind::ScrollDown => {
933 for _ in 0..3 {
934 self.send_terminal_input(b"\x1b[B");
935 }
936 return;
937 }
938 _ => {}
939 }
940 }
941
942 let bytes = if use_sgr {
943 encode_sgr_mouse(col, row, kind, modifiers)
944 } else {
945 encode_x10_mouse(col, row, kind, modifiers)
946 };
947
948 if let Some(bytes) = bytes {
949 self.send_terminal_input(&bytes);
950 }
951 }
952
953 /// Check if the given terminal buffer in this window is in
954 /// alternate-screen mode (vim/less/htop etc.).
955 pub fn is_terminal_in_alternate_screen(&self, buffer_id: BufferId) -> bool {
956 if let Some(&terminal_id) = self.terminal_buffers.get(&buffer_id) {
957 if let Some(handle) = self.terminal_manager.get(terminal_id) {
958 if let Ok(state) = handle.state.lock() {
959 return state.is_alternate_screen();
960 }
961 }
962 }
963 false
964 }
965
966 /// Resize a single terminal buffer's PTY (only if `buffer_id`
967 /// belongs to this window's terminal_buffers map).
968 pub fn resize_terminal(&mut self, buffer_id: BufferId, cols: u16, rows: u16) {
969 if let Some(&terminal_id) = self.terminal_buffers.get(&buffer_id) {
970 if let Some(handle) = self.terminal_manager.get_mut(terminal_id) {
971 handle.resize(cols, rows);
972 }
973 }
974 }
975
976 /// Resize all this window's visible terminal PTYs to match their
977 /// current split dimensions. Reads the window's cached
978 /// `terminal_width` / `terminal_height` for the screen size.
979 pub fn resize_visible_terminals(&mut self) {
980 // Get the content area excluding file explorer
981 let file_explorer_width = if self.file_explorer_visible {
982 self.file_explorer_width.to_cols(self.terminal_width)
983 } else {
984 0
985 };
986 let editor_width = self.terminal_width.saturating_sub(file_explorer_width);
987 let editor_area = ratatui::layout::Rect::new(
988 file_explorer_width,
989 1, // menu bar
990 editor_width,
991 self.terminal_height.saturating_sub(2), // menu bar + status bar
992 );
993
994 let Some((mgr, _)) = self.buffers.splits() else {
995 return;
996 };
997 let visible_buffers = mgr.get_visible_buffers(editor_area);
998
999 for (_split_id, buffer_id, split_area) in visible_buffers {
1000 if self.terminal_buffers.contains_key(&buffer_id) {
1001 // Tab bar takes 1 row, scrollbar takes 1 column on the right.
1002 let content_height = split_area.height.saturating_sub(2);
1003 let content_width = split_area.width.saturating_sub(2);
1004
1005 if content_width > 0 && content_height > 0 {
1006 self.resize_terminal(buffer_id, content_width, content_height);
1007 }
1008 }
1009 }
1010 }
1011
1012 /// Sync terminal content to the active terminal buffer's text view
1013 /// for read-only viewing / selection.
1014 ///
1015 /// Incremental streaming architecture:
1016 /// 1. Scrollback has already been streamed to the backing file during PTY reads.
1017 /// 2. We append the visible screen (~50 lines) to the backing file.
1018 /// 3. Reload the buffer from the backing file (lazy load for large files).
1019 ///
1020 /// Performance: O(screen_size) instead of O(total_history).
1021 pub fn sync_terminal_to_buffer(&mut self, buffer_id: BufferId) {
1022 let Some(&terminal_id) = self.terminal_buffers.get(&buffer_id) else {
1023 return;
1024 };
1025 // Get the backing file path
1026 let backing_file = match self.terminal_backing_files.get(&terminal_id) {
1027 Some(path) => path.clone(),
1028 None => return,
1029 };
1030
1031 // Append visible screen to backing file
1032 // The scrollback has already been incrementally streamed by the PTY read loop.
1033 // Capture the file size *just before* the append so the viewport
1034 // can anchor to it below — that byte offset is the first byte of
1035 // the visible screen we're about to append, which is exactly
1036 // where the live PTY grid drew its row 0.
1037 let mut history_end_byte: Option<u64> = None;
1038 if let Some(handle) = self.terminal_manager.get(terminal_id) {
1039 if let Ok(mut state) = handle.state.lock() {
1040 // Record the current file size as the history end point
1041 // (before appending visible screen) so we can truncate back to it
1042 if let Ok(metadata) = self.resources.authority.filesystem.metadata(&backing_file) {
1043 state.set_backing_file_history_end(metadata.size);
1044 history_end_byte = Some(metadata.size);
1045 }
1046
1047 // Open backing file in append mode to add visible screen
1048 if let Ok(mut file) = self
1049 .resources
1050 .authority
1051 .filesystem
1052 .open_file_for_append(&backing_file)
1053 {
1054 use std::io::BufWriter;
1055 let mut writer = BufWriter::new(&mut *file);
1056 if let Err(e) = state.append_visible_screen(&mut writer) {
1057 tracing::error!("Failed to append visible screen to backing file: {}", e);
1058 }
1059 }
1060 }
1061 }
1062
1063 // Reload buffer from the backing file (reusing existing file loading)
1064 let large_file_threshold = self.resources.config.editor.large_file_threshold_bytes as usize;
1065 if let Ok(new_state) = EditorState::from_file_with_languages(
1066 &backing_file,
1067 self.terminal_width,
1068 self.terminal_height,
1069 large_file_threshold,
1070 &self.resources.grammar_registry,
1071 &self.resources.config.languages,
1072 std::sync::Arc::clone(&self.resources.authority.filesystem),
1073 ) {
1074 let total_bytes = new_state.buffer.total_bytes();
1075 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1076 *state = new_state;
1077 // Terminal buffers should never be considered "modified"
1078 state.buffer.set_modified(false);
1079 }
1080 // Anchor the viewport at the first byte of the appended
1081 // visible screen and place the cursor there too. The scroll-
1082 // back view now opens with the just-appended PTY rows at the
1083 // top — exactly where the live grid drew them — so exit is
1084 // pixel-identical to the last terminal-mode tick even when
1085 // most of the screen is blank (post-`clear` / `reset`). The
1086 // old `cursor = total_bytes` + `ensure_cursor_visible` path
1087 // anchored the bottom row instead, which pulled older
1088 // scrollback into rows the PTY had drawn blank.
1089 let anchor_byte = history_end_byte
1090 .map(|h| (h as usize).min(total_bytes))
1091 .unwrap_or(total_bytes);
1092 if let Some((mgr, view_states)) = self.buffers.splits_mut() {
1093 let active_split = mgr.active_split();
1094 if let Some(view_state) = view_states.get_mut(&active_split) {
1095 view_state.cursors.primary_mut().position = anchor_byte;
1096 view_state.viewport.top_byte = anchor_byte;
1097 view_state.viewport.top_view_line_offset = 0;
1098 view_state.viewport.left_column = 0;
1099 }
1100 }
1101 }
1102
1103 // Mark buffer as editing-disabled while in non-terminal mode
1104 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1105 state.editing_disabled = true;
1106 state.margins.configure_for_line_numbers(false);
1107 }
1108
1109 // Refresh line-wrap state for the scroll-back view and arm the
1110 // skip_ensure_visible flag so the next render does *not* run
1111 // `Viewport::ensure_visible` against the cursor we just pinned.
1112 // Without this the renderer would notice that the cursor sits
1113 // on the viewport's top row, treat that as "above the scroll
1114 // margin", and scroll `top_byte` up by `scroll_offset` lines —
1115 // pulling pre-existing scrollback above the appended visible
1116 // screen and undoing the anchor. The flag is consumed
1117 // (cleared) by the first navigation / scroll action, so normal
1118 // scrolling still works after that.
1119 //
1120 // Also force the per-buffer gutter / current-line-highlight off
1121 // here as the exit-path's last line of defense. Spawn /
1122 // workspace-restore code paths each have their own setup, and a
1123 // single missed spot leaks a gutter pop-in on exit — pinning
1124 // them on this path covers any terminal regardless of how its
1125 // view state was created.
1126 if let Some((mgr, view_states)) = self.buffers.splits_mut() {
1127 let active_split = mgr.active_split();
1128 // The active split's view state may not yet have a keyed
1129 // entry for the terminal buffer (e.g. user just pressed
1130 // Alt+] into a split that has the terminal as a tab but
1131 // never displayed it before). ensure_buffer_state will
1132 // create one with defaults (show_line_numbers=true) the
1133 // very first time — so we have to *immediately* override
1134 // those defaults here, otherwise the next render flashes
1135 // a gutter for restored terminals.
1136 //
1137 // Also force the gutter / current-line-highlight off on
1138 // every other split that has this terminal as a tab. A
1139 // single missed BufferViewState (e.g. created lazily by
1140 // workspace restore + Alt+]) leaks a gutter pop-in.
1141 for vs in view_states.values_mut() {
1142 if vs.has_buffer(buffer_id) {
1143 let buf_state = vs.ensure_buffer_state(buffer_id);
1144 buf_state.show_line_numbers = false;
1145 buf_state.highlight_current_line = false;
1146 buf_state.viewport.line_wrap_enabled = false;
1147 }
1148 }
1149 if let Some(view_state) = view_states.get_mut(&active_split) {
1150 view_state.viewport.line_wrap_enabled = false;
1151 view_state.viewport.set_skip_ensure_visible();
1152 let buf_state = view_state.ensure_buffer_state(buffer_id);
1153 buf_state.show_line_numbers = false;
1154 buf_state.highlight_current_line = false;
1155 }
1156 }
1157 }
1158
1159 /// Render terminal content for terminal buffers in this window's
1160 /// split areas. Overlays the live PTY grid (colors, attributes,
1161 /// optional cursor) on top of the buffer's regular text content
1162 /// inside `content_rect`.
1163 ///
1164 /// `cursor_visible_if_active` controls whether the cursor is
1165 /// painted at all. The active-window render passes `true` so a
1166 /// focused terminal in `terminal_mode` blinks normally; the
1167 /// preview path passes `false` so the picker preview stays
1168 /// read-only.
1169 ///
1170 /// Window-local in every respect — reads `terminal_buffers`,
1171 /// `terminal_manager`, `terminal_mode`, `active_buffer()`, and
1172 /// `resources.theme` from `self`. The caller picks the window
1173 /// (active vs previewed); this method never reaches back to an
1174 /// `Editor` or to any other window.
1175 pub fn render_terminal_splits(
1176 &self,
1177 frame: &mut ratatui::Frame,
1178 split_areas: &[(
1179 crate::model::event::LeafId,
1180 BufferId,
1181 ratatui::layout::Rect,
1182 ratatui::layout::Rect,
1183 usize,
1184 usize,
1185 )],
1186 cursor_visible_if_active: bool,
1187 ) {
1188 for (_split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1189 split_areas
1190 {
1191 let Some(&terminal_id) = self.terminal_buffers.get(buffer_id) else {
1192 continue;
1193 };
1194 // When the user's current tab is a terminal but they're
1195 // *not* in terminal mode, the buffer is showing the
1196 // synced scrollback view — defer to the normal text
1197 // rendering so the user can scroll. The live grid only
1198 // overlays when terminal mode is active, or when the
1199 // tab isn't the active one (so a split's hidden tab
1200 // still gets live updates).
1201 let is_active = *buffer_id == self.active_buffer();
1202 if is_active && !self.terminal_mode {
1203 continue;
1204 }
1205 let Some(handle) = self.terminal_manager.get(terminal_id) else {
1206 continue;
1207 };
1208 let Ok(state) = handle.state.lock() else {
1209 continue;
1210 };
1211 let cursor_pos = state.cursor_position();
1212 let cursor_visible = state.cursor_visible()
1213 && is_active
1214 && self.terminal_mode
1215 && cursor_visible_if_active;
1216 let (_, rows) = state.size();
1217 let mut content = Vec::with_capacity(rows as usize);
1218 for row in 0..rows {
1219 content.push(state.get_line(row));
1220 }
1221 frame.render_widget(ratatui::widgets::Clear, *content_rect);
1222 let theme = self.resources.theme.read().unwrap();
1223 render::render_terminal_content(
1224 &content,
1225 cursor_pos,
1226 cursor_visible,
1227 *content_rect,
1228 frame.buffer_mut(),
1229 theme.terminal_fg,
1230 theme.terminal_bg,
1231 );
1232 }
1233 }
1234}
1235
1236impl Editor {
1237 /// Check if terminal mode is active (for testing)
1238 pub fn is_terminal_mode(&self) -> bool {
1239 self.active_window().terminal_mode
1240 }
1241
1242 /// Check if a buffer is in terminal_mode_resume set (for testing/debugging)
1243 pub fn is_in_terminal_mode_resume(&self, buffer_id: BufferId) -> bool {
1244 self.active_window()
1245 .terminal_mode_resume
1246 .contains(&buffer_id)
1247 }
1248
1249 /// Check if keyboard capture is enabled in terminal mode (for testing)
1250 pub fn is_keyboard_capture(&self) -> bool {
1251 self.active_window().keyboard_capture
1252 }
1253
1254 /// Set terminal jump_to_end_on_output config option (for testing)
1255 pub fn set_terminal_jump_to_end_on_output(&mut self, value: bool) {
1256 self.config_mut().terminal.jump_to_end_on_output = value;
1257 }
1258
1259 /// Get read-only access to the active window's terminal manager
1260 /// (for testing). After Step 0d, terminal state lives on each
1261 /// window — this routes to the active one.
1262 pub fn terminal_manager(&self) -> &crate::services::terminal::TerminalManager {
1263 &self
1264 .windows
1265 .get(&self.active_window)
1266 .expect("active window must exist")
1267 .terminal_manager
1268 }
1269
1270 /// Get read-only access to the active window's terminal backing
1271 /// files map (for testing).
1272 pub fn terminal_backing_files(
1273 &self,
1274 ) -> &std::collections::HashMap<crate::services::terminal::TerminalId, std::path::PathBuf> {
1275 &self
1276 .windows
1277 .get(&self.active_window)
1278 .expect("active window must exist")
1279 .terminal_backing_files
1280 }
1281
1282 /// Get the currently active buffer ID
1283 pub fn active_buffer_id(&self) -> BufferId {
1284 self.active_buffer()
1285 }
1286
1287 /// Get buffer content as a string (for testing)
1288 pub fn get_buffer_content(&self, buffer_id: BufferId) -> Option<String> {
1289 self.windows
1290 .get(&self.active_window)
1291 .map(|w| &w.buffers)
1292 .expect("active window present")
1293 .get(&buffer_id)
1294 .and_then(|state| state.buffer.to_string())
1295 }
1296
1297 /// Get cursor position for a buffer (for testing)
1298 pub fn get_cursor_position(&self, buffer_id: BufferId) -> Option<usize> {
1299 // Find cursor from any split view state that has this buffer
1300 self.windows
1301 .get(&self.active_window)
1302 .and_then(|w| w.buffers.splits())
1303 .map(|(_, vs)| vs)
1304 .expect("active window must have a populated split layout")
1305 .values()
1306 .find_map(|vs| {
1307 if vs.keyed_states.contains_key(&buffer_id) {
1308 Some(vs.keyed_states.get(&buffer_id)?.cursors.primary().position)
1309 } else {
1310 None
1311 }
1312 })
1313 .or_else(|| {
1314 // Fallback: check active cursors
1315 self.windows
1316 .get(&self.active_window)
1317 .and_then(|w| w.buffers.splits())
1318 .map(|(_, vs)| vs)
1319 .expect("active window must have a populated split layout")
1320 .values()
1321 .map(|vs| vs.cursors.primary().position)
1322 .next()
1323 })
1324 }
1325
1326 // `render_terminal_splits` moved to `impl Window`. Active-window
1327 // callers reach it via `self.active_window().render_terminal_splits(...)`;
1328 // the picker preview path reaches it via the previewed window
1329 // directly, so the live PTY grid renders into the preview embed
1330 // without going through the active-window state.
1331}
1332
1333/// Terminal rendering utilities
1334pub mod render {
1335 use crate::services::terminal::TerminalCell;
1336 use ratatui::buffer::Buffer;
1337 use ratatui::layout::Rect;
1338 use ratatui::style::{Color, Modifier, Style};
1339
1340 /// Render terminal content to a ratatui buffer
1341 pub fn render_terminal_content(
1342 content: &[Vec<TerminalCell>],
1343 cursor_pos: (u16, u16),
1344 cursor_visible: bool,
1345 area: Rect,
1346 buf: &mut Buffer,
1347 default_fg: Color,
1348 default_bg: Color,
1349 ) {
1350 // Fill the rendered area with the theme's terminal bg first so any
1351 // cells past the PTY grid (e.g. transiently smaller than the rect
1352 // mid-resize) show the theme background rather than leaking the
1353 // host terminal's default bg. Issue #1890.
1354 buf.set_style(area, Style::default().fg(default_fg).bg(default_bg));
1355
1356 for (row_idx, row) in content.iter().enumerate() {
1357 if row_idx as u16 >= area.height {
1358 break;
1359 }
1360
1361 let y = area.y + row_idx as u16;
1362
1363 for (col_idx, cell) in row.iter().enumerate() {
1364 if col_idx as u16 >= area.width {
1365 break;
1366 }
1367
1368 let x = area.x + col_idx as u16;
1369
1370 // Build style from cell attributes, using theme defaults
1371 let mut style = Style::default().fg(default_fg).bg(default_bg);
1372
1373 // Override with cell-specific colors if present
1374 if let Some((r, g, b)) = cell.fg {
1375 style = style.fg(Color::Rgb(r, g, b));
1376 }
1377
1378 if let Some((r, g, b)) = cell.bg {
1379 style = style.bg(Color::Rgb(r, g, b));
1380 }
1381
1382 // Apply modifiers
1383 if cell.bold {
1384 style = style.add_modifier(Modifier::BOLD);
1385 }
1386 if cell.italic {
1387 style = style.add_modifier(Modifier::ITALIC);
1388 }
1389 if cell.underline {
1390 style = style.add_modifier(Modifier::UNDERLINED);
1391 }
1392 if cell.inverse {
1393 style = style.add_modifier(Modifier::REVERSED);
1394 }
1395
1396 // Check if this is the cursor position
1397 if cursor_visible
1398 && row_idx as u16 == cursor_pos.1
1399 && col_idx as u16 == cursor_pos.0
1400 {
1401 style = style.add_modifier(Modifier::REVERSED);
1402 }
1403
1404 buf.set_string(x, y, cell.c.to_string(), style);
1405 }
1406 }
1407 }
1408
1409 #[cfg(test)]
1410 mod tests {
1411 use super::*;
1412 use crate::services::terminal::TerminalCell;
1413
1414 #[test]
1415 fn cells_past_pty_grid_get_theme_bg() {
1416 // PTY grid is 2x2, render area is 4x3 — the cells outside
1417 // the grid must still carry the theme's terminal_bg so the
1418 // nostalgia theme's blue fully covers the terminal pane
1419 // (issue #1890).
1420 let area = Rect::new(0, 0, 4, 3);
1421 let mut buf = Buffer::empty(area);
1422 let row = vec![TerminalCell::default(), TerminalCell::default()];
1423 let content = vec![row.clone(), row];
1424
1425 let default_bg = Color::Rgb(0, 0, 170);
1426 let default_fg = Color::Rgb(255, 255, 85);
1427
1428 render_terminal_content(
1429 &content,
1430 (0, 0),
1431 false,
1432 area,
1433 &mut buf,
1434 default_fg,
1435 default_bg,
1436 );
1437
1438 for y in area.top()..area.bottom() {
1439 for x in area.left()..area.right() {
1440 assert_eq!(
1441 buf[(x, y)].bg,
1442 default_bg,
1443 "cell ({x}, {y}) bg should be the theme terminal_bg",
1444 );
1445 }
1446 }
1447 }
1448 }
1449}
1450
1451/// Encode a mouse event in SGR format (modern protocol).
1452/// Format: CSI < Cb ; Cx ; Cy M (press) or CSI < Cb ; Cx ; Cy m (release)
1453fn encode_sgr_mouse(
1454 col: u16,
1455 row: u16,
1456 kind: crate::input::handler::TerminalMouseEventKind,
1457 modifiers: crossterm::event::KeyModifiers,
1458) -> Option<Vec<u8>> {
1459 use crate::input::handler::{TerminalMouseButton, TerminalMouseEventKind};
1460
1461 // SGR uses 1-based coordinates
1462 let cx = col + 1;
1463 let cy = row + 1;
1464
1465 // Build button code
1466 let (button_code, is_release) = match kind {
1467 TerminalMouseEventKind::Down(btn) => {
1468 let code = match btn {
1469 TerminalMouseButton::Left => 0,
1470 TerminalMouseButton::Middle => 1,
1471 TerminalMouseButton::Right => 2,
1472 };
1473 (code, false)
1474 }
1475 TerminalMouseEventKind::Up(btn) => {
1476 let code = match btn {
1477 TerminalMouseButton::Left => 0,
1478 TerminalMouseButton::Middle => 1,
1479 TerminalMouseButton::Right => 2,
1480 };
1481 (code, true)
1482 }
1483 TerminalMouseEventKind::Drag(btn) => {
1484 let code = match btn {
1485 TerminalMouseButton::Left => 32, // 0 + 32 (motion flag)
1486 TerminalMouseButton::Middle => 33, // 1 + 32
1487 TerminalMouseButton::Right => 34, // 2 + 32
1488 };
1489 (code, false)
1490 }
1491 TerminalMouseEventKind::Moved => (35, false), // 3 + 32 (no button + motion)
1492 TerminalMouseEventKind::ScrollUp => (64, false),
1493 TerminalMouseEventKind::ScrollDown => (65, false),
1494 };
1495
1496 // Add modifier flags
1497 let mut cb = button_code;
1498 if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
1499 cb += 4;
1500 }
1501 if modifiers.contains(crossterm::event::KeyModifiers::ALT) {
1502 cb += 8;
1503 }
1504 if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
1505 cb += 16;
1506 }
1507
1508 // Build escape sequence
1509 let terminator = if is_release { 'm' } else { 'M' };
1510 Some(format!("\x1b[<{};{};{}{}", cb, cx, cy, terminator).into_bytes())
1511}
1512
1513/// Encode a mouse event in X10/normal format (legacy protocol).
1514/// Format: CSI M Cb Cx Cy (with 32 added to all values for ASCII safety)
1515fn encode_x10_mouse(
1516 col: u16,
1517 row: u16,
1518 kind: crate::input::handler::TerminalMouseEventKind,
1519 modifiers: crossterm::event::KeyModifiers,
1520) -> Option<Vec<u8>> {
1521 use crate::input::handler::{TerminalMouseButton, TerminalMouseEventKind};
1522
1523 // X10 uses 1-based coordinates with 32 offset for ASCII safety
1524 // Maximum coordinate is 223 (255 - 32)
1525 let cx = (col.min(222) + 1 + 32) as u8;
1526 let cy = (row.min(222) + 1 + 32) as u8;
1527
1528 // Build button code
1529 let button_code: u8 = match kind {
1530 TerminalMouseEventKind::Down(btn) | TerminalMouseEventKind::Drag(btn) => match btn {
1531 TerminalMouseButton::Left => 0,
1532 TerminalMouseButton::Middle => 1,
1533 TerminalMouseButton::Right => 2,
1534 },
1535 TerminalMouseEventKind::Up(_) => 3, // Release is button 3 in X10
1536 TerminalMouseEventKind::Moved => 3 + 32,
1537 TerminalMouseEventKind::ScrollUp => 64,
1538 TerminalMouseEventKind::ScrollDown => 65,
1539 };
1540
1541 // Add modifier flags and motion flag for drag
1542 let mut cb = button_code;
1543 if matches!(kind, TerminalMouseEventKind::Drag(_)) {
1544 cb += 32; // Motion flag
1545 }
1546 if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
1547 cb += 4;
1548 }
1549 if modifiers.contains(crossterm::event::KeyModifiers::ALT) {
1550 cb += 8;
1551 }
1552 if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
1553 cb += 16;
1554 }
1555
1556 // Add 32 offset for ASCII safety
1557 let cb = cb + 32;
1558
1559 Some(vec![0x1b, b'[', b'M', cb, cx, cy])
1560}