Skip to main content

beyonder_core/
block.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4use ulid::Ulid;
5
6use crate::{AgentId, ProvenanceChain, SessionId, UnderlineStyle};
7
8/// Unique, time-sortable block identifier.
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct BlockId(pub String);
11
12impl BlockId {
13    pub fn new() -> Self {
14        Self(Ulid::new().to_string())
15    }
16}
17
18impl Default for BlockId {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl std::fmt::Display for BlockId {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(f, "{}", self.0)
27    }
28}
29
30/// A Block is the fundamental unit of content in Beyonder.
31/// Replaces the traditional scroll buffer — every piece of content
32/// (shell output, agent messages, approvals, diffs) is a Block.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Block {
35    pub id: BlockId,
36    pub kind: BlockKind,
37    pub parent_id: Option<BlockId>,
38    pub agent_id: Option<AgentId>,
39    pub session_id: SessionId,
40    pub created_at: DateTime<Utc>,
41    pub updated_at: DateTime<Utc>,
42    pub status: BlockStatus,
43    pub content: BlockContent,
44    pub provenance: ProvenanceChain,
45}
46
47impl Block {
48    pub fn new(kind: BlockKind, session_id: SessionId, content: BlockContent) -> Self {
49        let now = Utc::now();
50        Self {
51            id: BlockId::new(),
52            kind,
53            parent_id: None,
54            agent_id: None,
55            session_id,
56            created_at: now,
57            updated_at: now,
58            status: BlockStatus::Pending,
59            content,
60            provenance: ProvenanceChain::default(),
61        }
62    }
63
64    pub fn with_agent(mut self, agent_id: AgentId) -> Self {
65        self.agent_id = Some(agent_id);
66        self
67    }
68
69    pub fn with_parent(mut self, parent_id: BlockId) -> Self {
70        self.parent_id = Some(parent_id);
71        self
72    }
73}
74
75/// Categorizes what produced this block.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub enum BlockKind {
78    Human,
79    Agent,
80    System,
81    Tool,
82    Approval,
83}
84
85/// Lifecycle status of a block.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub enum BlockStatus {
88    Pending,
89    Running,
90    Completed,
91    Failed,
92    Cancelled,
93}
94
95/// The structured content of a block.
96/// This replaces unstructured text streams — every piece of content
97/// has a typed representation that the terminal can render and reason about.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(tag = "type", rename_all = "snake_case")]
100pub enum BlockContent {
101    /// Output from a shell command execution.
102    ShellCommand {
103        input: String,
104        output: TerminalOutput,
105        exit_code: Option<i32>,
106        cwd: PathBuf,
107        duration_ms: Option<u64>,
108    },
109
110    /// A message from an AI agent (ACP content blocks).
111    AgentMessage {
112        role: MessageRole,
113        content_blocks: Vec<ContentBlock>,
114    },
115
116    /// An agent's tool call and its result.
117    ToolCall {
118        tool_name: String,
119        tool_use_id: String,
120        input: serde_json::Value,
121        output: Option<String>,
122        streaming_text: Option<String>,
123        error: Option<String>,
124        collapsed_default: bool,
125    },
126
127    /// A permission request that the human must approve or deny.
128    ApprovalRequest {
129        action: AgentAction,
130        reasoning: Option<String>,
131        granted: Option<bool>,
132        granter: Option<ActorId>,
133    },
134
135    /// A file edit proposed by an agent.
136    FileEdit {
137        path: PathBuf,
138        diff: UnifiedDiff,
139        applied: bool,
140    },
141
142    /// An agent's structured plan.
143    PlanNode {
144        description: String,
145        subtask_ids: Vec<BlockId>,
146        progress: f32,
147        is_complete: bool,
148    },
149
150    /// Plain text (e.g., system messages, banners).
151    Text { text: String },
152}
153
154/// Parsed terminal output preserving ANSI color/style metadata.
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156pub struct TerminalOutput {
157    /// Each cell is a character with optional styling.
158    pub rows: Vec<TerminalRow>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct TerminalRow {
163    pub cells: Vec<TerminalCell>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct TerminalCell {
168    #[serde(
169        default,
170        alias = "character",
171        deserialize_with = "deser_grapheme_compat"
172    )]
173    pub grapheme: String,
174    pub fg: Option<Color>,
175    pub bg: Option<Color>,
176    pub bold: bool,
177    pub italic: bool,
178    #[serde(default, deserialize_with = "deser_underline_compat")]
179    pub underline: UnderlineStyle,
180    #[serde(default)]
181    pub strikethrough: bool,
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub link: Option<String>,
184}
185
186/// Accepts either a single `char` (legacy format) or a `String` grapheme cluster.
187fn deser_grapheme_compat<'de, D>(deserializer: D) -> Result<String, D::Error>
188where
189    D: serde::Deserializer<'de>,
190{
191    use serde::Deserialize;
192    #[derive(Deserialize)]
193    #[serde(untagged)]
194    enum Either {
195        Ch(char),
196        Str(String),
197    }
198    match Either::deserialize(deserializer)? {
199        Either::Ch(c) => Ok(c.to_string()),
200        Either::Str(s) => Ok(s),
201    }
202}
203
204/// Backward-compatible deserializer: accepts either the legacy `bool` form
205/// (old DB rows where `underline` was a plain flag) or the new enum form.
206fn deser_underline_compat<'de, D>(deserializer: D) -> Result<UnderlineStyle, D::Error>
207where
208    D: serde::Deserializer<'de>,
209{
210    use serde::Deserialize;
211    #[derive(Deserialize)]
212    #[serde(untagged)]
213    enum Either {
214        Bool(bool),
215        Style(UnderlineStyle),
216    }
217    match Either::deserialize(deserializer)? {
218        Either::Bool(true) => Ok(UnderlineStyle::Single),
219        Either::Bool(false) => Ok(UnderlineStyle::None),
220        Either::Style(s) => Ok(s),
221    }
222}
223
224/// 24-bit RGB color.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226pub struct Color {
227    pub r: u8,
228    pub g: u8,
229    pub b: u8,
230}
231
232impl Color {
233    pub const WHITE: Self = Self {
234        r: 255,
235        g: 255,
236        b: 255,
237    };
238    pub const BLACK: Self = Self { r: 0, g: 0, b: 0 };
239    pub const GREEN: Self = Self {
240        r: 80,
241        g: 200,
242        b: 120,
243    };
244    pub const RED: Self = Self {
245        r: 220,
246        g: 70,
247        b: 70,
248    };
249    pub const BLUE: Self = Self {
250        r: 80,
251        g: 140,
252        b: 220,
253    };
254    pub const YELLOW: Self = Self {
255        r: 220,
256        g: 200,
257        b: 60,
258    };
259    pub const CYAN: Self = Self {
260        r: 80,
261        g: 200,
262        b: 200,
263    };
264    pub const GRAY: Self = Self {
265        r: 150,
266        g: 150,
267        b: 150,
268    };
269
270    pub fn to_wgpu(&self) -> [f32; 3] {
271        [
272            self.r as f32 / 255.0,
273            self.g as f32 / 255.0,
274            self.b as f32 / 255.0,
275        ]
276    }
277}
278
279/// ACP content block types for agent messages.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281#[serde(tag = "type", rename_all = "snake_case")]
282pub enum ContentBlock {
283    Text {
284        text: String,
285    },
286    Code {
287        language: Option<String>,
288        code: String,
289    },
290    Thinking {
291        thinking: String,
292    },
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub enum MessageRole {
297    User,
298    Assistant,
299    System,
300}
301
302/// An action an agent wants to take — used in ApprovalRequest blocks.
303#[derive(Debug, Clone, Serialize, Deserialize)]
304#[serde(tag = "kind", rename_all = "snake_case")]
305pub enum AgentAction {
306    FileWrite {
307        path: PathBuf,
308        content_preview: Option<String>,
309    },
310    FileRead {
311        path: PathBuf,
312    },
313    FileDelete {
314        path: PathBuf,
315    },
316    ShellExecute {
317        command: String,
318    },
319    NetworkRequest {
320        url: String,
321        method: String,
322    },
323    AgentSpawn {
324        agent_name: String,
325    },
326    ToolUse {
327        tool_name: String,
328    },
329}
330
331/// A unified diff representing a file change.
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct UnifiedDiff {
334    pub old_path: Option<PathBuf>,
335    pub new_path: Option<PathBuf>,
336    pub hunks: Vec<DiffHunk>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct DiffHunk {
341    pub old_start: u32,
342    pub old_lines: u32,
343    pub new_start: u32,
344    pub new_lines: u32,
345    pub lines: Vec<DiffLine>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct DiffLine {
350    pub kind: DiffLineKind,
351    pub content: String,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub enum DiffLineKind {
356    Context,
357    Added,
358    Removed,
359}
360
361/// An actor in the system — either a human user or an agent.
362#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
363#[serde(tag = "kind", rename_all = "snake_case")]
364pub enum ActorId {
365    Human,
366    Agent { id: AgentId },
367}