1use core::fmt;
36
37pub const EVENT_SCHEMA_VERSION: &str = "1.0.0";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
47pub enum HostEventClass {
48 AttachTransition,
50 InputAccessibility,
52 InputComposition,
54 InputCompositionTrace,
56 InputFocus,
58 InputKey,
60 InputMouse,
62 InputPaste,
64 InputTouch,
66 InputVtBytes,
68 InputWheel,
70 TerminalProgress,
72 TerminalReplyBytes,
74 UiAccessibilityAnnouncement,
76 UiLinkClick,
78}
79
80impl HostEventClass {
81 pub const ALL: [HostEventClass; 15] = [
83 HostEventClass::AttachTransition,
84 HostEventClass::InputAccessibility,
85 HostEventClass::InputComposition,
86 HostEventClass::InputCompositionTrace,
87 HostEventClass::InputFocus,
88 HostEventClass::InputKey,
89 HostEventClass::InputMouse,
90 HostEventClass::InputPaste,
91 HostEventClass::InputTouch,
92 HostEventClass::InputVtBytes,
93 HostEventClass::InputWheel,
94 HostEventClass::TerminalProgress,
95 HostEventClass::TerminalReplyBytes,
96 HostEventClass::UiAccessibilityAnnouncement,
97 HostEventClass::UiLinkClick,
98 ];
99
100 #[must_use]
102 pub const fn as_str(self) -> &'static str {
103 match self {
104 HostEventClass::AttachTransition => "attach.transition",
105 HostEventClass::InputAccessibility => "input.accessibility",
106 HostEventClass::InputComposition => "input.composition",
107 HostEventClass::InputCompositionTrace => "input.composition_trace",
108 HostEventClass::InputFocus => "input.focus",
109 HostEventClass::InputKey => "input.key",
110 HostEventClass::InputMouse => "input.mouse",
111 HostEventClass::InputPaste => "input.paste",
112 HostEventClass::InputTouch => "input.touch",
113 HostEventClass::InputVtBytes => "input.vt_bytes",
114 HostEventClass::InputWheel => "input.wheel",
115 HostEventClass::TerminalProgress => "terminal.progress",
116 HostEventClass::TerminalReplyBytes => "terminal.reply_bytes",
117 HostEventClass::UiAccessibilityAnnouncement => "ui.accessibility_announcement",
118 HostEventClass::UiLinkClick => "ui.link_click",
119 }
120 }
121
122 #[must_use]
124 pub fn namespace(self) -> &'static str {
125 let s = self.as_str();
126 match s.split_once('.') {
127 Some((ns, _)) => ns,
128 None => s,
129 }
130 }
131
132 #[must_use]
134 pub fn from_wire(value: &str) -> Option<Self> {
135 Self::ALL.into_iter().find(|c| c.as_str() == value)
136 }
137}
138
139impl fmt::Display for HostEventClass {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 f.write_str(self.as_str())
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
153pub enum SdkErrorKind {
154 AttachProtocol,
156 CapabilityUnsupported,
158 ClipboardDisabled,
160 ClipboardPasteTooLarge,
162 InputParse,
164 QueueOverflow,
166 TerminalProgressMalformed,
168 UiLinkBlocked,
170}
171
172impl SdkErrorKind {
173 pub const ALL: [SdkErrorKind; 8] = [
175 SdkErrorKind::AttachProtocol,
176 SdkErrorKind::CapabilityUnsupported,
177 SdkErrorKind::ClipboardDisabled,
178 SdkErrorKind::ClipboardPasteTooLarge,
179 SdkErrorKind::InputParse,
180 SdkErrorKind::QueueOverflow,
181 SdkErrorKind::TerminalProgressMalformed,
182 SdkErrorKind::UiLinkBlocked,
183 ];
184
185 #[must_use]
187 pub const fn code(self) -> &'static str {
188 match self {
189 SdkErrorKind::AttachProtocol => "attach.protocol_error",
190 SdkErrorKind::CapabilityUnsupported => "capability.unsupported",
191 SdkErrorKind::ClipboardDisabled => "clipboard.disabled",
192 SdkErrorKind::ClipboardPasteTooLarge => "clipboard.paste_too_large",
193 SdkErrorKind::InputParse => "input.parse",
194 SdkErrorKind::QueueOverflow => "queue.overflow",
195 SdkErrorKind::TerminalProgressMalformed => "terminal.progress.malformed",
196 SdkErrorKind::UiLinkBlocked => "ui.link.blocked",
197 }
198 }
199
200 #[must_use]
202 pub const fn summary(self) -> &'static str {
203 match self {
204 SdkErrorKind::AttachProtocol => "websocket attach protocol error",
205 SdkErrorKind::CapabilityUnsupported => {
206 "requested capability is not advertised by the engine"
207 }
208 SdkErrorKind::ClipboardDisabled => "clipboard access disabled by host policy",
209 SdkErrorKind::ClipboardPasteTooLarge => {
210 "paste payload exceeds the maximum allowed size"
211 }
212 SdkErrorKind::InputParse => "host-encoded input payload could not be parsed",
213 SdkErrorKind::QueueOverflow => "a bounded host queue dropped its oldest entry",
214 SdkErrorKind::TerminalProgressMalformed => "malformed OSC 9;4 progress payload",
215 SdkErrorKind::UiLinkBlocked => "hyperlink activation blocked by link-open policy",
216 }
217 }
218
219 #[must_use]
221 pub fn from_code(value: &str) -> Option<Self> {
222 Self::ALL.into_iter().find(|k| k.code() == value)
223 }
224}
225
226impl fmt::Display for SdkErrorKind {
227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228 f.write_str(self.code())
229 }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub struct EventBufferPolicy {
236 pub encoded_inputs_queue_max: u32,
238 pub encoded_input_bytes_queue_max: u32,
240 pub ime_trace_queue_max: u32,
242 pub link_click_queue_max: u32,
244 pub accessibility_announcement_queue_max: u32,
246 pub attach_transition_queue_max: u32,
248 pub event_subscription_queue_default_max: u32,
250 pub event_subscription_queue_configurable_max: u32,
252 pub event_subscription_registry_max: u32,
254}
255
256impl EventBufferPolicy {
257 pub const DEFAULT: EventBufferPolicy = EventBufferPolicy {
259 encoded_inputs_queue_max: 4096,
260 encoded_input_bytes_queue_max: 4096,
261 ime_trace_queue_max: 2048,
262 link_click_queue_max: 2048,
263 accessibility_announcement_queue_max: 64,
264 attach_transition_queue_max: 512,
265 event_subscription_queue_default_max: 512,
266 event_subscription_queue_configurable_max: 8192,
267 event_subscription_registry_max: 256,
268 };
269
270 #[must_use]
273 pub fn entries(self) -> [(&'static str, u32); 9] {
274 [
275 ("encoded_inputs_queue_max", self.encoded_inputs_queue_max),
276 (
277 "encoded_input_bytes_queue_max",
278 self.encoded_input_bytes_queue_max,
279 ),
280 ("ime_trace_queue_max", self.ime_trace_queue_max),
281 ("link_click_queue_max", self.link_click_queue_max),
282 (
283 "accessibility_announcement_queue_max",
284 self.accessibility_announcement_queue_max,
285 ),
286 (
287 "attach_transition_queue_max",
288 self.attach_transition_queue_max,
289 ),
290 (
291 "event_subscription_queue_default_max",
292 self.event_subscription_queue_default_max,
293 ),
294 (
295 "event_subscription_queue_configurable_max",
296 self.event_subscription_queue_configurable_max,
297 ),
298 (
299 "event_subscription_registry_max",
300 self.event_subscription_registry_max,
301 ),
302 ]
303 }
304}
305
306fn to_lower_camel(snake: &str) -> String {
308 let mut out = String::with_capacity(snake.len());
309 let mut upper_next = false;
310 for ch in snake.chars() {
311 if ch == '_' {
312 upper_next = true;
313 } else if upper_next {
314 out.extend(ch.to_uppercase());
315 upper_next = false;
316 } else {
317 out.push(ch);
318 }
319 }
320 out
321}
322
323#[must_use]
330pub fn typescript_definitions() -> String {
331 let mut out = String::new();
332 out.push_str(
333 "// AUTO-GENERATED — do not edit by hand.\n\
334 // Source of truth: crates/ftui-web/src/sdk_event_model.rs\n\
335 // (mirrors docs/spec/frankenterm-web-api.md). Regenerate with:\n\
336 // FTUI_SDK_DTS_BLESS=1 cargo test -p ftui-web \\\n\
337 // --test frankenterm_js_sdk_contract_compat\n\
338 //\n\
339 // FrankenTermJS SDK event/error model (bd-2vr05.9.2).\n\n",
340 );
341
342 out.push_str(&format!(
343 "export const EVENT_SCHEMA_VERSION = \"{EVENT_SCHEMA_VERSION}\";\n\n"
344 ));
345
346 out.push_str("/** Canonical host-observable event classes. */\n");
347 out.push_str("export type HostEventClass =\n");
348 for (idx, class) in HostEventClass::ALL.iter().enumerate() {
349 let last = idx + 1 == HostEventClass::ALL.len();
350 let term = if last { ";" } else { "" };
351 out.push_str(&format!(" | \"{}\"{term}\n", class.as_str()));
352 }
353 out.push('\n');
354
355 out.push_str("/** A single host-observable event. */\n");
356 out.push_str("export interface HostEvent {\n");
357 out.push_str(" readonly type: HostEventClass;\n");
358 out.push_str(" /** Globally monotonic sequence number. */\n");
359 out.push_str(" readonly seq: number;\n");
360 out.push_str(" /** Event-class-specific payload. */\n");
361 out.push_str(" readonly payload?: unknown;\n");
362 out.push_str("}\n\n");
363
364 out.push_str("/** Stable SDK error codes. */\n");
365 out.push_str("export type SdkErrorCode =\n");
366 for (idx, kind) in SdkErrorKind::ALL.iter().enumerate() {
367 let last = idx + 1 == SdkErrorKind::ALL.len();
368 let term = if last { ";" } else { "" };
369 out.push_str(&format!(" | \"{}\"{term}\n", kind.code()));
370 }
371 out.push('\n');
372
373 out.push_str("/** A typed SDK error. */\n");
374 out.push_str("export interface SdkError {\n");
375 out.push_str(" readonly code: SdkErrorCode;\n");
376 out.push_str(" readonly message: string;\n");
377 out.push_str("}\n\n");
378
379 out.push_str("/** Bounded host-queue policy (drop-oldest). */\n");
380 out.push_str("export interface EventBufferPolicy {\n");
381 for (name, _) in EventBufferPolicy::DEFAULT.entries() {
382 out.push_str(&format!(" readonly {}: number;\n", to_lower_camel(name)));
383 }
384 out.push_str("}\n\n");
385
386 out.push_str("export const EVENT_BUFFER_POLICY: EventBufferPolicy = {\n");
387 for (name, value) in EventBufferPolicy::DEFAULT.entries() {
388 out.push_str(&format!(" {}: {value},\n", to_lower_camel(name)));
389 }
390 out.push_str("};\n");
391
392 out
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn taxonomy_is_unique_sorted_and_round_trips() {
401 let wires: Vec<&str> = HostEventClass::ALL.iter().map(|c| c.as_str()).collect();
402 let mut sorted = wires.clone();
403 sorted.sort_unstable();
404 sorted.dedup();
405 assert_eq!(wires, sorted, "taxonomy must be unique and sorted");
406 for class in HostEventClass::ALL {
407 assert_eq!(HostEventClass::from_wire(class.as_str()), Some(class));
408 }
409 assert_eq!(HostEventClass::from_wire("nope"), None);
410 }
411
412 #[test]
413 fn error_codes_are_unique_sorted_and_round_trip() {
414 let codes: Vec<&str> = SdkErrorKind::ALL.iter().map(|k| k.code()).collect();
415 let mut sorted = codes.clone();
416 sorted.sort_unstable();
417 sorted.dedup();
418 assert_eq!(codes, sorted, "error codes must be unique and sorted");
419 for kind in SdkErrorKind::ALL {
420 assert_eq!(SdkErrorKind::from_code(kind.code()), Some(kind));
421 assert!(!kind.summary().is_empty());
422 }
423 }
424
425 #[test]
426 fn typescript_definitions_are_deterministic_and_complete() {
427 let a = typescript_definitions();
428 let b = typescript_definitions();
429 assert_eq!(a, b, "generator must be deterministic");
430 for class in HostEventClass::ALL {
431 assert!(
432 a.contains(&format!("\"{}\"", class.as_str())),
433 "d.ts missing event class {}",
434 class.as_str()
435 );
436 }
437 for kind in SdkErrorKind::ALL {
438 assert!(
439 a.contains(&format!("\"{}\"", kind.code())),
440 "d.ts missing error code {}",
441 kind.code()
442 );
443 }
444 }
445
446 #[test]
447 fn lower_camel_conversion() {
448 assert_eq!(
449 to_lower_camel("encoded_inputs_queue_max"),
450 "encodedInputsQueueMax"
451 );
452 assert_eq!(to_lower_camel("seq"), "seq");
453 }
454}