Skip to main content

strop_engine/editor/
mod.rs

1//! The editor state: modes, pending keys, named registers, buffers.
2//! One input path for TUI and headless — both call `feed`.
3//!
4//! Mode handlers live beside this file: `normal`, `visual`, `insert`.
5
6mod blame;
7pub mod block;
8#[cfg(test)]
9pub mod conformance;
10#[cfg(test)]
11pub mod contract_probes;
12pub mod trace;
13
14pub(crate) mod analysis;
15mod changes;
16pub mod collections;
17mod containers;
18mod cursor;
19mod diagnostics;
20mod dispatch;
21pub mod resolution;
22pub use diagnostics::DocumentDiagnostics;
23mod dive;
24mod document;
25pub mod events;
26mod explain;
27mod git;
28mod git_memory;
29mod help;
30mod input;
31mod insert;
32pub mod io;
33mod jumps;
34pub mod keys;
35mod lsp;
36pub mod macros;
37#[cfg(test)]
38mod multicursor_tests;
39pub(crate) mod normal;
40mod occurrence;
41#[cfg(test)]
42mod occurrence_tests;
43mod panes;
44pub mod pending;
45mod permalink;
46mod picker;
47mod registers;
48pub mod remote;
49mod remote_completion;
50mod shell;
51pub mod transact;
52mod undo;
53pub mod view;
54mod visual;
55mod workspaces;
56
57pub use collections::{CollectionRow, CollectionRowInfo};
58pub use document::Document;
59pub use document::{DiffRow, DocumentSource, RemoteDirectory, RemoteDocument, Surface};
60pub use git_memory::{git_channel, BlameGutter, GitJob};
61pub use git_memory::{CommitFiles, PreparedDiff, PreparedFiles, Sidebar, SidebarRow};
62pub use panes::{LayoutDir, Pane};
63pub use picker::{PickerGlue, PreviewKey, PreviewResult, PreviewSource, Previews};
64pub use registers::{ClipboardKey, ClipboardResult, Register};
65pub use shell::{ShellIntent, ShellKey, ShellResult};
66
67pub use containers::{ContainerKey, ContainerResult};
68pub use lsp::attach::AttachRecord;
69pub use lsp::LspServer;
70pub use permalink::PendingPermalink;
71pub use picker::ranking::Event as RankingEvent;
72pub use remote::{RemoteEvent, RemoteView};
73pub use remote_completion::{RemoteCompletionKey, RemoteCompletionResult};
74pub use resolution::{ResolutionEvent, ResolutionState};
75use std::collections::HashMap;
76use std::path::PathBuf;
77use std::time::Duration;
78pub use workspaces::WorkspaceRegistry;
79
80use strop_core::{Buffer, Range};
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Mode {
84    Normal,
85    Insert,
86    Visual,
87    VisualLine,
88    /// ctrl-v: the rectangle selection (0017).
89    VisualBlock,
90}
91
92impl Mode {
93    pub fn chip(self) -> &'static str {
94        match self {
95            Mode::Normal => "NORMAL",
96            Mode::Insert => "INSERT",
97            Mode::Visual => "VISUAL",
98            Mode::VisualLine => "V-LINE",
99            Mode::VisualBlock => "V-BLOCK",
100        }
101    }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
105pub enum Key {
106    Char(char),
107    Esc,
108    Enter,
109    Backspace,
110    Up,
111    Down,
112    Left,
113    Right,
114    Tab,
115    Backtab,
116    /// Replace picker: exclude the row's whole file (vscode's toggle).
117    CtrlD,
118    CtrlR,
119    /// vim's jump-back (ctrl-i forward is Tab in a terminal).
120    CtrlO,
121    CtrlW,
122    /// Replace picker: exclude/include the selected match (0007 §2).
123    CtrlX,
124    /// vim ctrl-u/ctrl-f/ctrl-b: half/full page up.
125    CtrlU,
126    CtrlF,
127    CtrlB,
128    /// vim ctrl-^: alternate buffer.
129    CtrlCaret,
130    /// vim ctrl-v: visual block mode.
131    CtrlV,
132    /// vim ctrl-l: force a full terminal repaint (desync recovery).
133    CtrlL,
134}
135
136pub const FLASH_FOR: Duration = Duration::from_millis(280);
137
138/// The injected frame renderer's shape (0046): editor, columns, rows,
139/// record-action flag — the binary's implementation renders via TestBackend.
140pub type FrameDraw = fn(&mut Editor, u16, u16, bool) -> std::io::Result<()>;
141
142pub struct Editor {
143    pub docs: strop_core::id::Arena<strop_core::id::DocumentKind, Document>,
144    pub(crate) trace_documents:
145        HashMap<strop_core::id::DocumentId, strop_core::diagnostics::BufferTraceId>,
146    pub io: io::IoState,
147    pub(crate) remote: remote::RemoteState,
148    pub(crate) remote_completion: remote_completion::RemoteCompletionState,
149    pub(crate) worker_ids: strop_core::worker::WorkerIds,
150    pub(crate) worker_handles:
151        HashMap<strop_core::worker::WorkerId, strop_core::worker::CancelHandle>,
152    pub(crate) focus_epoch: u64,
153    pub(crate) finishing: bool,
154    pub tape: std::rc::Rc<strop_trace::replay::Tape>,
155    pub git_view: strop_core::worker::WorkerId,
156    pub git_discovery: strop_core::worker::Load<git_memory::ContextKey>,
157    pub hunk_load: strop_core::worker::Load<git_memory::HunkKey>,
158    pub hunks_untracked: bool,
159    pub log_requests:
160        HashMap<strop_core::id::DocumentId, strop_core::worker::Ticket<git_memory::LogKey>>,
161    pub card_request: Option<strop_core::worker::Ticket<git_memory::CardKey>>,
162    pub dive_requests:
163        HashMap<strop_core::id::DocumentId, strop_core::worker::Ticket<git_memory::DiveKey>>,
164    pub git_mutations: std::collections::VecDeque<git_memory::GitMutation>,
165    pub git_mutation: Option<strop_core::worker::Ticket<git_memory::MutationKey>>,
166    /// vim's jumplist (ctrl-o/ctrl-i): past/future stacks of
167    /// (document, byte offset) (jumps.rs).
168    pub jumplist_past: Vec<(strop_core::id::DocumentId, usize)>,
169    pub jumplist_future: Vec<(strop_core::id::DocumentId, usize)>,
170    pub mode: Mode,
171    pub pending: pending::PendingInput,
172    /// The input walker (0008 stage 2): typed parser state for
173    /// counts/registers/operators/prefixes — pending stays for the
174    /// free-text lines only.
175    pub walker: input::Walker,
176    /// `Space u` browser state (editor/undo.rs); None when closed.
177    pub undo_browser: Option<undo::UndoBrowser>,
178    /// Last `f/F/t/T` find: (char, backward, till). `;` and `,` replay it.
179    pub last_find: Option<(char, bool, bool)>,
180    /// Armed by `/`/`?`/`*`/`#` searches. `n`/`N` replay it; the render
181    /// highlights matches persistently (rootle: current match underlined).
182    pub last_search: Option<LastSearch>,
183    /// Live occurrence selection (0049 §7): needle + add-order ranges.
184    pub(crate) occurrence: Option<occurrence::OccurrenceState>,
185    pub registers: HashMap<char, Register>,
186    /// Marks: char → (document, byte offset). `m{a}` sets, `'{a}` jumps.
187    pub marks: HashMap<char, (strop_core::id::DocumentId, usize)>,
188    pub flash: Option<(Range, strop_trace::replay::Tick)>,
189    pub message: String,
190    pub should_quit: bool,
191    /// ctrl-c is armed after the first warn (0015 quit policy).
192    pub ctrl_c_armed: bool,
193    /// Last visual range for `gv` (recorded per visual-mode key).
194    pub last_visual: Option<(usize, usize)>,
195    /// Where the last insert session was for `gi`.
196    pub last_insert_pos: Option<usize>,
197    /// g;/g, walk: (index, history depth it was taken at) — a new
198    /// commit invalidates the walk.
199    pub change_idx: Option<(usize, usize)>,
200    /// Text area height in rows — the render loop feeds it via
201    /// scroll_to_cursor; viewport motions read it.
202    pub view_rows: usize,
203    /// Macro recording (0016): the register being recorded into.
204    pub recording: Option<char>,
205    /// The app event channel (0018): set by connect_events; late LSP
206    /// attaches forward through it.
207    pub app_tx: Option<events::EventSender>,
208    pub(crate) lsp_state: lsp::state::LspState,
209    /// Recorded macros: register → key events.
210    pub macros: std::collections::HashMap<char, Vec<Key>>,
211    /// The last replayed macro register (@@).
212    pub last_macro: Option<char>,
213    /// The rows and cell edge owned by an ongoing block insert/change.
214    pub(crate) block_insert_state: Option<block::BlockInsertState>,
215    /// Macro self-replay depth guard.
216    pub macro_depth: usize,
217    pub picker: Option<PickerGlue>,
218    pub(crate) picker_ranking: picker::ranking::State,
219    pub(crate) analysis: analysis::AnalysisState,
220    pub resolution: resolution::ResolutionState,
221    pub cwd: PathBuf,
222    /// Bound workspace contexts (0042 slice 2): one per filesystem in use.
223    pub workspaces: workspaces::WorkspaceRegistry,
224    /// Applied change plans and their receipts (0043); grouped undo reads it.
225    pub(crate) changes: changes::ChangeState,
226    pub(crate) review: changes::review::ReviewState,
227    /// Open editable code collections by their buffer document (0044).
228    pub(crate) collections: HashMap<strop_core::id::DocumentId, collections::Collection>,
229    /// A build waiting on background source loads (0044 v2).
230    pub(crate) collection_build: Option<collections::CollectionBuild>,
231    /// Container attach/browse state (0037 DC1a).
232    pub(crate) containers: containers::ContainerState,
233    /// MRU document order (most recent first); drives `Space b`.
234    pub mru: Vec<strop_core::id::DocumentId>,
235    /// Picker preview file cache.
236    pub previews: Previews,
237    /// Git working surface state (M2).
238    pub git: Option<strop_git::GitContext>,
239    /// Preview file reads run on worker threads (0001 §3); results and
240    /// the in-flight set are drained in drain_picker.
241    pub preview_tx: std::sync::mpsc::Sender<PreviewResult>,
242    pub preview_rx: Option<std::sync::mpsc::Receiver<PreviewResult>>,
243    pub(crate) preview_loads: HashMap<PathBuf, strop_core::worker::Load<PreviewKey>>,
244    pub hunks: git_memory::HunkSet,
245    /// HEAD↔index — the staged set (0014 wave 4); rendered in the
246    /// gutter's committed-adjacent color.
247    pub staged_hunks: git_memory::HunkSet,
248    /// Git memory (M3): per-buffer surface kinds, blame card, job channel,
249    /// OSC52 clipboard payload drained by the TUI.
250    pub blame_card: Option<strop_git::memory::BlameCard>,
251    /// Each full/range/tail document owns its own revision-checked gutter.
252    pub blame_gutters: HashMap<strop_core::id::DocumentId, BlameGutter>,
253    /// Bumped on every buffer-list mutation; git jobs carry the
254    /// generation they were spawned under so results for dead
255    /// surfaces are dropped (0011 §2).
256    pub generation: u64,
257    pub git_tx: std::sync::mpsc::Sender<GitJob>,
258    pub git_rx: Option<std::sync::mpsc::Receiver<GitJob>>,
259    pub osc52: Option<String>,
260    pub terminal_output: Vec<String>,
261    /// ctrl-l: the terminal desynced from the model — the draw loop
262    /// answers with a full repaint (vim's redraw).
263    pub needs_repaint: bool,
264    /// System-clipboard reads (paste from `+`) run on a worker thread;
265    /// `clip_paste_pending` remembers before/after AND the initiating
266    /// document until the read lands (0023 §4).
267    pub clip_tx: std::sync::mpsc::Sender<ClipboardResult>,
268    pub clip_rx: Option<std::sync::mpsc::Receiver<ClipboardResult>>,
269    pub clip_paste_pending: Option<(bool, strop_core::worker::Ticket<ClipboardKey>)>,
270    /// LSP server pool (0014 wave 2): one client per (workspace root,
271    /// server) — a rust file and a python file in one session get their
272    /// own servers. Diagnostics by path, hover card, open bookkeeping.
273    pub lsp_servers: Vec<crate::editor::lsp::LspServer>,
274    pub diags: HashMap<strop_core::id::DocumentId, DocumentDiagnostics>,
275    pub hover_card: Option<String>,
276    /// Shell jobs (`:!cmd` output buffers, `|cmd` pipes): results land
277    /// in drain_shell — never a subprocess on the input path (0001 §3).
278    pub shell_tx: std::sync::mpsc::Sender<ShellResult>,
279    pub shell_rx: Option<std::sync::mpsc::Receiver<ShellResult>>,
280    pub(crate) shell_requests: HashMap<strop_core::worker::WorkerId, ShellIntent>,
281    pub(crate) shell_focus: Option<strop_core::worker::WorkerId>,
282    /// Splits: flat row/column of panes (v1; tree layout later).
283    pub panes: Vec<Pane>,
284    pub active_pane: usize,
285    pub layout: LayoutDir,
286    /// User config (0005-lite: TOML, embedded defaults, never bricks).
287    pub config: crate::config::Config,
288    /// Shared state root for explicit trust and optional session persistence.
289    pub state_dir: Option<PathBuf>,
290    pub session_policy: crate::session::SessionPolicy,
291    /// The last grammar-level change (dot-repeat's semantic form).
292    pub(crate) last_change: Option<strop_grammar::Command>,
293    /// Direct non-grammar commands (x, p, J…) replay their key string.
294    pub(crate) last_cmd_keys: String,
295    pub(crate) last_insert: Option<String>,
296    pub(crate) recording_insert: Option<String>,
297    /// vim insert counts: `3i…`/`2o` repeat the session's text (o/O
298    /// repeat the opened line too — `insert_open` carries it).
299    pub(crate) insert_count: usize,
300    pub(crate) insert_open: Option<String>,
301    /// Injected frame renderer (0046): cell-grid production belongs to the
302    /// binary; replay of a recorded `Frame` action renders through this
303    /// hook. The engine never renders on its own.
304    pub frame_draw: Option<FrameDraw>,
305}
306
307/// The compiled query owns matching semantics for repeat, preview and highlighting.
308#[derive(Debug, Clone)]
309pub struct LastSearch {
310    pub query: strop_grammar::CompiledQuery,
311    pub backward: bool,
312}
313
314/// A pending `f/F/t/T` awaiting its target char — the leap-style
315/// candidate overlay's input. Named fields, not a naked `(u8, bool)`.
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub struct FindPending {
318    pub ch: char,
319    pub backward: bool,
320}
321
322/// The ctrl-v rectangle (0017): line span by buffer index, cell span
323/// by LineLayout columns — named fields, not a naked mixed-unit tuple.
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub struct BlockRect {
326    pub first_line: usize,
327    pub last_line: usize,
328    pub left_cell: strop_core::id::DisplayColumn,
329    pub right_cell: strop_core::id::DisplayColumn,
330}
331
332impl Editor {
333    pub fn new(buf: Buffer) -> Self {
334        // cwd is the process directory (project-wide): pickers walk it,
335        // LSP/git resolve against it; a file's own dir is not the project.
336        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
337        Self::new_in(buf, cwd)
338    }
339
340    /// Pure construction. Native services start explicitly after forensic seeding.
341    pub fn new_in(buf: Buffer, cwd: PathBuf) -> Self {
342        let (preview_tx, preview_rx) = std::sync::mpsc::channel();
343        let (shell_tx, shell_rx) = std::sync::mpsc::channel();
344        let (clip_tx, clip_rx) = std::sync::mpsc::channel();
345        let (git_tx, git_rx) = git_channel();
346        let mut docs = strop_core::id::Arena::default();
347        // the source identity is set at construction, not by convention
348        let doc = if buf.path.is_some() {
349            Document::new(buf)
350        } else {
351            Document::scratch(buf)
352        };
353        let current = docs.insert(doc);
354        Self {
355            docs,
356            trace_documents: HashMap::new(),
357            io: io::IoState::default(),
358            remote: remote::RemoteState::default(),
359            remote_completion: remote_completion::RemoteCompletionState::default(),
360            picker_ranking: picker::ranking::State::default(),
361            analysis: analysis::AnalysisState::default(),
362            resolution: resolution::ResolutionState::default(),
363            worker_ids: strop_core::worker::WorkerIds::default(),
364            worker_handles: HashMap::new(),
365            focus_epoch: 0,
366            finishing: false,
367            tape: std::rc::Rc::new(strop_trace::replay::Tape::new()),
368            git_view: strop_core::worker::WorkerId::new(0),
369            git_discovery: strop_core::worker::Load::Idle,
370            hunk_load: strop_core::worker::Load::Idle,
371            hunks_untracked: false,
372            log_requests: HashMap::new(),
373            card_request: None,
374            dive_requests: HashMap::new(),
375            git_mutations: std::collections::VecDeque::new(),
376            git_mutation: None,
377            shell_requests: HashMap::new(),
378            shell_focus: None,
379            containers: containers::ContainerState::default(),
380            mru: vec![current],
381            changes: changes::ChangeState::default(),
382            review: changes::review::ReviewState::default(),
383            collections: HashMap::new(),
384            collection_build: None,
385            mode: Mode::Normal,
386            occurrence: None,
387            pending: pending::PendingInput::default(),
388            walker: input::Walker::new(),
389            last_search: None,
390            undo_browser: None,
391            registers: HashMap::new(),
392            marks: HashMap::new(),
393            last_find: None,
394            flash: None,
395            message: String::new(),
396            should_quit: false,
397            ctrl_c_armed: false,
398            last_visual: None,
399            last_insert_pos: None,
400            change_idx: None,
401            view_rows: 24,
402            recording: None,
403            app_tx: None,
404            lsp_state: lsp::state::LspState::default(),
405            macros: std::collections::HashMap::new(),
406            last_macro: None,
407            block_insert_state: None,
408            macro_depth: 0,
409            last_change: None,
410            last_cmd_keys: String::new(),
411            last_insert: None,
412            recording_insert: None,
413            insert_count: 1,
414            insert_open: None,
415            picker: None,
416            workspaces: {
417                let mut registry = workspaces::WorkspaceRegistry::default();
418                registry.bind(strop_workspace::Filesystem::Local, Some(cwd.clone()));
419                registry
420            },
421            cwd,
422            blame_gutters: HashMap::new(),
423            generation: 0,
424            previews: HashMap::new(),
425            shell_tx,
426            shell_rx: Some(shell_rx),
427            git: None,
428            hunks: git_memory::HunkSet::default(),
429            staged_hunks: git_memory::HunkSet::default(),
430            blame_card: None,
431            git_tx,
432            git_rx: Some(git_rx),
433            needs_repaint: false,
434            osc52: None,
435            terminal_output: Vec::new(),
436            preview_tx,
437            preview_rx: Some(preview_rx),
438            preview_loads: HashMap::new(),
439            jumplist_past: Vec::new(),
440            jumplist_future: Vec::new(),
441            lsp_servers: Vec::new(),
442            clip_tx,
443            clip_rx: Some(clip_rx),
444            clip_paste_pending: None,
445            diags: HashMap::new(),
446            hover_card: None,
447            panes: vec![Pane {
448                doc: current,
449                sels: strop_core::selection::SelectionSet::default(),
450                view_top: 0,
451                hscroll: strop_core::id::DisplayColumn::new(0),
452                desired_column: None,
453            }],
454            active_pane: 0,
455            layout: LayoutDir::Row,
456            config: crate::config::Config::default(),
457            state_dir: None,
458            session_policy: crate::session::SessionPolicy::Automatic,
459            frame_draw: None,
460        }
461    }
462
463    pub fn feed_text(&mut self, text: &str) {
464        for key in keys::parse(text) {
465            self.feed(key);
466        }
467    }
468
469    pub fn feed(&mut self, key: Key) {
470        let _trace_scope = trace::InputScope::enter(self, key);
471        self.trace_state();
472        let generated = self.resolution.in_action;
473        if self.resolution.blocked()
474            || (!generated && !self.resolution.queue.is_empty())
475            || (generated && !self.resolution.staged.is_empty())
476        {
477            if generated {
478                self.resolution
479                    .staged
480                    .push_back(resolution::DeferredInput::GeneratedKey {
481                        key,
482                        depth: self.macro_depth,
483                    });
484            } else {
485                self.resolution
486                    .queue
487                    .push_back(resolution::DeferredInput::Key(key));
488            }
489            return;
490        }
491        self.run_input_action(|editor| {
492            editor.feed_inner(key);
493            editor.prepare_resolution_preview();
494        });
495        self.trace_state();
496    }
497
498    fn feed_inner(&mut self, key: Key) {
499        self.revoke_shell_focus();
500        self.message.clear();
501        if key == Key::Esc
502            && !self.pending.is_active()
503            && self.cancel_open(strop_core::worker::CancelReason::Dismissed)
504        {
505            self.message = "open cancelled".into();
506        }
507        // macro recording (0016): q at ground stops and never reaches
508        // the machine; everything else records BEFORE it runs, so
509        // replay is exactly the live stream
510        if let Some(reg) = self.recording {
511            let at_ground = self.walker.is_ground() && !self.pending.is_active();
512            if at_ground && key == Key::Char('q') {
513                self.recording = None;
514                self.message = format!("recorded @{}", reg);
515                return;
516            }
517            if let Some(buf) = self.macros.get_mut(&reg) {
518                buf.push(key);
519            }
520        }
521        self.dispatch_owned(key);
522    }
523
524    /// True when a modal input field sits in normal mode (picker field
525    /// or pending line) — the TUI draws the block cursor for it.
526    pub fn input_normal(&self) -> bool {
527        self.pending.normal()
528            || self
529                .picker
530                .as_ref()
531                .is_some_and(|g| g.picker.input_normal())
532    }
533
534    /// The modal line's sigil when a free-text line is open (`: / ? |`)
535    /// — the ONE authority; the render card, the terminal's bar-cursor
536    /// shape, and pending dispatch all ask here (a `|sed s/a/b/` body
537    /// is a pipe, not a search).
538    pub fn pending_sigil(&self) -> Option<char> {
539        self.pending.sigil()
540    }
541
542    /// The current document's indent (config default or detected at
543    /// open — resolved eagerly, so reads never rescan).
544    pub(crate) fn cur_indent(&self) -> document::Indent {
545        self.cur().indent
546    }
547
548    // ---- shared helpers -------------------------------------------------
549
550    /// `m{a}`: set mark a at the cursor.
551    pub(crate) fn set_mark(&mut self, mark: char) {
552        self.marks.insert(mark, (self.current(), self.head()));
553        self.message = format!("mark {mark} set");
554    }
555
556    /// (name, 1-based line, trimmed line text) per set mark, name-sorted —
557    /// the which-key mark cards' live rows (0047 §3).
558    pub fn mark_rows(&self) -> Vec<(char, usize, String)> {
559        let mut rows: Vec<_> = self
560            .marks
561            .iter()
562            .filter_map(|(name, (document, offset))| {
563                let doc = self.docs.get(*document)?;
564                let line = doc.buf.line_of(*offset);
565                let text: String = doc.buf.line_text(line).trim().chars().take(48).collect();
566                Some((*name, line + 1, text))
567            })
568            .collect();
569        rows.sort_by_key(|row| row.0);
570        rows
571    }
572
573    /// `'{a}`: jump to mark a (switches buffer if the mark lives there).
574    pub(crate) fn jump_mark(&mut self, mark: char) {
575        self.push_jump(); // mark jumps are jumplist entries
576        match self.marks.get(&mark).copied() {
577            Some((buf, offset)) => {
578                if self.docs.get(buf).is_some() {
579                    if buf != self.current() {
580                        self.switch_to(buf);
581                        self.discover_git();
582                    }
583                    self.set_head(
584                        self.buf()
585                            .clamp_boundary(offset.min(self.buf().len_bytes())),
586                    );
587                    self.clamp_cursor();
588                }
589            }
590            None => self.message = format!("mark {mark} not set"),
591        }
592    }
593}
594
595/// Local-channel delivery and fixture helpers for engine-consumer tests;
596/// live drivers use the shared AppEvent channel.
597#[cfg(any(test, feature = "test-support"))]
598pub mod test_support;
599#[cfg(test)]
600mod tests;
601#[cfg(test)]
602mod transaction_conformance;
603
604impl Drop for Editor {
605    fn drop(&mut self) {
606        // One shutdown boundary, including headless errors and terminal failures.
607        for handle in std::mem::take(&mut self.worker_handles).into_values() {
608            handle.cancel(strop_core::worker::CancelReason::Shutdown);
609        }
610        for server in std::mem::take(&mut self.lsp_servers) {
611            if let Some(client) = server.client {
612                client.shutdown();
613                client.wait(Duration::from_millis(500));
614            }
615        }
616    }
617}
618
619/// The headless `state` directive's logical observation (0006): mode,
620/// cursor, pending input, picker and register state — no cells. The
621/// binary's headless driver prints it; the forensic observation embeds it.
622pub fn state_json(editor: &Editor) -> String {
623    if editor.docs.is_empty() {
624        return serde_json::json!({"should_quit":editor.should_quit,"documents":0,"message":editor.message}).to_string();
625    }
626    serde_json::json!({
627        "mode": editor.mode.chip(),
628        "cursor": editor.head(),
629        "line": editor.buf().line_of(editor.head()) + 1,
630        "col": editor.buf().col_of(editor.head()) + 1,
631        "pending": editor.pending.text(),
632        "message": editor.message,
633        "extra_cursors": editor.extra_selections().iter().map(|s| s.head).collect::<Vec<_>>(),
634        "panes": editor.panes.len(),
635        "active_pane": editor.active_pane,
636        "picker": editor.picker_open(),
637        "picker_input": editor.picker.as_ref().map(|g| g.picker.input.text.clone()),
638        "picker_items": editor.picker.as_ref().map(|g| g.picker.items.len()),
639        "picker_streaming": editor.picker.as_ref().map(|g| g.picker.streaming),
640        "register": editor.register(None).text,
641        "dirty": editor.buf().dirty,
642    })
643    .to_string()
644}