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