Skip to main content

tear_core/
blocks.rs

1//! Pane-as-block — OSC 133 prompt-mark-driven block extractor.
2//!
3//! ## What this is
4//!
5//! Warp-class "every command + output is a discrete block"
6//! semantics, daemon-native. Operators (and AI agents via mado
7//! MCP) get to fetch one block instead of grepping a byte
8//! stream. Sharper context, sharper answers, sharper UI.
9//!
10//! ## The OSC 133 contract
11//!
12//! Originated in iTerm2 / FinalTerm; adopted by VS Code, ghostty,
13//! kitty, wezterm, and most modern terminal emulators. Shells
14//! emit these markers via their PS1 (bash), prompt() (zsh
15//! powerlevel10k), or starship's built-in support.
16//!
17//! ```text
18//! OSC 133 ; A ST     — start of prompt
19//! OSC 133 ; B ST     — end of prompt / start of command-line edit
20//! OSC 133 ; C ST     — end of command-line / start of command output
21//! OSC 133 ; D ; <n>  — end of output, exit code n
22//! ```
23//!
24//! A single "block" is therefore a four-state state machine:
25//! Idle → Prompt → Command → Output → Idle.
26//!
27//! ## What we capture
28//!
29//! For each completed block:
30//! - `prompt`  — verbatim characters printed between A and B
31//! - `command` — verbatim characters printed between B and C
32//! - `output`  — verbatim characters printed between C and D
33//! - `exit_code` — integer from the D marker (None if missing)
34//! - `started_at_unix_ms` / `ended_at_unix_ms`
35//!
36//! ## Storage
37//!
38//! Per-pane `VecDeque<Block>` with a configurable cap (default
39//! 10_000 blocks ≈ a few days of typical interactive shell).
40//! Oldest blocks evict first when the cap is hit — same
41//! ring-buffer shape `PaneRecording` uses.
42
43use std::collections::VecDeque;
44use std::time::{SystemTime, UNIX_EPOCH};
45
46/// Re-export the wire shape so callers don't have to choose
47/// between tear-core and tear-types — they're the same type.
48pub use tear_types::Block;
49
50/// Block-extractor state machine.
51#[derive(Copy, Clone, Debug, PartialEq, Eq)]
52enum Phase {
53    /// Haven't seen an A marker yet, or the previous block
54    /// closed and we're waiting for the next prompt.
55    Idle,
56    /// Inside the prompt — printable chars append to `prompt`.
57    Prompt,
58    /// Inside the command-line — printable chars append to
59    /// `command` (operator's typed input, echoed back by the
60    /// shell).
61    Command,
62    /// Inside output — printable chars append to `output`.
63    Output,
64}
65
66pub struct BlockExtractor {
67    blocks: VecDeque<Block>,
68    cap: usize,
69    current: Option<Block>,
70    phase: Phase,
71    next_index: u64,
72    /// Last-known working directory from OSC 7. Captured into
73    /// each block at prompt start so the block carries its
74    /// own `cwd` even if the shell cd's mid-output.
75    current_cwd: Option<String>,
76    /// Provenance of the owning pane, stamped onto every block
77    /// this extractor mints. Write-once via [`Self::stamp_yurai`].
78    yurai: tear_types::Yurai,
79}
80
81impl Default for BlockExtractor {
82    fn default() -> Self {
83        Self::new(10_000)
84    }
85}
86
87impl BlockExtractor {
88    #[must_use]
89    pub fn new(cap: usize) -> Self {
90        Self {
91            blocks: VecDeque::new(),
92            cap,
93            current: None,
94            phase: Phase::Idle,
95            next_index: 0,
96            current_cwd: None,
97            yurai: tear_types::Yurai::Unknown,
98        }
99    }
100
101    /// Stamp the owning pane's provenance onto this extractor —
102    /// **write-once**, and that is the whole point.
103    ///
104    /// [`Yurai`] is documented as a pane's provenance *for its
105    /// whole life*, so a settable field would contradict the type
106    /// it carries: an agent-spawned pane could re-stamp itself
107    /// `Human` mid-session and every block after that point would
108    /// lie, retroactively laundering the history a `freio` press
109    /// is supposed to be able to trust.
110    ///
111    /// So the second call is refused rather than applied. Returns
112    /// `true` when this call took effect. A refusal is not an
113    /// error — re-stamping the SAME provenance is harmless and
114    /// idempotent — but a refusal that would have CHANGED the
115    /// value is a caller bug, and the return value is how a caller
116    /// can notice.
117    ///
118    /// Tier-honest: **only-mitigated**. `Yurai::Automation` is a
119    /// public variant, so in-process code can construct a
120    /// provenance without holding an attested connection; what is
121    /// closed here is *drift after the fact*, not *fabrication at
122    /// the source*. Fabrication is bounded by
123    /// [`tear_types::Yurai::from_shutai`] being the only
124    /// production path, which is convention plus its own tests —
125    /// not a compile error.
126    ///
127    /// [`Yurai`]: tear_types::Yurai
128    pub fn stamp_yurai(&mut self, y: tear_types::Yurai) -> bool {
129        if self.yurai == tear_types::Yurai::Unknown {
130            self.yurai = y;
131            true
132        } else {
133            false
134        }
135    }
136
137    /// The provenance every block from this extractor carries.
138    #[must_use]
139    pub fn yurai(&self) -> &tear_types::Yurai {
140        &self.yurai
141    }
142
143    /// Record the shell's current working directory. Called by
144    /// the OSC 7 hook in GridState — the next prompt-start
145    /// stamps this onto the new block. OSC 7 payload is
146    /// typically `file://<host>/path/to/dir`; we strip the
147    /// scheme + host to keep just the absolute path.
148    pub fn set_cwd_from_osc7(&mut self, raw: &str) {
149        if let Some(rest) = raw.strip_prefix("file://") {
150            // Drop the host portion (anything before the first
151            // '/' that starts the path).
152            if let Some(slash) = rest.find('/') {
153                self.current_cwd = Some(rest[slash..].to_owned());
154                return;
155            }
156        }
157        // Fallback: take the raw payload verbatim — some
158        // shells emit just the path.
159        self.current_cwd = Some(raw.to_owned());
160    }
161
162    /// Current OSC 7-set cwd, if any. Surfaced for tests and
163    /// for renderers that want to display the active directory
164    /// independent of any open block.
165    #[must_use]
166    pub fn current_cwd(&self) -> Option<&str> {
167        self.current_cwd.as_deref()
168    }
169
170    /// Number of completed blocks currently retained.
171    #[must_use]
172    pub fn len(&self) -> usize {
173        self.blocks.len()
174    }
175
176    #[must_use]
177    pub fn is_empty(&self) -> bool {
178        self.blocks.is_empty()
179    }
180
181    /// Iterate completed blocks oldest-first.
182    pub fn iter(&self) -> impl Iterator<Item = &Block> {
183        self.blocks.iter()
184    }
185
186    /// Fetch a single block by its per-pane index. Returns
187    /// `None` when the block has been evicted (index < oldest)
188    /// or never existed (index > latest completed).
189    #[must_use]
190    pub fn get(&self, index: u64) -> Option<&Block> {
191        self.blocks.iter().find(|b| b.index == index)
192    }
193
194    /// In-progress block (if any). Useful for live "what's
195    /// running right now" introspection.
196    #[must_use]
197    pub fn current(&self) -> Option<&Block> {
198        self.current.as_ref()
199    }
200
201    /// Append a printable char to whatever phase we're in.
202    /// No-op when Idle. Called from the vte Perform::print hook.
203    pub fn on_print(&mut self, c: char) {
204        let Some(block) = self.current.as_mut() else {
205            return;
206        };
207        match self.phase {
208            Phase::Prompt => block.prompt.push(c),
209            Phase::Command => block.command.push(c),
210            Phase::Output => block.output.push(c),
211            Phase::Idle => {}
212        }
213    }
214
215    /// Append raw bytes (for non-Print events like CR/LF/Esc).
216    /// The output phase wants the full byte stream so a replayer
217    /// can reproduce escape sequences. Phases Prompt/Command
218    /// keep only printable chars (escapes there are usually
219    /// terminal-renderer concerns).
220    pub fn on_raw_byte(&mut self, b: u8) {
221        let Some(block) = self.current.as_mut() else {
222            return;
223        };
224        if matches!(self.phase, Phase::Output) {
225            // Push as UTF-8 — \r\n stays as-is, escape bytes
226            // become control chars in the String.
227            block.output.push(b as char);
228        }
229    }
230
231    /// Handle an OSC 133 marker. `marker` is the second OSC
232    /// param (the `A` / `B` / `C` / `D[;<n>]` part).
233    pub fn on_osc_133(&mut self, marker: &str) {
234        let kind = marker.chars().next().unwrap_or(' ');
235        match kind {
236            'A' => self.start_prompt(),
237            'B' => self.start_command(),
238            'C' => self.start_output(),
239            'D' => self.end_output(parse_exit_code(marker)),
240            _ => {}
241        }
242    }
243
244    fn start_prompt(&mut self) {
245        // If a block is still open (e.g. shell emitted A
246        // without a D), close it as orphaned and start fresh.
247        if self.current.is_some() {
248            self.finalize_current(None);
249        }
250        let now = now_ms();
251        self.current = Some(Block {
252            index: self.next_index,
253            prompt: String::new(),
254            command: String::new(),
255            output: String::new(),
256            exit_code: None,
257            started_at_unix_ms: now,
258            ended_at_unix_ms: None,
259            cwd: self.current_cwd.clone(),
260            // Stamped at prompt START, not at completion: the
261            // question "who ran this" is settled when the block
262            // is minted, so a block that never finishes still
263            // carries its attribution.
264            yurai: self.yurai.clone(),
265        });
266        self.next_index += 1;
267        self.phase = Phase::Prompt;
268    }
269
270    fn start_command(&mut self) {
271        if self.current.is_some() {
272            self.phase = Phase::Command;
273        }
274    }
275
276    fn start_output(&mut self) {
277        if self.current.is_some() {
278            self.phase = Phase::Output;
279        }
280    }
281
282    fn end_output(&mut self, exit_code: Option<i32>) {
283        self.finalize_current(exit_code);
284    }
285
286    fn finalize_current(&mut self, exit_code: Option<i32>) {
287        let Some(mut block) = self.current.take() else {
288            return;
289        };
290        block.exit_code = exit_code;
291        block.ended_at_unix_ms = Some(now_ms());
292        if self.blocks.len() == self.cap {
293            self.blocks.pop_front();
294        }
295        self.blocks.push_back(block);
296        self.phase = Phase::Idle;
297    }
298}
299
300fn parse_exit_code(marker: &str) -> Option<i32> {
301    // D ; <n>  — split on ';' and parse the second segment.
302    marker.split(';').nth(1).and_then(|s| s.trim().parse().ok())
303}
304
305fn now_ms() -> u64 {
306    SystemTime::now()
307        .duration_since(UNIX_EPOCH)
308        .map(|d| d.as_millis() as u64)
309        .unwrap_or(0)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn idle_extractor_drops_prints() {
318        let mut bx = BlockExtractor::default();
319        bx.on_print('x');
320        assert!(bx.is_empty());
321        assert!(bx.current().is_none());
322    }
323
324    #[test]
325    fn full_block_lifecycle_captures_all_phases() {
326        let mut bx = BlockExtractor::default();
327        // OSC 133 A — prompt start
328        bx.on_osc_133("A");
329        for c in "$ ".chars() {
330            bx.on_print(c);
331        }
332        // OSC 133 B — command start
333        bx.on_osc_133("B");
334        for c in "ls".chars() {
335            bx.on_print(c);
336        }
337        // OSC 133 C — output start
338        bx.on_osc_133("C");
339        for c in "a b c".chars() {
340            bx.on_print(c);
341        }
342        // OSC 133 D ; 0 — exit 0
343        bx.on_osc_133("D;0");
344
345        assert_eq!(bx.len(), 1);
346        let b = bx.iter().next().unwrap();
347        assert_eq!(b.prompt, "$ ");
348        assert_eq!(b.command, "ls");
349        assert_eq!(b.output, "a b c");
350        assert_eq!(b.exit_code, Some(0));
351        assert!(b.ended_at_unix_ms.is_some());
352        assert_eq!(b.index, 0);
353        assert!(bx.current().is_none());
354    }
355
356    #[test]
357    fn exit_code_optional_when_d_marker_omits_it() {
358        let mut bx = BlockExtractor::default();
359        bx.on_osc_133("A");
360        bx.on_osc_133("B");
361        bx.on_osc_133("C");
362        bx.on_osc_133("D");
363        let b = bx.iter().next().unwrap();
364        assert_eq!(b.exit_code, None);
365    }
366
367    #[test]
368    fn unfinished_block_is_orphaned_when_next_prompt_starts() {
369        let mut bx = BlockExtractor::default();
370        bx.on_osc_133("A");
371        for c in "p1".chars() {
372            bx.on_print(c);
373        }
374        // Shell glitches — sends another A without D.
375        bx.on_osc_133("A");
376        for c in "p2".chars() {
377            bx.on_print(c);
378        }
379        bx.on_osc_133("B");
380        bx.on_osc_133("C");
381        bx.on_osc_133("D;0");
382
383        assert_eq!(bx.len(), 2);
384        let mut iter = bx.iter();
385        let first = iter.next().unwrap();
386        let second = iter.next().unwrap();
387        assert_eq!(first.prompt, "p1");
388        assert_eq!(first.exit_code, None);
389        assert_eq!(second.prompt, "p2");
390        assert_eq!(second.exit_code, Some(0));
391    }
392
393    #[test]
394    fn ring_buffer_caps_at_max() {
395        let mut bx = BlockExtractor::new(3);
396        for i in 0..5 {
397            bx.on_osc_133("A");
398            for c in format!("p{i}").chars() {
399                bx.on_print(c);
400            }
401            bx.on_osc_133("D;0");
402        }
403        assert_eq!(bx.len(), 3);
404        // Oldest two evicted; remaining indices are 2, 3, 4.
405        let indices: Vec<u64> = bx.iter().map(|b| b.index).collect();
406        assert_eq!(indices, vec![2, 3, 4]);
407    }
408
409    #[test]
410    fn get_by_index_returns_block_or_none() {
411        let mut bx = BlockExtractor::default();
412        bx.on_osc_133("A");
413        bx.on_osc_133("D;0");
414        bx.on_osc_133("A");
415        bx.on_osc_133("D;1");
416
417        assert_eq!(bx.get(0).map(|b| b.exit_code), Some(Some(0)));
418        assert_eq!(bx.get(1).map(|b| b.exit_code), Some(Some(1)));
419        assert!(bx.get(99).is_none());
420    }
421
422    #[test]
423    fn osc7_cwd_stamped_onto_next_block() {
424        let mut bx = BlockExtractor::default();
425        bx.set_cwd_from_osc7("file://localhost/Users/me/code");
426        bx.on_osc_133("A");
427        bx.on_osc_133("D;0");
428        let b = bx.iter().next().unwrap();
429        assert_eq!(b.cwd.as_deref(), Some("/Users/me/code"));
430    }
431
432    #[test]
433    fn osc7_without_file_scheme_passes_through_verbatim() {
434        let mut bx = BlockExtractor::default();
435        bx.set_cwd_from_osc7("/tmp/raw-path");
436        assert_eq!(bx.current_cwd(), Some("/tmp/raw-path"));
437    }
438
439    #[test]
440    fn block_duration_ms_computes_on_finalize() {
441        let mut bx = BlockExtractor::default();
442        bx.on_osc_133("A");
443        std::thread::sleep(std::time::Duration::from_millis(5));
444        bx.on_osc_133("D;0");
445        let b = bx.iter().next().unwrap();
446        let d = b.duration_ms().expect("finalized block has duration");
447        assert!(d < 5_000, "absurd duration: {d}ms");
448    }
449
450    #[test]
451    fn parse_exit_code_handles_typical_shapes() {
452        assert_eq!(parse_exit_code("D"), None);
453        assert_eq!(parse_exit_code("D;"), None);
454        assert_eq!(parse_exit_code("D;0"), Some(0));
455        assert_eq!(parse_exit_code("D;127"), Some(127));
456        assert_eq!(parse_exit_code("D ; 130"), Some(130));
457    }
458
459    // ── attribution (the naturalize(Superlogical) delta) ──────────
460    //
461    // Superlogical's product unit is the terminal block. Ours was
462    // too, and was ANONYMOUS: an agent-run command and an
463    // operator-run one produced identical rows. These pin the
464    // field that makes a block history worth trusting.
465
466    /// Drive one full A→B→C→D block through the extractor.
467    fn one_block(ex: &mut BlockExtractor) {
468        ex.on_osc_133("A");
469        ex.on_osc_133("B");
470        for c in "echo hi".chars() {
471            ex.on_print(c);
472        }
473        ex.on_osc_133("C");
474        ex.on_osc_133("D;0");
475    }
476
477    #[test]
478    fn an_unstamped_extractor_mints_unknown_never_human() {
479        let mut ex = BlockExtractor::new(8);
480        one_block(&mut ex);
481        assert_eq!(
482            ex.get(0).unwrap().yurai,
483            tear_types::Yurai::Unknown,
484            "an unattributed block must stay Unknown — defaulting to Human \
485             would launder every agent-run command in the history"
486        );
487    }
488
489    #[test]
490    fn a_stamped_extractor_marks_every_block_it_mints() {
491        let mut ex = BlockExtractor::new(8);
492        assert!(ex.stamp_yurai(tear_types::Yurai::Automation {
493            label: Some("claude-code".into())
494        }));
495        one_block(&mut ex);
496        one_block(&mut ex);
497        for i in 0..2 {
498            assert!(
499                ex.get(i).unwrap().yurai.is_automation(),
500                "block {i} lost its attribution"
501            );
502        }
503    }
504
505    /// The write-once rule, and why it is not merely tidiness: a
506    /// re-stampable field would let an agent-spawned pane relabel
507    /// itself `Human` mid-session, retroactively laundering every
508    /// block after that point.
509    #[test]
510    fn re_stamping_is_refused_so_provenance_cannot_drift() {
511        let mut ex = BlockExtractor::new(8);
512        assert!(ex.stamp_yurai(tear_types::Yurai::Automation { label: None }));
513        assert!(
514            !ex.stamp_yurai(tear_types::Yurai::Human),
515            "the second stamp must be REFUSED, not applied"
516        );
517        one_block(&mut ex);
518        assert!(
519            ex.get(0).unwrap().yurai.is_automation(),
520            "an agent pane must not be able to relabel itself human"
521        );
522    }
523
524    /// A block written before this field existed decodes as
525    /// `Unknown` — which is what its absence honestly means.
526    /// Same discipline as `Yurai`'s own pre-field decode test.
527    #[test]
528    fn a_pre_attribution_block_decodes_as_unknown() {
529        let legacy = r#"{
530            "index": 0, "prompt": "$ ", "command": "ls", "output": "a\n",
531            "exit_code": 0, "started_at_unix_ms": 1, "ended_at_unix_ms": 2
532        }"#;
533        let b: Block = serde_json::from_str(legacy).expect("legacy block must still decode");
534        assert_eq!(b.yurai, tear_types::Yurai::Unknown);
535        assert_eq!(b.cwd, None);
536    }
537}