Skip to main content

fresh/app/
plugin_dispatch.rs

1//! Plugin command dispatch and plugin-specific handlers on `Editor`.
2//!
3//! Three clusters previously inline in mod.rs:
4//!
5//! - `update_plugin_state_snapshot` — synchronizes the immutable view of
6//!   editor state plugins observe between commands.
7//! - `handle_plugin_command` — the giant match dispatching every
8//!   PluginCommand variant to a specialized handler. Most arms call
9//!   methods in app/plugin_commands.rs; the rest live below.
10//! - The handle_* family — buffer/path lookups, action execution, plugin
11//!   lifecycle management, and view-control commands callable from
12//!   plugin code.
13
14use std::sync::Arc;
15
16use anyhow::Result as AnyhowResult;
17
18use fresh_core::api::{BufferSavedDiff, JsCallbackId, PluginCommand};
19
20use crate::model::event::{BufferId, LeafId, SplitId};
21use crate::services::async_bridge::AsyncMessage;
22use crate::view::split::SplitViewState;
23
24use super::Editor;
25
26impl Editor {
27    /// Update the plugin state snapshot with current editor state
28    #[cfg(feature = "plugins")]
29    pub(super) fn update_plugin_state_snapshot(&mut self) {
30        // Update TypeScript plugin manager state
31        if let Some(snapshot_handle) = self.plugin_manager.state_snapshot_handle() {
32            use fresh_core::api::{BufferInfo, CursorInfo, ViewportInfo};
33            let mut snapshot = snapshot_handle.write().unwrap();
34
35            // Update grammar info (only rebuild if count changed, cheap check)
36            let grammar_count = self.grammar_registry.available_syntaxes().len();
37            if snapshot.available_grammars.len() != grammar_count {
38                snapshot.available_grammars = self
39                    .grammar_registry
40                    .available_grammar_info()
41                    .into_iter()
42                    .map(|g| fresh_core::api::GrammarInfoSnapshot {
43                        name: g.name,
44                        source: g.source.to_string(),
45                        file_extensions: g.file_extensions,
46                        short_name: g.short_name,
47                    })
48                    .collect();
49            }
50
51            // Update active buffer ID
52            snapshot.active_buffer_id = self.active_buffer();
53
54            // Update active split ID
55            snapshot.active_split_id = self.split_manager.active_split().0 .0;
56
57            // Clear and update buffer info
58            snapshot.buffers.clear();
59            snapshot.buffer_saved_diffs.clear();
60            snapshot.buffer_cursor_positions.clear();
61            snapshot.buffer_text_properties.clear();
62
63            for (buffer_id, state) in &self.buffers {
64                let is_virtual = self
65                    .buffer_metadata
66                    .get(buffer_id)
67                    .map(|m| m.is_virtual())
68                    .unwrap_or(false);
69                // Report the ACTIVE split's view_mode so plugins can distinguish
70                // which mode the user is currently in. Separately, report whether
71                // ANY split has compose mode so plugins can maintain decorations
72                // for compose-mode splits even when a source-mode split is active.
73                let active_split = self.split_manager.active_split();
74                let active_vs = self.split_view_states.get(&active_split);
75                let view_mode = active_vs
76                    .and_then(|vs| vs.buffer_state(*buffer_id))
77                    .map(|bs| match bs.view_mode {
78                        crate::state::ViewMode::Source => "source",
79                        crate::state::ViewMode::PageView => "compose",
80                    })
81                    .unwrap_or("source");
82                let compose_width = active_vs
83                    .and_then(|vs| vs.buffer_state(*buffer_id))
84                    .and_then(|bs| bs.compose_width);
85                let is_composing_in_any_split = self.split_view_states.values().any(|vs| {
86                    vs.buffer_state(*buffer_id)
87                        .map(|bs| matches!(bs.view_mode, crate::state::ViewMode::PageView))
88                        .unwrap_or(false)
89                });
90                let is_preview = self
91                    .buffer_metadata
92                    .get(buffer_id)
93                    .map(|m| m.is_preview)
94                    .unwrap_or(false);
95                // Which splits currently hold this buffer — lets plugins
96                // implement "focus existing if visible, else open new"
97                // without tracking split ids across editor restarts
98                // (the restart reassigns them). SplitManager has the
99                // authoritative map; we just mirror it.
100                let splits: Vec<fresh_core::SplitId> = self
101                    .split_manager
102                    .splits_for_buffer(*buffer_id)
103                    .into_iter()
104                    .map(|leaf_id| leaf_id.0)
105                    .collect();
106                let buffer_info = BufferInfo {
107                    id: *buffer_id,
108                    path: state.buffer.file_path().map(|p| p.to_path_buf()),
109                    modified: state.buffer.is_modified(),
110                    length: state.buffer.len(),
111                    is_virtual,
112                    view_mode: view_mode.to_string(),
113                    is_composing_in_any_split,
114                    compose_width,
115                    language: state.language.clone(),
116                    is_preview,
117                    splits,
118                };
119                snapshot.buffers.insert(*buffer_id, buffer_info);
120
121                let diff = {
122                    let diff = state.buffer.diff_since_saved();
123                    BufferSavedDiff {
124                        equal: diff.equal,
125                        byte_ranges: diff.byte_ranges.clone(),
126                    }
127                };
128                snapshot.buffer_saved_diffs.insert(*buffer_id, diff);
129
130                // Regular buffers live in exactly one split's keyed_states.
131                // Panel (hidden) buffers natively live inside a group's inner
132                // split — but the close-buffer path can leave a *shadow*
133                // entry in the group's host split (from `switch_buffer`'s
134                // auto-insert, kept to preserve the
135                // `active_buffer ∈ keyed_states` invariant). For hidden
136                // buffers we therefore skip group-host splits and pick the
137                // inner split, which is the authoritative home.
138                let is_hidden = self
139                    .buffer_metadata
140                    .get(buffer_id)
141                    .is_some_and(|m| m.hidden_from_tabs);
142                let source_split = self.split_view_states.iter().find(|(split_id, vs)| {
143                    vs.keyed_states.contains_key(buffer_id)
144                        && !(is_hidden && self.grouped_subtrees.contains_key(split_id))
145                });
146                let cursor_pos = source_split
147                    .and_then(|(_, vs)| vs.buffer_state(*buffer_id))
148                    .map(|bs| bs.cursors.primary().position)
149                    .unwrap_or(0);
150                tracing::trace!(
151                    "snapshot: buffer {:?} cursor_pos={} (from split {:?})",
152                    buffer_id,
153                    cursor_pos,
154                    source_split.map(|(id, _)| *id),
155                );
156                snapshot
157                    .buffer_cursor_positions
158                    .insert(*buffer_id, cursor_pos);
159
160                // Store text properties if this buffer has any
161                if !state.text_properties.is_empty() {
162                    snapshot
163                        .buffer_text_properties
164                        .insert(*buffer_id, state.text_properties.all().to_vec());
165                }
166            }
167
168            // Update cursor information for active buffer
169            if let Some(active_vs) = self
170                .split_view_states
171                .get(&self.split_manager.active_split())
172            {
173                // Primary cursor (from SplitViewState)
174                let active_cursors = &active_vs.cursors;
175                let primary = active_cursors.primary();
176                let primary_position = primary.position;
177                let primary_selection = primary.selection_range();
178
179                snapshot.primary_cursor = Some(CursorInfo {
180                    position: primary_position,
181                    selection: primary_selection.clone(),
182                });
183
184                // All cursors
185                snapshot.all_cursors = active_cursors
186                    .iter()
187                    .map(|(_, cursor)| CursorInfo {
188                        position: cursor.position,
189                        selection: cursor.selection_range(),
190                    })
191                    .collect();
192
193                // Selected text from primary cursor (for clipboard plugin)
194                if let Some(range) = primary_selection {
195                    if let Some(active_state) = self.buffers.get_mut(&self.active_buffer()) {
196                        snapshot.selected_text =
197                            Some(active_state.get_text_range(range.start, range.end));
198                    }
199                }
200
201                // Viewport - get from SplitViewState (the authoritative source)
202                let top_line = self.buffers.get(&self.active_buffer()).and_then(|state| {
203                    if state.buffer.line_count().is_some() {
204                        Some(state.buffer.get_line_number(active_vs.viewport.top_byte))
205                    } else {
206                        None
207                    }
208                });
209                snapshot.viewport = Some(ViewportInfo {
210                    top_byte: active_vs.viewport.top_byte,
211                    top_line,
212                    left_column: active_vs.viewport.left_column,
213                    width: active_vs.viewport.width,
214                    height: active_vs.viewport.height,
215                });
216            } else {
217                snapshot.primary_cursor = None;
218                snapshot.all_cursors.clear();
219                snapshot.viewport = None;
220                snapshot.selected_text = None;
221            }
222
223            // Update clipboard (provide internal clipboard content to plugins)
224            snapshot.clipboard = self.clipboard.get_internal().to_string();
225
226            // Update working directory (for spawning processes in correct directory)
227            snapshot.working_dir = self.working_dir.clone();
228            snapshot.authority_label = self.authority.display_label.clone();
229
230            // Update LSP diagnostics: Arc refcount bump; no clone.
231            snapshot.diagnostics = Arc::clone(&self.stored_diagnostics);
232
233            // Update LSP folding ranges: Arc refcount bump; no clone.
234            snapshot.folding_ranges = Arc::clone(&self.stored_folding_ranges);
235
236            // Update config. Reserialize only when the underlying
237            // `Arc<Config>` pointer has actually moved since the last
238            // refresh — `Arc::ptr_eq` vs `config_snapshot_anchor` is a
239            // sound cache key because the anchor keeps `self.config`'s
240            // strong count at ≥ 2, forcing every `Arc::make_mut` on the
241            // editor side to CoW into a new allocation. On idle (no
242            // config mutation), this branch is skipped entirely and the
243            // snapshot update is a refcount bump.
244            if !Arc::ptr_eq(&self.config, &self.config_snapshot_anchor) {
245                let json = serde_json::to_value(&*self.config).unwrap_or(serde_json::Value::Null);
246                self.config_cached_json = Arc::new(json);
247                self.config_snapshot_anchor = Arc::clone(&self.config);
248            }
249            snapshot.config = Arc::clone(&self.config_cached_json);
250
251            // Update user config (cached raw file contents, not merged with defaults).
252            // This allows plugins to distinguish between user-set and default values.
253            // Arc refcount bump; no clone.
254            snapshot.user_config = Arc::clone(&self.user_config_raw);
255
256            // Update editor mode (for vi mode and other modal editing)
257            snapshot.editor_mode = self.editor_mode.clone();
258
259            // Update plugin global states from Rust-side store.
260            // Merge using or_insert to preserve JS-side write-through entries.
261            for (plugin_name, state_map) in &self.plugin_global_state {
262                let entry = snapshot
263                    .plugin_global_states
264                    .entry(plugin_name.clone())
265                    .or_default();
266                for (key, value) in state_map {
267                    entry.entry(key.clone()).or_insert_with(|| value.clone());
268                }
269            }
270
271            // Update plugin view states from active split's BufferViewState.plugin_state.
272            // If the active split changed, fully repopulate. Otherwise, merge using
273            // or_insert to preserve JS-side write-through entries that haven't
274            // round-tripped through the command channel yet.
275            let active_split_id = self.split_manager.active_split().0 .0;
276            let split_changed = snapshot.plugin_view_states_split != active_split_id;
277            if split_changed {
278                snapshot.plugin_view_states.clear();
279                snapshot.plugin_view_states_split = active_split_id;
280            }
281
282            // Clean up entries for buffers that are no longer open
283            {
284                let open_bids: Vec<_> = snapshot.buffers.keys().copied().collect();
285                snapshot
286                    .plugin_view_states
287                    .retain(|bid, _| open_bids.contains(bid));
288            }
289
290            // Merge from Rust-side plugin_state (source of truth for persisted state)
291            if let Some(active_vs) = self
292                .split_view_states
293                .get(&self.split_manager.active_split())
294            {
295                for (buffer_id, buf_state) in &active_vs.keyed_states {
296                    if !buf_state.plugin_state.is_empty() {
297                        let entry = snapshot.plugin_view_states.entry(*buffer_id).or_default();
298                        for (key, value) in &buf_state.plugin_state {
299                            // Use or_insert to preserve JS write-through values
300                            entry.entry(key.clone()).or_insert_with(|| value.clone());
301                        }
302                    }
303                }
304            }
305        }
306    }
307
308    /// Handle a plugin command - dispatches to specialized handlers in plugin_commands module
309    pub fn handle_plugin_command(&mut self, command: PluginCommand) -> AnyhowResult<()> {
310        match command {
311            // ==================== Text Editing Commands ====================
312            PluginCommand::InsertText {
313                buffer_id,
314                position,
315                text,
316            } => {
317                self.handle_insert_text(buffer_id, position, text);
318            }
319            PluginCommand::DeleteRange { buffer_id, range } => {
320                self.handle_delete_range(buffer_id, range);
321            }
322            PluginCommand::InsertAtCursor { text } => {
323                self.handle_insert_at_cursor(text);
324            }
325            PluginCommand::DeleteSelection => {
326                self.handle_delete_selection();
327            }
328
329            // ==================== Overlay Commands ====================
330            PluginCommand::AddOverlay {
331                buffer_id,
332                namespace,
333                range,
334                options,
335            } => {
336                self.handle_add_overlay(buffer_id, namespace, range, options);
337            }
338            PluginCommand::RemoveOverlay { buffer_id, handle } => {
339                self.handle_remove_overlay(buffer_id, handle);
340            }
341            PluginCommand::ClearAllOverlays { buffer_id } => {
342                self.handle_clear_all_overlays(buffer_id);
343            }
344            PluginCommand::ClearNamespace {
345                buffer_id,
346                namespace,
347            } => {
348                self.handle_clear_namespace(buffer_id, namespace);
349            }
350            PluginCommand::ClearOverlaysInRange {
351                buffer_id,
352                start,
353                end,
354            } => {
355                self.handle_clear_overlays_in_range(buffer_id, start, end);
356            }
357
358            // ==================== Virtual Text Commands ====================
359            PluginCommand::AddVirtualText {
360                buffer_id,
361                virtual_text_id,
362                position,
363                text,
364                color,
365                use_bg,
366                before,
367            } => {
368                self.handle_add_virtual_text(
369                    buffer_id,
370                    virtual_text_id,
371                    position,
372                    text,
373                    color,
374                    use_bg,
375                    before,
376                );
377            }
378            PluginCommand::RemoveVirtualText {
379                buffer_id,
380                virtual_text_id,
381            } => {
382                self.handle_remove_virtual_text(buffer_id, virtual_text_id);
383            }
384            PluginCommand::RemoveVirtualTextsByPrefix { buffer_id, prefix } => {
385                self.handle_remove_virtual_texts_by_prefix(buffer_id, prefix);
386            }
387            PluginCommand::ClearVirtualTexts { buffer_id } => {
388                self.handle_clear_virtual_texts(buffer_id);
389            }
390            PluginCommand::AddVirtualLine {
391                buffer_id,
392                position,
393                text,
394                fg_color,
395                bg_color,
396                above,
397                namespace,
398                priority,
399            } => {
400                self.handle_add_virtual_line(
401                    buffer_id, position, text, fg_color, bg_color, above, namespace, priority,
402                );
403            }
404            PluginCommand::ClearVirtualTextNamespace {
405                buffer_id,
406                namespace,
407            } => {
408                self.handle_clear_virtual_text_namespace(buffer_id, namespace);
409            }
410
411            // ==================== Conceal Commands ====================
412            PluginCommand::AddConceal {
413                buffer_id,
414                namespace,
415                start,
416                end,
417                replacement,
418            } => {
419                self.handle_add_conceal(buffer_id, namespace, start, end, replacement);
420            }
421            PluginCommand::ClearConcealNamespace {
422                buffer_id,
423                namespace,
424            } => {
425                self.handle_clear_conceal_namespace(buffer_id, namespace);
426            }
427            PluginCommand::ClearConcealsInRange {
428                buffer_id,
429                start,
430                end,
431            } => {
432                self.handle_clear_conceals_in_range(buffer_id, start, end);
433            }
434
435            PluginCommand::AddFold {
436                buffer_id,
437                start,
438                end,
439                placeholder,
440            } => {
441                self.handle_add_fold(buffer_id, start, end, placeholder);
442            }
443            PluginCommand::ClearFolds { buffer_id } => {
444                self.handle_clear_folds(buffer_id);
445            }
446
447            // ==================== Soft Break Commands ====================
448            PluginCommand::AddSoftBreak {
449                buffer_id,
450                namespace,
451                position,
452                indent,
453            } => {
454                self.handle_add_soft_break(buffer_id, namespace, position, indent);
455            }
456            PluginCommand::ClearSoftBreakNamespace {
457                buffer_id,
458                namespace,
459            } => {
460                self.handle_clear_soft_break_namespace(buffer_id, namespace);
461            }
462            PluginCommand::ClearSoftBreaksInRange {
463                buffer_id,
464                start,
465                end,
466            } => {
467                self.handle_clear_soft_breaks_in_range(buffer_id, start, end);
468            }
469
470            // ==================== Menu Commands ====================
471            PluginCommand::AddMenuItem {
472                menu_label,
473                item,
474                position,
475            } => {
476                self.handle_add_menu_item(menu_label, item, position);
477            }
478            PluginCommand::AddMenu { menu, position } => {
479                self.handle_add_menu(menu, position);
480            }
481            PluginCommand::RemoveMenuItem {
482                menu_label,
483                item_label,
484            } => {
485                self.handle_remove_menu_item(menu_label, item_label);
486            }
487            PluginCommand::RemoveMenu { menu_label } => {
488                self.handle_remove_menu(menu_label);
489            }
490
491            // ==================== Split Commands ====================
492            PluginCommand::FocusSplit { split_id } => {
493                self.handle_focus_split(split_id);
494            }
495            PluginCommand::SetSplitBuffer {
496                split_id,
497                buffer_id,
498            } => {
499                self.handle_set_split_buffer(split_id, buffer_id);
500            }
501            PluginCommand::SetSplitScroll { split_id, top_byte } => {
502                self.handle_set_split_scroll(split_id, top_byte);
503            }
504            PluginCommand::RequestHighlights {
505                buffer_id,
506                range,
507                request_id,
508            } => {
509                self.handle_request_highlights(buffer_id, range, request_id);
510            }
511            PluginCommand::CloseSplit { split_id } => {
512                self.handle_close_split(split_id);
513            }
514            PluginCommand::SetSplitRatio { split_id, ratio } => {
515                self.handle_set_split_ratio(split_id, ratio);
516            }
517            PluginCommand::SetSplitLabel { split_id, label } => {
518                self.split_manager.set_label(LeafId(split_id), label);
519            }
520            PluginCommand::ClearSplitLabel { split_id } => {
521                self.split_manager.clear_label(split_id);
522            }
523            PluginCommand::GetSplitByLabel { label, request_id } => {
524                let split_id = self.split_manager.find_split_by_label(&label);
525                let callback_id = fresh_core::api::JsCallbackId::from(request_id);
526                let json = serde_json::to_string(&split_id.map(|s| s.0 .0))
527                    .unwrap_or_else(|_| "null".to_string());
528                self.plugin_manager.resolve_callback(callback_id, json);
529            }
530            PluginCommand::DistributeSplitsEvenly { split_ids: _ } => {
531                self.handle_distribute_splits_evenly();
532            }
533            PluginCommand::SetBufferCursor {
534                buffer_id,
535                position,
536            } => {
537                self.handle_set_buffer_cursor(buffer_id, position);
538            }
539            PluginCommand::SetBufferShowCursors { buffer_id, show } => {
540                if let Some(state) = self.buffers.get_mut(&buffer_id) {
541                    state.show_cursors = show;
542                } else {
543                    tracing::warn!("SetBufferShowCursors: buffer {:?} not found", buffer_id);
544                }
545            }
546
547            // ==================== View/Layout Commands ====================
548            PluginCommand::SetLayoutHints {
549                buffer_id,
550                split_id,
551                range: _,
552                hints,
553            } => {
554                self.handle_set_layout_hints(buffer_id, split_id, hints);
555            }
556            PluginCommand::SetLineNumbers { buffer_id, enabled } => {
557                self.handle_set_line_numbers(buffer_id, enabled);
558            }
559            PluginCommand::SetViewMode { buffer_id, mode } => {
560                self.handle_set_view_mode(buffer_id, &mode);
561            }
562            PluginCommand::SetLineWrap {
563                buffer_id,
564                split_id,
565                enabled,
566            } => {
567                self.handle_set_line_wrap(buffer_id, split_id, enabled);
568            }
569            PluginCommand::SubmitViewTransform {
570                buffer_id,
571                split_id,
572                payload,
573            } => {
574                self.handle_submit_view_transform(buffer_id, split_id, payload);
575            }
576            PluginCommand::ClearViewTransform {
577                buffer_id: _,
578                split_id,
579            } => {
580                self.handle_clear_view_transform(split_id);
581            }
582            PluginCommand::SetViewState {
583                buffer_id,
584                key,
585                value,
586            } => {
587                self.handle_set_view_state(buffer_id, key, value);
588            }
589            PluginCommand::SetGlobalState {
590                plugin_name,
591                key,
592                value,
593            } => {
594                self.handle_set_global_state(plugin_name, key, value);
595            }
596            PluginCommand::RefreshLines { buffer_id } => {
597                self.handle_refresh_lines(buffer_id);
598            }
599            PluginCommand::RefreshAllLines => {
600                self.handle_refresh_all_lines();
601            }
602            PluginCommand::HookCompleted { .. } => {
603                // Sentinel processed in render loop; no-op if encountered elsewhere.
604            }
605            PluginCommand::SetLineIndicator {
606                buffer_id,
607                line,
608                namespace,
609                symbol,
610                color,
611                priority,
612            } => {
613                self.handle_set_line_indicator(buffer_id, line, namespace, symbol, color, priority);
614            }
615            PluginCommand::SetLineIndicators {
616                buffer_id,
617                lines,
618                namespace,
619                symbol,
620                color,
621                priority,
622            } => {
623                self.handle_set_line_indicators(
624                    buffer_id, lines, namespace, symbol, color, priority,
625                );
626            }
627            PluginCommand::ClearLineIndicators {
628                buffer_id,
629                namespace,
630            } => {
631                self.handle_clear_line_indicators(buffer_id, namespace);
632            }
633            PluginCommand::SetFileExplorerDecorations {
634                namespace,
635                decorations,
636            } => {
637                self.handle_set_file_explorer_decorations(namespace, decorations);
638            }
639            PluginCommand::ClearFileExplorerDecorations { namespace } => {
640                self.handle_clear_file_explorer_decorations(&namespace);
641            }
642
643            // ==================== Status/Prompt Commands ====================
644            PluginCommand::SetStatus { message } => {
645                self.handle_set_status(message);
646            }
647            PluginCommand::ApplyTheme { theme_name } => {
648                self.apply_theme(&theme_name);
649            }
650            PluginCommand::OverrideThemeColors { overrides } => {
651                let pairs = overrides
652                    .into_iter()
653                    .map(|(k, [r, g, b])| (k, ratatui::style::Color::Rgb(r, g, b)));
654                let applied = self.theme.override_colors(pairs);
655                if applied > 0 {
656                    // Diagnostics / semantic overlays bake RGB at creation
657                    // time — rebuild them so the override is visible
658                    // everywhere on the next frame.
659                    self.reapply_all_overlays();
660                }
661            }
662            PluginCommand::ReloadConfig => {
663                self.reload_config();
664            }
665            PluginCommand::SetSetting { path, value, .. } => {
666                self.handle_set_setting(path, value);
667            }
668            PluginCommand::ReloadThemes { apply_theme } => {
669                self.reload_themes();
670                if let Some(theme_name) = apply_theme {
671                    self.apply_theme(&theme_name);
672                }
673            }
674            PluginCommand::RegisterGrammar {
675                language,
676                grammar_path,
677                extensions,
678            } => {
679                self.handle_register_grammar(language, grammar_path, extensions);
680            }
681            PluginCommand::RegisterLanguageConfig { language, config } => {
682                self.handle_register_language_config(language, config);
683            }
684            PluginCommand::RegisterLspServer { language, config } => {
685                self.handle_register_lsp_server(language, config);
686            }
687            PluginCommand::ReloadGrammars { callback_id } => {
688                self.handle_reload_grammars(callback_id);
689            }
690            PluginCommand::StartPrompt { label, prompt_type } => {
691                self.handle_start_prompt(label, prompt_type);
692            }
693            PluginCommand::StartPromptWithInitial {
694                label,
695                prompt_type,
696                initial_value,
697            } => {
698                self.handle_start_prompt_with_initial(label, prompt_type, initial_value);
699            }
700            PluginCommand::StartPromptAsync {
701                label,
702                initial_value,
703                callback_id,
704            } => {
705                self.handle_start_prompt_async(label, initial_value, callback_id);
706            }
707            PluginCommand::SetPromptSuggestions { suggestions } => {
708                self.handle_set_prompt_suggestions(suggestions);
709            }
710            PluginCommand::SetPromptInputSync { sync } => {
711                if let Some(prompt) = &mut self.prompt {
712                    prompt.sync_input_on_navigate = sync;
713                }
714            }
715
716            // ==================== Command/Mode Registration ====================
717            PluginCommand::RegisterCommand { command } => {
718                self.handle_register_command(command);
719            }
720            PluginCommand::UnregisterCommand { name } => {
721                self.handle_unregister_command(name);
722            }
723            PluginCommand::DefineMode {
724                name,
725                bindings,
726                read_only,
727                allow_text_input,
728                inherit_normal_bindings,
729                plugin_name,
730            } => {
731                self.handle_define_mode(
732                    name,
733                    bindings,
734                    read_only,
735                    allow_text_input,
736                    inherit_normal_bindings,
737                    plugin_name,
738                );
739            }
740
741            // ==================== File/Navigation Commands ====================
742            PluginCommand::OpenFileInBackground { path } => {
743                self.handle_open_file_in_background(path);
744            }
745            PluginCommand::OpenFileAtLocation { path, line, column } => {
746                return self.handle_open_file_at_location(path, line, column);
747            }
748            PluginCommand::OpenFileInSplit {
749                split_id,
750                path,
751                line,
752                column,
753            } => {
754                return self.handle_open_file_in_split(split_id, path, line, column);
755            }
756            PluginCommand::ShowBuffer { buffer_id } => {
757                self.handle_show_buffer(buffer_id);
758            }
759            PluginCommand::CloseBuffer { buffer_id } => {
760                self.handle_close_buffer(buffer_id);
761            }
762
763            // ==================== LSP Commands ====================
764            PluginCommand::SendLspRequest {
765                language,
766                method,
767                params,
768                request_id,
769            } => {
770                self.handle_send_lsp_request(language, method, params, request_id);
771            }
772
773            // ==================== Clipboard Commands ====================
774            PluginCommand::SetClipboard { text } => {
775                self.handle_set_clipboard(text);
776            }
777
778            // ==================== Async Plugin Commands ====================
779            PluginCommand::SpawnProcess {
780                command,
781                args,
782                cwd,
783                callback_id,
784            } => {
785                // Spawn process asynchronously using the process spawner
786                // (supports both local and remote execution)
787                if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
788                    let effective_cwd = cwd.or_else(|| {
789                        std::env::current_dir()
790                            .map(|p| p.to_string_lossy().to_string())
791                            .ok()
792                    });
793                    let sender = bridge.sender();
794                    let spawner = self.authority.process_spawner.clone();
795
796                    runtime.spawn(async move {
797                        // Receiver may be dropped if editor is shutting down
798                        #[allow(clippy::let_underscore_must_use)]
799                        match spawner.spawn(command, args, effective_cwd).await {
800                            Ok(result) => {
801                                let _ = sender.send(AsyncMessage::PluginProcessOutput {
802                                    process_id: callback_id.as_u64(),
803                                    stdout: result.stdout,
804                                    stderr: result.stderr,
805                                    exit_code: result.exit_code,
806                                });
807                            }
808                            Err(e) => {
809                                let _ = sender.send(AsyncMessage::PluginProcessOutput {
810                                    process_id: callback_id.as_u64(),
811                                    stdout: String::new(),
812                                    stderr: e.to_string(),
813                                    exit_code: -1,
814                                });
815                            }
816                        }
817                    });
818                } else {
819                    // No async runtime - reject the callback
820                    self.plugin_manager
821                        .reject_callback(callback_id, "Async runtime not available".to_string());
822                }
823            }
824
825            PluginCommand::SpawnHostProcess {
826                command,
827                args,
828                cwd,
829                callback_id,
830            } => {
831                // Bypass the active authority on purpose: this is
832                // reserved for plugin internals that must run host-side
833                // work (e.g. `devcontainer up`) before the authority
834                // they want is even built. Uses the same callback shape
835                // as `SpawnProcess` so the plugin-facing API is
836                // symmetric.
837                //
838                // Kill handle: we store a oneshot sender in
839                // `host_process_handles` keyed by the callback id. A
840                // `KillHostProcess` dispatch sends on it; the spawn
841                // task's `tokio::select!` then start_kill()s the
842                // child. This lets a plugin cancel a long-running
843                // spawn (e.g. "Cancel Startup" on the Remote
844                // Indicator popup during `devcontainer up`).
845                if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
846                    use tokio::io::{AsyncReadExt, BufReader};
847                    use tokio::process::Command as TokioCommand;
848
849                    let effective_cwd = cwd.or_else(|| {
850                        std::env::current_dir()
851                            .map(|p| p.to_string_lossy().to_string())
852                            .ok()
853                    });
854                    let sender = bridge.sender();
855                    let process_id = callback_id.as_u64();
856
857                    let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
858                    self.host_process_handles.insert(process_id, kill_tx);
859
860                    runtime.spawn(async move {
861                        let mut cmd = TokioCommand::new(&command);
862                        cmd.args(&args);
863                        cmd.stdout(std::process::Stdio::piped());
864                        cmd.stderr(std::process::Stdio::piped());
865                        if let Some(ref dir) = effective_cwd {
866                            cmd.current_dir(dir);
867                        }
868                        let mut child = match cmd.spawn() {
869                            Ok(c) => c,
870                            Err(e) => {
871                                #[allow(clippy::let_underscore_must_use)]
872                                let _ = sender.send(AsyncMessage::PluginProcessOutput {
873                                    process_id,
874                                    stdout: String::new(),
875                                    stderr: e.to_string(),
876                                    exit_code: -1,
877                                });
878                                return;
879                            }
880                        };
881
882                        // Take the pipes out of the Child so the
883                        // reader tasks own them; then `child.wait()`
884                        // has exclusive mutable access for the
885                        // kill-or-exit select. Matches the
886                        // fresh-plugin-runtime process.rs pattern.
887                        let stdout_pipe = child.stdout.take();
888                        let stderr_pipe = child.stderr.take();
889
890                        let stdout_fut = async {
891                            let mut buf = String::new();
892                            if let Some(s) = stdout_pipe {
893                                #[allow(clippy::let_underscore_must_use)]
894                                let _ = BufReader::new(s).read_to_string(&mut buf).await;
895                            }
896                            buf
897                        };
898                        let stderr_fut = async {
899                            let mut buf = String::new();
900                            if let Some(s) = stderr_pipe {
901                                #[allow(clippy::let_underscore_must_use)]
902                                let _ = BufReader::new(s).read_to_string(&mut buf).await;
903                            }
904                            buf
905                        };
906                        let wait_fut = async {
907                            tokio::select! {
908                                status = child.wait() => {
909                                    status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
910                                }
911                                _ = &mut kill_rx => {
912                                    // Best-effort SIGKILL + reap.
913                                    // Children of the killed
914                                    // process may leak (Q-C2).
915                                    #[allow(clippy::let_underscore_must_use)]
916                                    let _ = child.start_kill();
917                                    child
918                                        .wait()
919                                        .await
920                                        .map(|s| s.code().unwrap_or(-1))
921                                        .unwrap_or(-1)
922                                }
923                            }
924                        };
925                        let (stdout, stderr, exit_code) =
926                            tokio::join!(stdout_fut, stderr_fut, wait_fut);
927
928                        #[allow(clippy::let_underscore_must_use)]
929                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
930                            process_id,
931                            stdout,
932                            stderr,
933                            exit_code,
934                        });
935                    });
936                } else {
937                    self.plugin_manager
938                        .reject_callback(callback_id, "Async runtime not available".to_string());
939                }
940            }
941
942            PluginCommand::KillHostProcess { process_id } => {
943                // Removing from the map gives us the oneshot sender.
944                // Firing it signals the spawn task to `start_kill()`
945                // the child and reap. Unknown ids are intentionally
946                // silent — the process may have exited on its own
947                // and its cleanup already drained the slot.
948                if let Some(tx) = self.host_process_handles.remove(&process_id) {
949                    #[allow(clippy::let_underscore_must_use)]
950                    let _ = tx.send(());
951                    tracing::debug!("KillHostProcess: sent kill for process_id={}", process_id);
952                } else {
953                    tracing::debug!(
954                        "KillHostProcess: unknown process_id={} (already exited?)",
955                        process_id
956                    );
957                }
958            }
959
960            PluginCommand::SetAuthority { payload } => {
961                // Payload is an opaque `serde_json::Value` at the
962                // fresh-core layer; the concrete schema lives in
963                // `services::authority::AuthorityPayload` and is only
964                // deserialized here, so core stays ignorant of backend
965                // kinds.
966                match serde_json::from_value::<crate::services::authority::AuthorityPayload>(
967                    payload,
968                ) {
969                    Ok(parsed) => {
970                        match crate::services::authority::Authority::from_plugin_payload(parsed) {
971                            Ok(auth) => {
972                                tracing::info!("Plugin installed new authority");
973                                self.install_authority(auth);
974                            }
975                            Err(e) => {
976                                tracing::warn!("setAuthority: invalid payload: {}", e);
977                                self.set_status_message(format!("setAuthority rejected: {}", e));
978                            }
979                        }
980                    }
981                    Err(e) => {
982                        tracing::warn!("setAuthority: failed to parse payload: {}", e);
983                        self.set_status_message(format!("setAuthority rejected: {}", e));
984                    }
985                }
986            }
987
988            PluginCommand::ClearAuthority => {
989                tracing::info!("Plugin cleared authority; restoring local");
990                self.clear_authority();
991            }
992
993            PluginCommand::SetRemoteIndicatorState { state } => {
994                // Opaque JSON at the fresh-core boundary; the concrete
995                // schema (RemoteIndicatorOverride) lives in the view
996                // crate so core stays ignorant of per-state labels.
997                match serde_json::from_value::<crate::view::ui::status_bar::RemoteIndicatorOverride>(
998                    state,
999                ) {
1000                    Ok(over) => {
1001                        self.remote_indicator_override = Some(over);
1002                    }
1003                    Err(e) => {
1004                        tracing::warn!("setRemoteIndicatorState: invalid payload: {}", e);
1005                        self.set_status_message(format!("setRemoteIndicatorState rejected: {}", e));
1006                    }
1007                }
1008            }
1009
1010            PluginCommand::ClearRemoteIndicatorState => {
1011                self.remote_indicator_override = None;
1012            }
1013
1014            PluginCommand::SpawnProcessWait {
1015                process_id,
1016                callback_id,
1017            } => {
1018                // TODO: Implement proper process wait tracking
1019                // For now, just reject with an error since there's no process tracking yet
1020                tracing::warn!(
1021                    "SpawnProcessWait not fully implemented - process_id={}",
1022                    process_id
1023                );
1024                self.plugin_manager.reject_callback(
1025                    callback_id,
1026                    format!(
1027                        "SpawnProcessWait not yet fully implemented for process_id={}",
1028                        process_id
1029                    ),
1030                );
1031            }
1032
1033            PluginCommand::Delay {
1034                callback_id,
1035                duration_ms,
1036            } => {
1037                // Spawn async delay via tokio
1038                if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
1039                    let sender = bridge.sender();
1040                    let callback_id_u64 = callback_id.as_u64();
1041                    runtime.spawn(async move {
1042                        tokio::time::sleep(tokio::time::Duration::from_millis(duration_ms)).await;
1043                        // Receiver may have been dropped during shutdown.
1044                        #[allow(clippy::let_underscore_must_use)]
1045                        let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1046                            fresh_core::api::PluginAsyncMessage::DelayComplete {
1047                                callback_id: callback_id_u64,
1048                            },
1049                        ));
1050                    });
1051                } else {
1052                    // Fallback to blocking if no runtime available
1053                    std::thread::sleep(std::time::Duration::from_millis(duration_ms));
1054                    self.plugin_manager
1055                        .resolve_callback(callback_id, "null".to_string());
1056                }
1057            }
1058
1059            PluginCommand::SpawnBackgroundProcess {
1060                process_id,
1061                command,
1062                args,
1063                cwd,
1064                callback_id,
1065            } => {
1066                // Spawn background process with streaming output via tokio
1067                if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
1068                    use tokio::io::{AsyncBufReadExt, BufReader};
1069                    use tokio::process::Command as TokioCommand;
1070
1071                    let effective_cwd = cwd.unwrap_or_else(|| {
1072                        std::env::current_dir()
1073                            .map(|p| p.to_string_lossy().to_string())
1074                            .unwrap_or_else(|_| ".".to_string())
1075                    });
1076
1077                    let sender = bridge.sender();
1078                    let sender_stdout = sender.clone();
1079                    let sender_stderr = sender.clone();
1080                    let callback_id_u64 = callback_id.as_u64();
1081
1082                    // Receiver may be dropped if editor is shutting down
1083                    #[allow(clippy::let_underscore_must_use)]
1084                    let handle = runtime.spawn(async move {
1085                        let mut child = match TokioCommand::new(&command)
1086                            .args(&args)
1087                            .current_dir(&effective_cwd)
1088                            .stdout(std::process::Stdio::piped())
1089                            .stderr(std::process::Stdio::piped())
1090                            .spawn()
1091                        {
1092                            Ok(child) => child,
1093                            Err(e) => {
1094                                let _ = sender.send(
1095                                    crate::services::async_bridge::AsyncMessage::Plugin(
1096                                        fresh_core::api::PluginAsyncMessage::ProcessExit {
1097                                            process_id,
1098                                            callback_id: callback_id_u64,
1099                                            exit_code: -1,
1100                                        },
1101                                    ),
1102                                );
1103                                tracing::error!("Failed to spawn background process: {}", e);
1104                                return;
1105                            }
1106                        };
1107
1108                        // Stream stdout
1109                        let stdout = child.stdout.take();
1110                        let stderr = child.stderr.take();
1111                        let pid = process_id;
1112
1113                        // Spawn stdout reader
1114                        if let Some(stdout) = stdout {
1115                            let sender = sender_stdout;
1116                            tokio::spawn(async move {
1117                                let reader = BufReader::new(stdout);
1118                                let mut lines = reader.lines();
1119                                while let Ok(Some(line)) = lines.next_line().await {
1120                                    let _ = sender.send(
1121                                        crate::services::async_bridge::AsyncMessage::Plugin(
1122                                            fresh_core::api::PluginAsyncMessage::ProcessStdout {
1123                                                process_id: pid,
1124                                                data: line + "\n",
1125                                            },
1126                                        ),
1127                                    );
1128                                }
1129                            });
1130                        }
1131
1132                        // Spawn stderr reader
1133                        if let Some(stderr) = stderr {
1134                            let sender = sender_stderr;
1135                            tokio::spawn(async move {
1136                                let reader = BufReader::new(stderr);
1137                                let mut lines = reader.lines();
1138                                while let Ok(Some(line)) = lines.next_line().await {
1139                                    let _ = sender.send(
1140                                        crate::services::async_bridge::AsyncMessage::Plugin(
1141                                            fresh_core::api::PluginAsyncMessage::ProcessStderr {
1142                                                process_id: pid,
1143                                                data: line + "\n",
1144                                            },
1145                                        ),
1146                                    );
1147                                }
1148                            });
1149                        }
1150
1151                        // Wait for process to complete
1152                        let exit_code = match child.wait().await {
1153                            Ok(status) => status.code().unwrap_or(-1),
1154                            Err(_) => -1,
1155                        };
1156
1157                        let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1158                            fresh_core::api::PluginAsyncMessage::ProcessExit {
1159                                process_id,
1160                                callback_id: callback_id_u64,
1161                                exit_code,
1162                            },
1163                        ));
1164                    });
1165
1166                    // Store abort handle for potential kill
1167                    self.background_process_handles
1168                        .insert(process_id, handle.abort_handle());
1169                } else {
1170                    // No runtime - reject immediately
1171                    self.plugin_manager
1172                        .reject_callback(callback_id, "Async runtime not available".to_string());
1173                }
1174            }
1175
1176            PluginCommand::KillBackgroundProcess { process_id } => {
1177                if let Some(handle) = self.background_process_handles.remove(&process_id) {
1178                    handle.abort();
1179                    tracing::debug!("Killed background process {}", process_id);
1180                }
1181            }
1182
1183            // ==================== Virtual Buffer Commands (complex, kept inline) ====================
1184            PluginCommand::CreateVirtualBuffer {
1185                name,
1186                mode,
1187                read_only,
1188            } => {
1189                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
1190                tracing::info!(
1191                    "Created virtual buffer '{}' with mode '{}' (id={:?})",
1192                    name,
1193                    mode,
1194                    buffer_id
1195                );
1196                // TODO: Return buffer_id to plugin via callback or hook
1197            }
1198            PluginCommand::CreateVirtualBufferWithContent {
1199                name,
1200                mode,
1201                read_only,
1202                entries,
1203                show_line_numbers,
1204                show_cursors,
1205                editing_disabled,
1206                hidden_from_tabs,
1207                request_id,
1208            } => {
1209                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
1210                tracing::info!(
1211                    "Created virtual buffer '{}' with mode '{}' (id={:?})",
1212                    name,
1213                    mode,
1214                    buffer_id
1215                );
1216
1217                // Apply view options to the buffer
1218                // TODO: show_line_numbers is duplicated between EditorState.margins and
1219                // BufferViewState. The renderer reads BufferViewState and overwrites
1220                // margins each frame via configure_for_line_numbers(), making the margin
1221                // setting here effectively write-only. Consider removing the margin call
1222                // and only setting BufferViewState.show_line_numbers.
1223                if let Some(state) = self.buffers.get_mut(&buffer_id) {
1224                    state.margins.configure_for_line_numbers(show_line_numbers);
1225                    state.show_cursors = show_cursors;
1226                    state.editing_disabled = editing_disabled;
1227                    tracing::debug!(
1228                        "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
1229                        buffer_id,
1230                        show_line_numbers,
1231                        show_cursors,
1232                        editing_disabled
1233                    );
1234                }
1235                let active_split = self.split_manager.active_split();
1236                if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1237                    view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
1238                }
1239
1240                // Apply hidden_from_tabs to buffer metadata
1241                if hidden_from_tabs {
1242                    if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
1243                        meta.hidden_from_tabs = true;
1244                    }
1245                }
1246
1247                // Now set the content
1248                match self.set_virtual_buffer_content(buffer_id, entries) {
1249                    Ok(()) => {
1250                        tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
1251                        // Switch to the new buffer to display it
1252                        self.set_active_buffer(buffer_id);
1253                        tracing::debug!("Switched to virtual buffer {:?}", buffer_id);
1254
1255                        // Send response if request_id is present
1256                        if let Some(req_id) = request_id {
1257                            tracing::info!(
1258                                "CreateVirtualBufferWithContent: resolving callback for request_id={}, buffer_id={:?}",
1259                                req_id,
1260                                buffer_id
1261                            );
1262                            // createVirtualBuffer returns VirtualBufferResult: { bufferId, splitId }
1263                            let result = fresh_core::api::VirtualBufferResult {
1264                                buffer_id: buffer_id.0 as u64,
1265                                split_id: None,
1266                            };
1267                            self.plugin_manager.resolve_callback(
1268                                fresh_core::api::JsCallbackId::from(req_id),
1269                                serde_json::to_string(&result).unwrap_or_default(),
1270                            );
1271                            tracing::info!("CreateVirtualBufferWithContent: resolve_callback sent for request_id={}", req_id);
1272                        }
1273                    }
1274                    Err(e) => {
1275                        tracing::error!("Failed to set virtual buffer content: {}", e);
1276                    }
1277                }
1278            }
1279            PluginCommand::CreateVirtualBufferInSplit {
1280                name,
1281                mode,
1282                read_only,
1283                entries,
1284                ratio,
1285                direction,
1286                panel_id,
1287                show_line_numbers,
1288                show_cursors,
1289                editing_disabled,
1290                line_wrap,
1291                before,
1292                request_id,
1293            } => {
1294                // Check if this panel already exists (for idempotent operations)
1295                if let Some(pid) = &panel_id {
1296                    if let Some(&existing_buffer_id) = self.panel_ids.get(pid) {
1297                        // Verify the buffer actually exists (defensive check for stale entries)
1298                        if self.buffers.contains_key(&existing_buffer_id) {
1299                            // Panel exists, just update its content
1300                            if let Err(e) =
1301                                self.set_virtual_buffer_content(existing_buffer_id, entries)
1302                            {
1303                                tracing::error!("Failed to update panel content: {}", e);
1304                            } else {
1305                                tracing::info!("Updated existing panel '{}' content", pid);
1306                            }
1307
1308                            // Find and focus the split that contains this buffer
1309                            let splits = self.split_manager.splits_for_buffer(existing_buffer_id);
1310                            if let Some(&split_id) = splits.first() {
1311                                self.split_manager.set_active_split(split_id);
1312                                // Route through set_pane_buffer so tree + SVS
1313                                // stay consistent (issue #1620 invariant).
1314                                self.set_pane_buffer(split_id, existing_buffer_id);
1315                                tracing::debug!(
1316                                    "Focused split {:?} containing panel buffer",
1317                                    split_id
1318                                );
1319                            }
1320
1321                            // Send response with existing buffer ID and split ID via callback resolution
1322                            if let Some(req_id) = request_id {
1323                                let result = fresh_core::api::VirtualBufferResult {
1324                                    buffer_id: existing_buffer_id.0 as u64,
1325                                    split_id: splits.first().map(|s| s.0 .0 as u64),
1326                                };
1327                                self.plugin_manager.resolve_callback(
1328                                    fresh_core::api::JsCallbackId::from(req_id),
1329                                    serde_json::to_string(&result).unwrap_or_default(),
1330                                );
1331                            }
1332                            return Ok(());
1333                        } else {
1334                            // Buffer no longer exists, remove stale panel_id entry
1335                            tracing::warn!(
1336                                "Removing stale panel_id '{}' pointing to non-existent buffer {:?}",
1337                                pid,
1338                                existing_buffer_id
1339                            );
1340                            self.panel_ids.remove(pid);
1341                            // Fall through to create a new buffer
1342                        }
1343                    }
1344                }
1345
1346                // Capture the source split before creating the buffer —
1347                // `create_virtual_buffer` unconditionally adds the new buffer
1348                // as a tab to the currently active split, which is the wrong
1349                // thing for a panel that lives in its own dedicated split
1350                // (it would show up as a tab in BOTH splits — see bug #3).
1351                let source_split_before_create = self.split_manager.active_split();
1352
1353                // Create the virtual buffer first
1354                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
1355                tracing::info!(
1356                    "Created virtual buffer '{}' with mode '{}' in split (id={:?})",
1357                    name,
1358                    mode,
1359                    buffer_id
1360                );
1361
1362                // Apply view options to the buffer
1363                if let Some(state) = self.buffers.get_mut(&buffer_id) {
1364                    state.margins.configure_for_line_numbers(show_line_numbers);
1365                    state.show_cursors = show_cursors;
1366                    state.editing_disabled = editing_disabled;
1367                    tracing::debug!(
1368                        "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
1369                        buffer_id,
1370                        show_line_numbers,
1371                        show_cursors,
1372                        editing_disabled
1373                    );
1374                }
1375
1376                // Store the panel ID mapping if provided
1377                if let Some(pid) = panel_id {
1378                    self.panel_ids.insert(pid, buffer_id);
1379                }
1380
1381                // Set the content
1382                if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
1383                    tracing::error!("Failed to set virtual buffer content: {}", e);
1384                    return Ok(());
1385                }
1386
1387                // Determine split direction
1388                let split_dir = match direction.as_deref() {
1389                    Some("vertical") => crate::model::event::SplitDirection::Vertical,
1390                    _ => crate::model::event::SplitDirection::Horizontal,
1391                };
1392
1393                // Create a split with the new buffer
1394                let created_split_id = match self
1395                    .split_manager
1396                    .split_active_positioned(split_dir, buffer_id, ratio, before)
1397                {
1398                    Ok(new_split_id) => {
1399                        // The buffer now lives in its own split, so drop its
1400                        // tab from the source split (see bug #3).  Only do
1401                        // this when the new split actually differs from the
1402                        // source split — otherwise we'd leave no split
1403                        // displaying the buffer.
1404                        if new_split_id != source_split_before_create {
1405                            if let Some(source_view_state) =
1406                                self.split_view_states.get_mut(&source_split_before_create)
1407                            {
1408                                source_view_state.remove_buffer(buffer_id);
1409                            }
1410                        }
1411                        // Create independent view state for the new split with the buffer in tabs
1412                        let mut view_state = SplitViewState::with_buffer(
1413                            self.terminal_width,
1414                            self.terminal_height,
1415                            buffer_id,
1416                        );
1417                        view_state.apply_config_defaults(
1418                            self.config.editor.line_numbers,
1419                            self.config.editor.highlight_current_line,
1420                            line_wrap
1421                                .unwrap_or_else(|| self.resolve_line_wrap_for_buffer(buffer_id)),
1422                            self.config.editor.wrap_indent,
1423                            self.resolve_wrap_column_for_buffer(buffer_id),
1424                            self.config.editor.rulers.clone(),
1425                        );
1426                        // Override with plugin-requested show_line_numbers
1427                        view_state.ensure_buffer_state(buffer_id).show_line_numbers =
1428                            show_line_numbers;
1429                        self.split_view_states.insert(new_split_id, view_state);
1430
1431                        // Focus the new split (the diagnostics panel)
1432                        self.split_manager.set_active_split(new_split_id);
1433                        // NOTE: split tree was updated by split_active, active_buffer derives from it
1434
1435                        tracing::info!(
1436                            "Created {:?} split with virtual buffer {:?}",
1437                            split_dir,
1438                            buffer_id
1439                        );
1440                        Some(new_split_id)
1441                    }
1442                    Err(e) => {
1443                        tracing::error!("Failed to create split: {}", e);
1444                        // Fall back to just switching to the buffer
1445                        self.set_active_buffer(buffer_id);
1446                        None
1447                    }
1448                };
1449
1450                // Send response with buffer ID and split ID via callback resolution
1451                // NOTE: Using VirtualBufferResult type for type-safe JSON serialization
1452                if let Some(req_id) = request_id {
1453                    tracing::trace!("CreateVirtualBufferInSplit: resolving callback for request_id={}, buffer_id={:?}, split_id={:?}", req_id, buffer_id, created_split_id);
1454                    let result = fresh_core::api::VirtualBufferResult {
1455                        buffer_id: buffer_id.0 as u64,
1456                        split_id: created_split_id.map(|s| s.0 .0 as u64),
1457                    };
1458                    self.plugin_manager.resolve_callback(
1459                        fresh_core::api::JsCallbackId::from(req_id),
1460                        serde_json::to_string(&result).unwrap_or_default(),
1461                    );
1462                }
1463            }
1464            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
1465                match self.set_virtual_buffer_content(buffer_id, entries) {
1466                    Ok(()) => {
1467                        tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
1468                    }
1469                    Err(e) => {
1470                        tracing::error!("Failed to set virtual buffer content: {}", e);
1471                    }
1472                }
1473            }
1474            PluginCommand::GetTextPropertiesAtCursor { buffer_id } => {
1475                // Get text properties at cursor and fire a hook with the data
1476                if let Some(state) = self.buffers.get(&buffer_id) {
1477                    let cursor_pos = self
1478                        .split_view_states
1479                        .values()
1480                        .find_map(|vs| vs.buffer_state(buffer_id))
1481                        .map(|bs| bs.cursors.primary().position)
1482                        .unwrap_or(0);
1483                    let properties = state.text_properties.get_at(cursor_pos);
1484                    tracing::debug!(
1485                        "Text properties at cursor in {:?}: {} properties found",
1486                        buffer_id,
1487                        properties.len()
1488                    );
1489                    // TODO: Fire hook with properties data for plugins to consume
1490                }
1491            }
1492            PluginCommand::CreateVirtualBufferInExistingSplit {
1493                name,
1494                mode,
1495                read_only,
1496                entries,
1497                split_id,
1498                show_line_numbers,
1499                show_cursors,
1500                editing_disabled,
1501                line_wrap,
1502                request_id,
1503            } => {
1504                // Create the virtual buffer
1505                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
1506                tracing::info!(
1507                    "Created virtual buffer '{}' with mode '{}' for existing split {:?} (id={:?})",
1508                    name,
1509                    mode,
1510                    split_id,
1511                    buffer_id
1512                );
1513
1514                // Apply view options to the buffer
1515                if let Some(state) = self.buffers.get_mut(&buffer_id) {
1516                    state.margins.configure_for_line_numbers(show_line_numbers);
1517                    state.show_cursors = show_cursors;
1518                    state.editing_disabled = editing_disabled;
1519                }
1520
1521                // Set the content
1522                if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
1523                    tracing::error!("Failed to set virtual buffer content: {}", e);
1524                    return Ok(());
1525                }
1526
1527                // Show the buffer in the target split. set_pane_buffer
1528                // covers the tree + SVS updates the old code did by hand.
1529                let leaf_id = LeafId(split_id);
1530                self.split_manager.set_active_split(leaf_id);
1531                self.set_pane_buffer(leaf_id, buffer_id);
1532
1533                // Fall-through to the cursor/open_buffers housekeeping
1534                // that used to follow the manual switch_buffer. We keep
1535                // the `if let Some(view_state)` block below — set_pane_buffer
1536                // already called switch_buffer, but the downstream code
1537                // also nudges open_buffers and focus_history.
1538                if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
1539                    view_state.switch_buffer(buffer_id);
1540                    view_state.add_buffer(buffer_id);
1541                    view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
1542
1543                    // Apply line_wrap setting if provided
1544                    if let Some(wrap) = line_wrap {
1545                        view_state.active_state_mut().viewport.line_wrap_enabled = wrap;
1546                    }
1547                }
1548
1549                tracing::info!(
1550                    "Displayed virtual buffer {:?} in split {:?}",
1551                    buffer_id,
1552                    split_id
1553                );
1554
1555                // Send response with buffer ID and split ID via callback resolution
1556                if let Some(req_id) = request_id {
1557                    let result = fresh_core::api::VirtualBufferResult {
1558                        buffer_id: buffer_id.0 as u64,
1559                        split_id: Some(split_id.0 as u64),
1560                    };
1561                    self.plugin_manager.resolve_callback(
1562                        fresh_core::api::JsCallbackId::from(req_id),
1563                        serde_json::to_string(&result).unwrap_or_default(),
1564                    );
1565                }
1566            }
1567
1568            // ==================== Context Commands ====================
1569            PluginCommand::SetContext { name, active } => {
1570                if active {
1571                    self.active_custom_contexts.insert(name.clone());
1572                    tracing::debug!("Set custom context: {}", name);
1573                } else {
1574                    self.active_custom_contexts.remove(&name);
1575                    tracing::debug!("Unset custom context: {}", name);
1576                }
1577            }
1578
1579            // ==================== Review Diff Commands ====================
1580            PluginCommand::SetReviewDiffHunks { hunks } => {
1581                self.review_hunks = hunks;
1582                tracing::debug!("Set {} review hunks", self.review_hunks.len());
1583            }
1584
1585            // ==================== Vi Mode Commands ====================
1586            PluginCommand::ExecuteAction { action_name } => {
1587                self.handle_execute_action(action_name);
1588            }
1589            PluginCommand::ExecuteActions { actions } => {
1590                self.handle_execute_actions(actions);
1591            }
1592            PluginCommand::GetBufferText {
1593                buffer_id,
1594                start,
1595                end,
1596                request_id,
1597            } => {
1598                self.handle_get_buffer_text(buffer_id, start, end, request_id);
1599            }
1600            PluginCommand::GetLineStartPosition {
1601                buffer_id,
1602                line,
1603                request_id,
1604            } => {
1605                self.handle_get_line_start_position(buffer_id, line, request_id);
1606            }
1607            PluginCommand::GetLineEndPosition {
1608                buffer_id,
1609                line,
1610                request_id,
1611            } => {
1612                self.handle_get_line_end_position(buffer_id, line, request_id);
1613            }
1614            PluginCommand::GetBufferLineCount {
1615                buffer_id,
1616                request_id,
1617            } => {
1618                self.handle_get_buffer_line_count(buffer_id, request_id);
1619            }
1620            PluginCommand::ScrollToLineCenter {
1621                split_id,
1622                buffer_id,
1623                line,
1624            } => {
1625                self.handle_scroll_to_line_center(split_id, buffer_id, line);
1626            }
1627            PluginCommand::ScrollBufferToLine { buffer_id, line } => {
1628                self.handle_scroll_buffer_to_line(buffer_id, line);
1629            }
1630            PluginCommand::SetEditorMode { mode } => {
1631                self.handle_set_editor_mode(mode);
1632            }
1633
1634            // ==================== LSP Helper Commands ====================
1635            PluginCommand::ShowActionPopup {
1636                popup_id,
1637                title,
1638                message,
1639                actions,
1640            } => {
1641                tracing::info!(
1642                    "Action popup requested: id={}, title={}, actions={}",
1643                    popup_id,
1644                    title,
1645                    actions.len()
1646                );
1647
1648                // Build popup list items from actions
1649                let items: Vec<crate::model::event::PopupListItemData> = actions
1650                    .iter()
1651                    .map(|action| crate::model::event::PopupListItemData {
1652                        text: action.label.clone(),
1653                        detail: None,
1654                        icon: None,
1655                        data: Some(action.id.clone()),
1656                    })
1657                    .collect();
1658
1659                // The popup_id lives on the popup itself via its
1660                // `PopupResolver::PluginAction` — no side-channel stack.
1661                // Drop the incoming `actions` vec; its ids are already
1662                // encoded as each list item's `data` field below.
1663                drop(actions);
1664
1665                // Create popup with message + action list
1666                let popup_data = crate::model::event::PopupData {
1667                    kind: crate::model::event::PopupKindHint::List,
1668                    title: Some(title),
1669                    description: Some(message),
1670                    transient: false,
1671                    content: crate::model::event::PopupContentData::List { items, selected: 0 },
1672                    position: crate::model::event::PopupPositionData::BottomRight,
1673                    width: 60,
1674                    max_height: 15,
1675                    bordered: true,
1676                };
1677
1678                // Action popups are buffer-independent notifications; route
1679                // them to the editor-level popup stack so they remain visible
1680                // (and dismissible) regardless of which buffer is focused —
1681                // including virtual buffers like the Dashboard that own the
1682                // whole split.
1683                //
1684                // The resolver carries the popup_id so confirm/cancel fires
1685                // `action_popup_result` for exactly THIS popup, even when
1686                // multiple plugin popups are stacked concurrently.
1687                let mut popup_obj = crate::state::convert_popup_data_to_popup(&popup_data);
1688                popup_obj.resolver = crate::view::popup::PopupResolver::PluginAction {
1689                    popup_id: popup_id.clone(),
1690                };
1691                self.global_popups.show(popup_obj);
1692                tracing::info!(
1693                    "Action popup shown: id={}, stack_depth={}",
1694                    popup_id,
1695                    self.global_popups.all().len()
1696                );
1697            }
1698
1699            PluginCommand::DisableLspForLanguage { language } => {
1700                tracing::info!("Disabling LSP for language: {}", language);
1701
1702                // 1. Stop the LSP server for this language if running
1703                if let Some(ref mut lsp) = self.lsp {
1704                    lsp.shutdown_server(&language);
1705                    tracing::info!("Stopped LSP server for {}", language);
1706                }
1707
1708                // 2. Update the config to disable the language
1709                if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&language) {
1710                    for c in lsp_configs.as_mut_slice() {
1711                        c.enabled = false;
1712                        c.auto_start = false;
1713                    }
1714                    tracing::info!("Disabled LSP config for {}", language);
1715                }
1716
1717                // 3. Persist the config change
1718                if let Err(e) = self.save_config() {
1719                    tracing::error!("Failed to save config: {}", e);
1720                    self.status_message = Some(format!(
1721                        "LSP disabled for {} (config save failed)",
1722                        language
1723                    ));
1724                } else {
1725                    self.status_message = Some(format!("LSP disabled for {}", language));
1726                }
1727
1728                // 4. Clear any LSP-related warnings for this language
1729                self.warning_domains.lsp.clear();
1730            }
1731
1732            PluginCommand::RestartLspForLanguage { language } => {
1733                tracing::info!("Plugin restarting LSP for language: {}", language);
1734
1735                let file_path = self
1736                    .buffer_metadata
1737                    .get(&self.active_buffer())
1738                    .and_then(|meta| meta.file_path().cloned());
1739                let success = if let Some(ref mut lsp) = self.lsp {
1740                    let (ok, msg) = lsp.manual_restart(&language, file_path.as_deref());
1741                    self.status_message = Some(msg);
1742                    ok
1743                } else {
1744                    self.status_message = Some("No LSP manager available".to_string());
1745                    false
1746                };
1747
1748                if success {
1749                    self.reopen_buffers_for_language(&language);
1750                }
1751            }
1752
1753            PluginCommand::SetLspRootUri { language, uri } => {
1754                tracing::info!("Plugin setting LSP root URI for {}: {}", language, uri);
1755
1756                // Parse the URI string into an lsp_types::Uri
1757                match uri.parse::<lsp_types::Uri>() {
1758                    Ok(parsed_uri) => {
1759                        if let Some(ref mut lsp) = self.lsp {
1760                            let restarted = lsp.set_language_root_uri(&language, parsed_uri);
1761                            if restarted {
1762                                self.status_message = Some(format!(
1763                                    "LSP root updated for {} (restarting server)",
1764                                    language
1765                                ));
1766                            } else {
1767                                self.status_message =
1768                                    Some(format!("LSP root set for {}", language));
1769                            }
1770                        }
1771                    }
1772                    Err(e) => {
1773                        tracing::error!("Invalid LSP root URI '{}': {}", uri, e);
1774                        self.status_message = Some(format!("Invalid LSP root URI: {}", e));
1775                    }
1776                }
1777            }
1778
1779            // ==================== Scroll Sync Commands ====================
1780            PluginCommand::CreateScrollSyncGroup {
1781                group_id,
1782                left_split,
1783                right_split,
1784            } => {
1785                let success = self.scroll_sync_manager.create_group_with_id(
1786                    group_id,
1787                    left_split,
1788                    right_split,
1789                );
1790                if success {
1791                    tracing::debug!(
1792                        "Created scroll sync group {} for splits {:?} and {:?}",
1793                        group_id,
1794                        left_split,
1795                        right_split
1796                    );
1797                } else {
1798                    tracing::warn!(
1799                        "Failed to create scroll sync group {} (ID already exists)",
1800                        group_id
1801                    );
1802                }
1803            }
1804            PluginCommand::SetScrollSyncAnchors { group_id, anchors } => {
1805                use crate::view::scroll_sync::SyncAnchor;
1806                let anchor_count = anchors.len();
1807                let sync_anchors: Vec<SyncAnchor> = anchors
1808                    .into_iter()
1809                    .map(|(left_line, right_line)| SyncAnchor {
1810                        left_line,
1811                        right_line,
1812                    })
1813                    .collect();
1814                self.scroll_sync_manager.set_anchors(group_id, sync_anchors);
1815                tracing::debug!(
1816                    "Set {} anchors for scroll sync group {}",
1817                    anchor_count,
1818                    group_id
1819                );
1820            }
1821            PluginCommand::RemoveScrollSyncGroup { group_id } => {
1822                if self.scroll_sync_manager.remove_group(group_id) {
1823                    tracing::debug!("Removed scroll sync group {}", group_id);
1824                } else {
1825                    tracing::warn!("Scroll sync group {} not found", group_id);
1826                }
1827            }
1828
1829            // ==================== Composite Buffer Commands ====================
1830            PluginCommand::CreateCompositeBuffer {
1831                name,
1832                mode,
1833                layout,
1834                sources,
1835                hunks,
1836                initial_focus_hunk,
1837                request_id,
1838            } => {
1839                self.handle_create_composite_buffer(
1840                    name,
1841                    mode,
1842                    layout,
1843                    sources,
1844                    hunks,
1845                    initial_focus_hunk,
1846                    request_id,
1847                );
1848            }
1849            PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => {
1850                self.handle_update_composite_alignment(buffer_id, hunks);
1851            }
1852            PluginCommand::CloseCompositeBuffer { buffer_id } => {
1853                self.close_composite_buffer(buffer_id);
1854            }
1855            PluginCommand::FlushLayout => {
1856                self.flush_layout();
1857            }
1858            PluginCommand::CompositeNextHunk { buffer_id } => {
1859                let split_id = self.split_manager.active_split();
1860                self.composite_next_hunk(split_id, buffer_id);
1861            }
1862            PluginCommand::CompositePrevHunk { buffer_id } => {
1863                let split_id = self.split_manager.active_split();
1864                self.composite_prev_hunk(split_id, buffer_id);
1865            }
1866
1867            // ==================== Buffer Groups ====================
1868            PluginCommand::CreateBufferGroup {
1869                name,
1870                mode,
1871                layout_json,
1872                request_id,
1873            } => match self.create_buffer_group(name, mode, layout_json) {
1874                Ok(result) => {
1875                    if let Some(req_id) = request_id {
1876                        let json = serde_json::to_string(&result).unwrap_or_default();
1877                        self.plugin_manager
1878                            .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), json);
1879                    }
1880                }
1881                Err(e) => {
1882                    tracing::error!("Failed to create buffer group: {}", e);
1883                }
1884            },
1885            PluginCommand::SetPanelContent {
1886                group_id,
1887                panel_name,
1888                entries,
1889            } => {
1890                self.set_panel_content(group_id, panel_name, entries);
1891            }
1892            PluginCommand::CloseBufferGroup { group_id } => {
1893                self.close_buffer_group(group_id);
1894            }
1895            PluginCommand::FocusPanel {
1896                group_id,
1897                panel_name,
1898            } => {
1899                self.focus_panel(group_id, panel_name);
1900            }
1901
1902            // ==================== File Operations ====================
1903            PluginCommand::SaveBufferToPath { buffer_id, path } => {
1904                self.handle_save_buffer_to_path(buffer_id, path);
1905            }
1906
1907            // ==================== Plugin Management ====================
1908            #[cfg(feature = "plugins")]
1909            PluginCommand::LoadPlugin { path, callback_id } => {
1910                self.handle_load_plugin(path, callback_id);
1911            }
1912            #[cfg(feature = "plugins")]
1913            PluginCommand::UnloadPlugin { name, callback_id } => {
1914                self.handle_unload_plugin(name, callback_id);
1915            }
1916            #[cfg(feature = "plugins")]
1917            PluginCommand::ReloadPlugin { name, callback_id } => {
1918                self.handle_reload_plugin(name, callback_id);
1919            }
1920            #[cfg(feature = "plugins")]
1921            PluginCommand::ListPlugins { callback_id } => {
1922                self.handle_list_plugins(callback_id);
1923            }
1924            // When plugins feature is disabled, these commands are no-ops
1925            #[cfg(not(feature = "plugins"))]
1926            PluginCommand::LoadPlugin { .. }
1927            | PluginCommand::UnloadPlugin { .. }
1928            | PluginCommand::ReloadPlugin { .. }
1929            | PluginCommand::ListPlugins { .. } => {
1930                tracing::warn!("Plugin management commands require the 'plugins' feature");
1931            }
1932
1933            // ==================== Terminal Commands ====================
1934            PluginCommand::CreateTerminal {
1935                cwd,
1936                direction,
1937                ratio,
1938                focus,
1939                persistent,
1940                request_id,
1941            } => {
1942                let (cols, rows) = self.get_terminal_dimensions();
1943
1944                // Set up async bridge for terminal manager if not already done
1945                if let Some(ref bridge) = self.async_bridge {
1946                    self.terminal_manager.set_async_bridge(bridge.clone());
1947                }
1948
1949                // Determine working directory
1950                let working_dir = cwd
1951                    .map(std::path::PathBuf::from)
1952                    .unwrap_or_else(|| self.working_dir.clone());
1953
1954                // Prepare persistent storage paths
1955                let terminal_root = self.dir_context.terminal_dir_for(&working_dir);
1956                if let Err(e) = self.authority.filesystem.create_dir_all(&terminal_root) {
1957                    tracing::warn!("Failed to create terminal directory: {}", e);
1958                }
1959                let predicted_terminal_id = self.terminal_manager.next_terminal_id();
1960                // Ephemeral terminals get a per-spawn suffix on their backing
1961                // files so there is no possibility of picking up the scrollback
1962                // that a previous run (with the same numeric terminal ID) wrote
1963                // to `fresh-terminal-N.{txt,log}`. Persistent terminals keep
1964                // the stable `fresh-terminal-N.*` name so workspace restore
1965                // can still find them.
1966                let name_stem = if persistent {
1967                    format!("fresh-terminal-{}", predicted_terminal_id.0)
1968                } else {
1969                    let nanos = std::time::SystemTime::now()
1970                        .duration_since(std::time::UNIX_EPOCH)
1971                        .map(|d| d.as_nanos())
1972                        .unwrap_or(0);
1973                    format!("fresh-terminal-eph-{}-{}", predicted_terminal_id.0, nanos)
1974                };
1975                let log_path = terminal_root.join(format!("{}.log", name_stem));
1976                let backing_path = terminal_root.join(format!("{}.txt", name_stem));
1977                self.terminal_backing_files
1978                    .insert(predicted_terminal_id, backing_path);
1979                let backing_path_for_spawn = self
1980                    .terminal_backing_files
1981                    .get(&predicted_terminal_id)
1982                    .cloned();
1983
1984                match self.terminal_manager.spawn(
1985                    cols,
1986                    rows,
1987                    Some(working_dir),
1988                    Some(log_path.clone()),
1989                    backing_path_for_spawn,
1990                    self.resolved_terminal_wrapper(),
1991                ) {
1992                    Ok(terminal_id) => {
1993                        // Track log file path
1994                        self.terminal_log_files
1995                            .insert(terminal_id, log_path.clone());
1996                        // Fix up backing path if the predicted ID didn't match
1997                        // the one the terminal manager handed out. Persistent
1998                        // terminals re-derive the stable `fresh-terminal-N.txt`
1999                        // name so the workspace restore path can find them;
2000                        // ephemeral terminals just keep the already-spawned
2001                        // file (it has a nanos-unique name either way) and
2002                        // rebind the HashMap key to the real ID.
2003                        if terminal_id != predicted_terminal_id {
2004                            let existing =
2005                                self.terminal_backing_files.remove(&predicted_terminal_id);
2006                            let fixed_backing = if persistent {
2007                                terminal_root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
2008                            } else {
2009                                existing.unwrap_or_else(|| {
2010                                    terminal_root.join(format!("{}.txt", name_stem))
2011                                })
2012                            };
2013                            self.terminal_backing_files
2014                                .insert(terminal_id, fixed_backing);
2015                        }
2016                        if !persistent {
2017                            self.ephemeral_terminals.insert(terminal_id);
2018                        }
2019
2020                        // Pick buffer-attachment strategy based on whether the
2021                        // plugin asked for its own split:
2022                        //
2023                        // - direction = Some: use `_detached` so the buffer
2024                        //   isn't also added as a tab to the user's active
2025                        //   split. The new split below owns it exclusively,
2026                        //   so when the user closes that split the terminal
2027                        //   disappears entirely instead of leaving a ghost
2028                        //   tab behind in the main split.
2029                        // - direction = None: use `_attached` — the plugin
2030                        //   is intentionally placing the terminal as a new
2031                        //   tab in the active split, which is the whole
2032                        //   point of the no-split branch.
2033                        let active_split = self.split_manager.active_split();
2034                        let buffer_id = if direction.is_some() {
2035                            self.create_terminal_buffer_detached(terminal_id)
2036                        } else {
2037                            self.create_terminal_buffer_attached(terminal_id, active_split)
2038                        };
2039
2040                        let created_split_id = if let Some(dir_str) = direction.as_deref() {
2041                            let split_dir = match dir_str {
2042                                "horizontal" => crate::model::event::SplitDirection::Horizontal,
2043                                _ => crate::model::event::SplitDirection::Vertical,
2044                            };
2045
2046                            let split_ratio = ratio.unwrap_or(0.5);
2047                            match self
2048                                .split_manager
2049                                .split_active(split_dir, buffer_id, split_ratio)
2050                            {
2051                                Ok(new_split_id) => {
2052                                    let mut view_state = SplitViewState::with_buffer(
2053                                        self.terminal_width,
2054                                        self.terminal_height,
2055                                        buffer_id,
2056                                    );
2057                                    view_state.apply_config_defaults(
2058                                        self.config.editor.line_numbers,
2059                                        self.config.editor.highlight_current_line,
2060                                        false,
2061                                        false,
2062                                        None,
2063                                        self.config.editor.rulers.clone(),
2064                                    );
2065                                    // Terminal output is ANSI-sequenced and
2066                                    // assumes a fixed column count; wrapping
2067                                    // would mangle cursor positioning.
2068                                    view_state.viewport.line_wrap_enabled = false;
2069                                    self.split_view_states.insert(new_split_id, view_state);
2070
2071                                    if focus.unwrap_or(true) {
2072                                        self.split_manager.set_active_split(new_split_id);
2073                                    }
2074
2075                                    tracing::info!(
2076                                        "Created {:?} split for terminal {:?} with buffer {:?}",
2077                                        split_dir,
2078                                        terminal_id,
2079                                        buffer_id
2080                                    );
2081                                    Some(new_split_id)
2082                                }
2083                                Err(e) => {
2084                                    tracing::error!(
2085                                        "Failed to create split for terminal: {}; \
2086                                         falling back to active split",
2087                                        e
2088                                    );
2089                                    // The buffer was created detached. Split
2090                                    // creation failed, so attach it to the
2091                                    // active split as a graceful fallback
2092                                    // rather than leaving an orphan buffer.
2093                                    if let Some(view_state) =
2094                                        self.split_view_states.get_mut(&active_split)
2095                                    {
2096                                        view_state.add_buffer(buffer_id);
2097                                        view_state.viewport.line_wrap_enabled = false;
2098                                    }
2099                                    self.set_active_buffer(buffer_id);
2100                                    None
2101                                }
2102                            }
2103                        } else {
2104                            // No split — just switch to the terminal buffer in the active split
2105                            self.set_active_buffer(buffer_id);
2106                            None
2107                        };
2108
2109                        // Resize terminal to match actual split content area
2110                        self.resize_visible_terminals();
2111
2112                        // Resolve the callback with TerminalResult
2113                        let result = fresh_core::api::TerminalResult {
2114                            buffer_id: buffer_id.0 as u64,
2115                            terminal_id: terminal_id.0 as u64,
2116                            split_id: created_split_id.map(|s| s.0 .0 as u64),
2117                        };
2118                        self.plugin_manager.resolve_callback(
2119                            fresh_core::api::JsCallbackId::from(request_id),
2120                            serde_json::to_string(&result).unwrap_or_default(),
2121                        );
2122
2123                        tracing::info!(
2124                            "Plugin created terminal {:?} with buffer {:?}",
2125                            terminal_id,
2126                            buffer_id
2127                        );
2128                    }
2129                    Err(e) => {
2130                        tracing::error!("Failed to create terminal for plugin: {}", e);
2131                        self.plugin_manager.reject_callback(
2132                            fresh_core::api::JsCallbackId::from(request_id),
2133                            format!("Failed to create terminal: {}", e),
2134                        );
2135                    }
2136                }
2137            }
2138
2139            PluginCommand::SendTerminalInput { terminal_id, data } => {
2140                if let Some(handle) = self.terminal_manager.get(terminal_id) {
2141                    handle.write(data.as_bytes());
2142                    tracing::trace!(
2143                        "Plugin sent {} bytes to terminal {:?}",
2144                        data.len(),
2145                        terminal_id
2146                    );
2147                } else {
2148                    tracing::warn!(
2149                        "Plugin tried to send input to non-existent terminal {:?}",
2150                        terminal_id
2151                    );
2152                }
2153            }
2154
2155            PluginCommand::CloseTerminal { terminal_id } => {
2156                // Find and close the buffer associated with this terminal
2157                let buffer_to_close = self
2158                    .terminal_buffers
2159                    .iter()
2160                    .find(|(_, &tid)| tid == terminal_id)
2161                    .map(|(&bid, _)| bid);
2162
2163                if let Some(buffer_id) = buffer_to_close {
2164                    if let Err(e) = self.close_buffer(buffer_id) {
2165                        tracing::warn!("Failed to close terminal buffer: {}", e);
2166                    }
2167                    tracing::info!("Plugin closed terminal {:?}", terminal_id);
2168                } else {
2169                    // Terminal exists but no buffer — just close the terminal directly
2170                    self.terminal_manager.close(terminal_id);
2171                    tracing::info!("Plugin closed terminal {:?} (no buffer found)", terminal_id);
2172                }
2173            }
2174
2175            PluginCommand::GrepProject {
2176                pattern,
2177                fixed_string,
2178                case_sensitive,
2179                max_results,
2180                whole_words,
2181                callback_id,
2182            } => {
2183                self.handle_grep_project(
2184                    pattern,
2185                    fixed_string,
2186                    case_sensitive,
2187                    max_results,
2188                    whole_words,
2189                    callback_id,
2190                );
2191            }
2192
2193            PluginCommand::GrepProjectStreaming {
2194                pattern,
2195                fixed_string,
2196                case_sensitive,
2197                max_results,
2198                whole_words,
2199                search_id,
2200                callback_id,
2201            } => {
2202                self.handle_grep_project_streaming(
2203                    pattern,
2204                    fixed_string,
2205                    case_sensitive,
2206                    max_results,
2207                    whole_words,
2208                    search_id,
2209                    callback_id,
2210                );
2211            }
2212
2213            PluginCommand::ReplaceInBuffer {
2214                file_path,
2215                matches,
2216                replacement,
2217                callback_id,
2218            } => {
2219                self.handle_replace_in_buffer(file_path, matches, replacement, callback_id);
2220            }
2221        }
2222        Ok(())
2223    }
2224
2225    /// Save a buffer to a specific file path (for :w filename)
2226    fn handle_save_buffer_to_path(&mut self, buffer_id: BufferId, path: std::path::PathBuf) {
2227        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2228            // Save to the specified path
2229            match state.buffer.save_to_file(&path) {
2230                Ok(()) => {
2231                    // save_to_file already updates file_path internally via finalize_save
2232                    // Run on-save actions (formatting, etc.)
2233                    if let Err(e) = self.finalize_save(Some(path)) {
2234                        tracing::warn!("Failed to finalize save: {}", e);
2235                    }
2236                    tracing::debug!("Saved buffer {:?} to path", buffer_id);
2237                }
2238                Err(e) => {
2239                    self.handle_set_status(format!("Error saving: {}", e));
2240                    tracing::error!("Failed to save buffer to path: {}", e);
2241                }
2242            }
2243        } else {
2244            self.handle_set_status(format!("Buffer {:?} not found", buffer_id));
2245            tracing::warn!("SaveBufferToPath: buffer {:?} not found", buffer_id);
2246        }
2247    }
2248
2249    /// Load a plugin from a file path
2250    #[cfg(feature = "plugins")]
2251    fn handle_load_plugin(&mut self, path: std::path::PathBuf, callback_id: JsCallbackId) {
2252        match self.plugin_manager.load_plugin(&path) {
2253            Ok(()) => {
2254                tracing::info!("Loaded plugin from {:?}", path);
2255                self.plugin_manager
2256                    .resolve_callback(callback_id, "true".to_string());
2257            }
2258            Err(e) => {
2259                tracing::error!("Failed to load plugin from {:?}: {}", path, e);
2260                self.plugin_manager
2261                    .reject_callback(callback_id, format!("{}", e));
2262            }
2263        }
2264    }
2265
2266    /// Unload a plugin by name
2267    #[cfg(feature = "plugins")]
2268    fn handle_unload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
2269        match self.plugin_manager.unload_plugin(&name) {
2270            Ok(()) => {
2271                tracing::info!("Unloaded plugin: {}", name);
2272                self.plugin_manager
2273                    .resolve_callback(callback_id, "true".to_string());
2274            }
2275            Err(e) => {
2276                tracing::error!("Failed to unload plugin '{}': {}", name, e);
2277                self.plugin_manager
2278                    .reject_callback(callback_id, format!("{}", e));
2279            }
2280        }
2281    }
2282
2283    /// Reload a plugin by name
2284    #[cfg(feature = "plugins")]
2285    fn handle_reload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
2286        match self.plugin_manager.reload_plugin(&name) {
2287            Ok(()) => {
2288                tracing::info!("Reloaded plugin: {}", name);
2289                self.plugin_manager
2290                    .resolve_callback(callback_id, "true".to_string());
2291            }
2292            Err(e) => {
2293                tracing::error!("Failed to reload plugin '{}': {}", name, e);
2294                self.plugin_manager
2295                    .reject_callback(callback_id, format!("{}", e));
2296            }
2297        }
2298    }
2299
2300    /// List all loaded plugins
2301    #[cfg(feature = "plugins")]
2302    fn handle_list_plugins(&mut self, callback_id: JsCallbackId) {
2303        let plugins = self.plugin_manager.list_plugins();
2304        // Serialize to JSON array of { name, path, enabled }
2305        let json_array: Vec<serde_json::Value> = plugins
2306            .iter()
2307            .map(|p| {
2308                serde_json::json!({
2309                    "name": p.name,
2310                    "path": p.path.to_string_lossy(),
2311                    "enabled": p.enabled
2312                })
2313            })
2314            .collect();
2315        let json_str = serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string());
2316        self.plugin_manager.resolve_callback(callback_id, json_str);
2317    }
2318
2319    /// Execute an editor action by name (for vi mode plugin)
2320    fn handle_execute_action(&mut self, action_name: String) {
2321        use crate::input::keybindings::Action;
2322        use std::collections::HashMap;
2323
2324        // Parse the action name into an Action enum
2325        if let Some(action) = Action::from_str(&action_name, &HashMap::new()) {
2326            // Execute the action
2327            if let Err(e) = self.handle_action(action) {
2328                tracing::warn!("Failed to execute action '{}': {}", action_name, e);
2329            } else {
2330                tracing::debug!("Executed action: {}", action_name);
2331            }
2332        } else {
2333            tracing::warn!("Unknown action: {}", action_name);
2334        }
2335    }
2336
2337    /// Execute multiple actions in sequence, each with an optional repeat count
2338    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
2339    fn handle_execute_actions(&mut self, actions: Vec<fresh_core::api::ActionSpec>) {
2340        use crate::input::keybindings::Action;
2341        use std::collections::HashMap;
2342
2343        for action_spec in actions {
2344            if let Some(action) = Action::from_str(&action_spec.action, &HashMap::new()) {
2345                // Execute the action `count` times
2346                for _ in 0..action_spec.count {
2347                    if let Err(e) = self.handle_action(action.clone()) {
2348                        tracing::warn!("Failed to execute action '{}': {}", action_spec.action, e);
2349                        return; // Stop on first error
2350                    }
2351                }
2352                tracing::debug!(
2353                    "Executed action '{}' {} time(s)",
2354                    action_spec.action,
2355                    action_spec.count
2356                );
2357            } else {
2358                tracing::warn!("Unknown action: {}", action_spec.action);
2359                return; // Stop on unknown action
2360            }
2361        }
2362    }
2363
2364    /// Get text from a buffer range (for vi mode yank operations)
2365    fn handle_get_buffer_text(
2366        &mut self,
2367        buffer_id: BufferId,
2368        start: usize,
2369        end: usize,
2370        request_id: u64,
2371    ) {
2372        let result = if let Some(state) = self.buffers.get_mut(&buffer_id) {
2373            // Get text from the buffer using the mutable get_text_range method
2374            let len = state.buffer.len();
2375            if start <= end && end <= len {
2376                Ok(state.get_text_range(start, end))
2377            } else {
2378                Err(format!(
2379                    "Invalid range {}..{} for buffer of length {}",
2380                    start, end, len
2381                ))
2382            }
2383        } else {
2384            Err(format!("Buffer {:?} not found", buffer_id))
2385        };
2386
2387        // Resolve the JavaScript Promise callback directly
2388        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2389        match result {
2390            Ok(text) => {
2391                // Serialize text as JSON string
2392                let json = serde_json::to_string(&text).unwrap_or_else(|_| "null".to_string());
2393                self.plugin_manager.resolve_callback(callback_id, json);
2394            }
2395            Err(error) => {
2396                self.plugin_manager.reject_callback(callback_id, error);
2397            }
2398        }
2399    }
2400
2401    /// Set the global editor mode (for vi mode)
2402    fn handle_set_editor_mode(&mut self, mode: Option<String>) {
2403        self.editor_mode = mode.clone();
2404        tracing::debug!("Set editor mode: {:?}", mode);
2405    }
2406
2407    /// Get the byte offset of the start of a line in the active buffer
2408    fn handle_get_line_start_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
2409        // Use active buffer if buffer_id is 0
2410        let actual_buffer_id = if buffer_id.0 == 0 {
2411            self.active_buffer_id()
2412        } else {
2413            buffer_id
2414        };
2415
2416        let result = if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
2417            // Get line start position by iterating through the buffer content
2418            let line_number = line as usize;
2419            let buffer_len = state.buffer.len();
2420
2421            if line_number == 0 {
2422                // First line always starts at 0
2423                Some(0)
2424            } else {
2425                // Count newlines to find the start of the requested line
2426                let mut current_line = 0;
2427                let mut line_start = None;
2428
2429                // Read buffer content to find newlines using the BufferState's get_text_range
2430                let content = state.get_text_range(0, buffer_len);
2431                for (byte_idx, c) in content.char_indices() {
2432                    if c == '\n' {
2433                        current_line += 1;
2434                        if current_line == line_number {
2435                            // Found the start of the requested line (byte after newline)
2436                            line_start = Some(byte_idx + 1);
2437                            break;
2438                        }
2439                    }
2440                }
2441                line_start
2442            }
2443        } else {
2444            None
2445        };
2446
2447        // Resolve the JavaScript Promise callback directly
2448        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2449        // Serialize as JSON (null for None, number for Some)
2450        let json = serde_json::to_string(&result).unwrap_or_else(|_| "null".to_string());
2451        self.plugin_manager.resolve_callback(callback_id, json);
2452    }
2453
2454    /// Get the byte offset of the end of a line in the active buffer
2455    /// Returns the position after the last character of the line (before newline)
2456    fn handle_get_line_end_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
2457        // Use active buffer if buffer_id is 0
2458        let actual_buffer_id = if buffer_id.0 == 0 {
2459            self.active_buffer_id()
2460        } else {
2461            buffer_id
2462        };
2463
2464        let result = if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
2465            let line_number = line as usize;
2466            let buffer_len = state.buffer.len();
2467
2468            // Read buffer content to find line boundaries
2469            let content = state.get_text_range(0, buffer_len);
2470            let mut current_line = 0;
2471            let mut line_end = None;
2472
2473            for (byte_idx, c) in content.char_indices() {
2474                if c == '\n' {
2475                    if current_line == line_number {
2476                        // Found the end of the requested line (position of newline)
2477                        line_end = Some(byte_idx);
2478                        break;
2479                    }
2480                    current_line += 1;
2481                }
2482            }
2483
2484            // Handle last line (no trailing newline)
2485            if line_end.is_none() && current_line == line_number {
2486                line_end = Some(buffer_len);
2487            }
2488
2489            line_end
2490        } else {
2491            None
2492        };
2493
2494        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2495        let json = serde_json::to_string(&result).unwrap_or_else(|_| "null".to_string());
2496        self.plugin_manager.resolve_callback(callback_id, json);
2497    }
2498
2499    /// Get the total number of lines in a buffer
2500    fn handle_get_buffer_line_count(&mut self, buffer_id: BufferId, request_id: u64) {
2501        // Use active buffer if buffer_id is 0
2502        let actual_buffer_id = if buffer_id.0 == 0 {
2503            self.active_buffer_id()
2504        } else {
2505            buffer_id
2506        };
2507
2508        let result = if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
2509            let buffer_len = state.buffer.len();
2510            let content = state.get_text_range(0, buffer_len);
2511
2512            // Count lines (number of newlines + 1, unless empty)
2513            if content.is_empty() {
2514                Some(1) // Empty buffer has 1 line
2515            } else {
2516                let newline_count = content.chars().filter(|&c| c == '\n').count();
2517                // If file ends with newline, don't count extra line
2518                let ends_with_newline = content.ends_with('\n');
2519                if ends_with_newline {
2520                    Some(newline_count)
2521                } else {
2522                    Some(newline_count + 1)
2523                }
2524            }
2525        } else {
2526            None
2527        };
2528
2529        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2530        let json = serde_json::to_string(&result).unwrap_or_else(|_| "null".to_string());
2531        self.plugin_manager.resolve_callback(callback_id, json);
2532    }
2533
2534    /// Scroll a split to center a specific line in the viewport
2535    fn handle_scroll_to_line_center(
2536        &mut self,
2537        split_id: SplitId,
2538        buffer_id: BufferId,
2539        line: usize,
2540    ) {
2541        // Use active split if split_id is 0
2542        let actual_split_id = if split_id.0 == 0 {
2543            self.split_manager.active_split()
2544        } else {
2545            LeafId(split_id)
2546        };
2547
2548        // Use active buffer if buffer_id is 0
2549        let actual_buffer_id = if buffer_id.0 == 0 {
2550            self.active_buffer()
2551        } else {
2552            buffer_id
2553        };
2554
2555        // Get viewport height
2556        let viewport_height = if let Some(view_state) = self.split_view_states.get(&actual_split_id)
2557        {
2558            view_state.viewport.height as usize
2559        } else {
2560            return;
2561        };
2562
2563        // Calculate the target line to scroll to (center the requested line)
2564        let lines_above = viewport_height / 2;
2565        let target_line = line.saturating_sub(lines_above);
2566
2567        // Get the buffer and scroll
2568        if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
2569            let buffer = &mut state.buffer;
2570            if let Some(view_state) = self.split_view_states.get_mut(&actual_split_id) {
2571                view_state.viewport.scroll_to(buffer, target_line);
2572                // Mark to skip ensure_visible on next render so the scroll isn't undone
2573                view_state.viewport.set_skip_ensure_visible();
2574            }
2575        }
2576    }
2577
2578    /// Scroll every split whose active buffer is `buffer_id` so that
2579    /// `line` is within the viewport. Used by plugin panels (buffer
2580    /// groups) whose plugin-side "selected row" doesn't drive the
2581    /// buffer cursor — after updating the selection, the plugin calls
2582    /// this to bring the selected row into view.
2583    ///
2584    /// Walks both the main split tree's leaves AND the inner leaves of
2585    /// all Grouped subtrees stored in `grouped_subtrees`, because the
2586    /// latter are not represented in `split_manager`'s tree.
2587    fn handle_scroll_buffer_to_line(&mut self, buffer_id: BufferId, line: usize) {
2588        if !self.buffers.contains_key(&buffer_id) {
2589            return;
2590        }
2591
2592        // Collect the leaf ids whose active buffer is `buffer_id`.
2593        let mut target_leaves: Vec<LeafId> = Vec::new();
2594
2595        // Main tree: walk its leaves.
2596        for leaf_id in self.split_manager.root().leaf_split_ids() {
2597            if let Some(vs) = self.split_view_states.get(&leaf_id) {
2598                if vs.active_buffer == buffer_id {
2599                    target_leaves.push(leaf_id);
2600                }
2601            }
2602        }
2603
2604        // Grouped subtrees: walk each group's inner leaves.
2605        for (_group_leaf_id, node) in self.grouped_subtrees.iter() {
2606            if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
2607                for inner_leaf in layout.leaf_split_ids() {
2608                    if let Some(vs) = self.split_view_states.get(&inner_leaf) {
2609                        if vs.active_buffer == buffer_id && !target_leaves.contains(&inner_leaf) {
2610                            target_leaves.push(inner_leaf);
2611                        }
2612                    }
2613                }
2614            }
2615        }
2616
2617        if target_leaves.is_empty() {
2618            return;
2619        }
2620
2621        let state = match self.buffers.get_mut(&buffer_id) {
2622            Some(s) => s,
2623            None => return,
2624        };
2625
2626        for leaf_id in target_leaves {
2627            let Some(view_state) = self.split_view_states.get_mut(&leaf_id) else {
2628                continue;
2629            };
2630            let viewport_height = view_state.viewport.height as usize;
2631            // Place `line` roughly a third of the viewport from the top so
2632            // the next few navigation steps don't immediately scroll again.
2633            let lines_above = viewport_height / 3;
2634            let target = line.saturating_sub(lines_above);
2635            view_state.viewport.scroll_to(&mut state.buffer, target);
2636            view_state.viewport.set_skip_ensure_visible();
2637        }
2638    }
2639}
2640
2641#[cfg(test)]
2642mod tests {
2643    //! Focused tests for the SpawnHostProcess kill mechanism.
2644    //!
2645    //! These don't exercise the full `handle_plugin_command` dispatcher
2646    //! (which would require scaffolding an Editor with a real tokio
2647    //! runtime and async_bridge); they replicate the inner
2648    //! `tokio::select!` pattern directly on a real subprocess. A
2649    //! regression in the select arms or in the kill-then-wait
2650    //! sequencing would reproduce here.
2651    //!
2652    //! The dispatcher-level integration coverage comes from the e2e
2653    //! attach-cancel test in `tests/e2e/` — this unit test is the
2654    //! lower-level pin.
2655    use tokio::io::{AsyncReadExt, BufReader};
2656    use tokio::process::Command as TokioCommand;
2657    use tokio::time::{timeout, Duration};
2658
2659    /// A long-sleep child that runs `tokio::select! { wait | kill_rx }`
2660    /// terminates when the kill channel fires, and the terminal exit
2661    /// code reflects signal termination (non-zero / None).
2662    ///
2663    /// Spawns `sleep` directly rather than through `sh -c` so SIGKILL
2664    /// reaches the process whose pipe our reader futures hold —
2665    /// `sh -c sleep` leaks the sleep child on SIGKILL (Q-C2), the
2666    /// pipe stays open, and the reader future hangs. That's a
2667    /// deliberate known limitation of start_kill; this test
2668    /// exercises the clean path.
2669    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2670    async fn kill_via_oneshot_terminates_long_running_child() {
2671        let mut cmd = TokioCommand::new("sleep");
2672        cmd.args(["30"]);
2673        cmd.stdout(std::process::Stdio::piped());
2674        cmd.stderr(std::process::Stdio::piped());
2675
2676        let mut child = cmd.spawn().expect("spawn sh -c sleep 30");
2677        let pid = child.id().expect("child has a pid");
2678
2679        let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
2680        let stdout_pipe = child.stdout.take();
2681        let stderr_pipe = child.stderr.take();
2682
2683        let stdout_fut = async {
2684            let mut buf = String::new();
2685            if let Some(s) = stdout_pipe {
2686                #[allow(clippy::let_underscore_must_use)]
2687                let _ = BufReader::new(s).read_to_string(&mut buf).await;
2688            }
2689            buf
2690        };
2691        let stderr_fut = async {
2692            let mut buf = String::new();
2693            if let Some(s) = stderr_pipe {
2694                #[allow(clippy::let_underscore_must_use)]
2695                let _ = BufReader::new(s).read_to_string(&mut buf).await;
2696            }
2697            buf
2698        };
2699        let wait_fut = async {
2700            tokio::select! {
2701                status = child.wait() => {
2702                    status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
2703                }
2704                _ = &mut kill_rx => {
2705                    #[allow(clippy::let_underscore_must_use)]
2706                    let _ = child.start_kill();
2707                    child
2708                        .wait()
2709                        .await
2710                        .map(|s| s.code().unwrap_or(-1))
2711                        .unwrap_or(-1)
2712                }
2713            }
2714        };
2715
2716        // Give the shell a moment to install itself — firing kill
2717        // against an not-yet-existent child is still valid (SIGKILL
2718        // to a zombie is a no-op) but we want to actually exercise
2719        // the running-child path.
2720        tokio::time::sleep(Duration::from_millis(50)).await;
2721        kill_tx.send(()).expect("kill channel send");
2722
2723        let result = timeout(Duration::from_secs(5), async {
2724            tokio::join!(stdout_fut, stderr_fut, wait_fut)
2725        })
2726        .await;
2727
2728        let (_stdout, _stderr, exit_code) = result.expect(
2729            "kill path must resolve within 5s — if this times out the \
2730             select! arm order or kill-then-wait logic is broken",
2731        );
2732        // The cross-platform invariant is "the child did not complete
2733        // its 30s sleep" — i.e. the exit code is non-success. Platform
2734        // specifics:
2735        //   - Unix: `start_kill()` sends SIGKILL; `ExitStatus::code()`
2736        //     returns None for signal-terminated processes, which our
2737        //     dispatcher maps to -1 via `.unwrap_or(-1)`.
2738        //   - Windows: `start_kill()` calls `TerminateProcess(..., 1)`;
2739        //     `code()` returns `Some(1)`, mapped to 1 by the same
2740        //     `.unwrap_or(-1)`.
2741        // A successful 30s sleep would yield 0 — that's the
2742        // regression case we're guarding against.
2743        assert_ne!(
2744            exit_code, 0,
2745            "killed child must exit non-success (got 0 — did the \
2746             kill arm fire too late, or did sleep somehow complete?)"
2747        );
2748
2749        // Sanity: on Unix the child must be gone. `kill -0 <pid>`
2750        // returns 0 iff the process still exists; we expect non-zero
2751        // (No such process) after wait(). This catches a zombie /
2752        // leaked child that would indicate we skipped the wait() on
2753        // the kill path. Skipped on Windows — `kill` isn't available
2754        // and `tasklist` output parsing is more noise than signal
2755        // for this one-shot check; the wait() having returned is
2756        // already evidence of reap there.
2757        #[cfg(unix)]
2758        {
2759            let still_alive = std::process::Command::new("kill")
2760                .args(["-0", &pid.to_string()])
2761                .status()
2762                .map(|s| s.success())
2763                .unwrap_or(false);
2764            assert!(
2765                !still_alive,
2766                "process {pid} must be reaped after wait() — a still-\
2767                 alive check means the kill path leaked the child"
2768            );
2769        }
2770        #[cfg(not(unix))]
2771        {
2772            // Touch `pid` so the unused-variable lint doesn't fire on
2773            // non-Unix builds.
2774            let _ = pid;
2775        }
2776    }
2777}