Skip to main content

vtcode_commons/ui_protocol/
input.rs

1//! Shared input protocol types.
2//!
3//! `ContentPart` and `SubmittedInput` are defined here (moved from
4//! `vtcode-ui`) so headless `vtcode-core` builds share one type identity
5//! with the TUI instead of stubbing parallel definitions.
6
7use std::ops::Deref;
8
9/// Structured input content used by the TUI input/history systems.
10///
11/// `Image` data is boxed (base64 payloads dwarf every other variant) so a
12/// `Vec<ContentPart>` of text parts does not pay the inline footprint.
13#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14#[serde(tag = "type", rename_all = "snake_case")]
15pub enum ContentPart {
16    Text { text: String },
17    Image { data: Box<str>, media_type: String },
18}
19
20impl ContentPart {
21    pub fn text(text: impl Into<String>) -> Self {
22        Self::Text { text: text.into() }
23    }
24
25    pub fn image(data: impl Into<String>, media_type: impl Into<String>) -> Self {
26        Self::Image {
27            data: data.into().into_boxed_str(),
28            media_type: media_type.into(),
29        }
30    }
31
32    pub fn is_image(&self) -> bool {
33        matches!(self, Self::Image { .. })
34    }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct SubmittedInput {
39    pub text: String,
40    pub attachments: Vec<ContentPart>,
41    /// Whether this submission may be merged with other queued text-only
42    /// submissions of the same agent into a single model turn. Ctrl+Enter and
43    /// Tab set this when they queue plain text while busy; busy plain-Enter
44    /// queues as its own non-batchable turn and slash commands stay a
45    /// one-per-turn dispatch.
46    pub batchable: bool,
47}
48
49impl SubmittedInput {
50    pub fn new(text: impl Into<String>, attachments: Vec<ContentPart>) -> Self {
51        Self { text: text.into(), attachments, batchable: false }
52    }
53
54    fn text_only(text: impl Into<String>) -> Self {
55        Self::new(text, Vec::new())
56    }
57
58    /// Mark this submission as eligible for queue batching (Ctrl+Enter).
59    pub fn batchable(mut self) -> Self {
60        self.batchable = true;
61        self
62    }
63
64    pub fn trim_text(self) -> Self {
65        Self {
66            text: self.text.trim().to_string(),
67            attachments: self.attachments,
68            batchable: self.batchable,
69        }
70    }
71
72    pub fn is_empty(&self) -> bool {
73        self.text.trim().is_empty() && self.attachments.is_empty()
74    }
75
76    pub fn has_attachments(&self) -> bool {
77        !self.attachments.is_empty()
78    }
79
80    /// Whether this submission may steer a live turn: plain text only.
81    /// Slash commands are runloop actions and the steering channel is
82    /// text-only, so those stay queued.
83    pub fn is_steerable_text(&self) -> bool {
84        !self.has_attachments() && !self.text.trim_start().starts_with('/')
85    }
86}
87
88impl From<String> for SubmittedInput {
89    fn from(text: String) -> Self {
90        Self::text_only(text)
91    }
92}
93
94impl From<&str> for SubmittedInput {
95    fn from(text: &str) -> Self {
96        Self::text_only(text)
97    }
98}
99
100impl Deref for SubmittedInput {
101    type Target = str;
102
103    fn deref(&self) -> &Self::Target {
104        &self.text
105    }
106}
107
108impl PartialEq<&str> for SubmittedInput {
109    fn eq(&self, other: &&str) -> bool {
110        self.text == *other
111    }
112}
113
114impl PartialEq<&str> for &SubmittedInput {
115    fn eq(&self, other: &&str) -> bool {
116        self.text == *other
117    }
118}
119
120impl PartialEq<String> for SubmittedInput {
121    fn eq(&self, other: &String) -> bool {
122        self.text == *other
123    }
124}
125
126impl PartialEq<&String> for SubmittedInput {
127    fn eq(&self, other: &&String) -> bool {
128        self.text == **other
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn steerable_text_matrix() {
138        assert!(SubmittedInput::from("run tests").is_steerable_text());
139        assert!(!SubmittedInput::from("/compact now").is_steerable_text());
140        assert!(!SubmittedInput::from("  /compact now").is_steerable_text());
141        assert!(
142            !SubmittedInput::new("describe this", vec![ContentPart::image("aGVsbG8=", "image/png")])
143                .is_steerable_text()
144        );
145    }
146
147    #[test]
148    fn content_part_constructors_keep_shape() {
149        let text = ContentPart::text("hello");
150        assert!(!text.is_image());
151        assert!(matches!(text, ContentPart::Text { .. }));
152
153        let image = ContentPart::image("aGVsbG8=", "image/png");
154        assert!(image.is_image());
155        assert!(matches!(
156            image,
157            ContentPart::Image { ref media_type, .. } if media_type == "image/png"
158        ));
159    }
160
161    #[test]
162    fn batchable_and_trim_roundtrip() {
163        let input = SubmittedInput::from("  padded  ").batchable().trim_text();
164        assert!(input.batchable);
165        assert_eq!(input.text, "padded");
166        assert_eq!(&*input, "padded");
167    }
168}