Skip to main content

supercode_frontend_model/
composer.rs

1use std::collections::HashSet;
2use std::collections::VecDeque;
3use std::time::Instant;
4
5use serde_json::Value;
6use supercode::FrontendApprovalDecision;
7use supercode::FrontendElicitationAction;
8use supercode::FrontendEvent;
9use supercode::FrontendOperationInvocation;
10use supercode::FrontendRequest;
11use supercode::FrontendRequestKind;
12use supercode::FrontendResponse;
13use supercode::FrontendRuntimeDescriptor;
14use supercode::FrontendTurnState;
15use unicode_segmentation::UnicodeSegmentation;
16
17use crate::paste_burst::PasteBurst;
18use crate::semantic_value_summary;
19
20use crate::ComposerCapabilities;
21
22/// Renderer-independent key vocabulary accepted by the semantic composer.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum ComposerKeyCode {
25    Char(char),
26    Enter,
27    Backspace,
28    Delete,
29    Left,
30    Right,
31    Home,
32    End,
33    Up,
34    Down,
35    Escape,
36    Other,
37}
38
39/// One framework-neutral key atom.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub struct ComposerKey {
42    pub code: ComposerKeyCode,
43    pub control: bool,
44    pub shift: bool,
45    pub alt: bool,
46    pub release: bool,
47}
48
49/// Input atoms used by terminal, browser, and donor-derived clients.
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub enum ComposerInputEvent {
52    Key(ComposerKey),
53    Paste(String),
54    Resize { width: u16, height: u16 },
55    FocusChanged(bool),
56}
57
58/// Immediate local turn state. A submit changes this before any runtime await.
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum ComposerTurnState {
61    Idle,
62    Working { since: Instant },
63}
64
65/// Generic overlay category. Child approvals remain typed approvals on the
66/// wire; the child distinction is presentation metadata from the payload.
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum ComposerOverlayKind {
69    Approval,
70    ChildApproval,
71    Elicitation,
72    Other,
73}
74
75/// One SDK-owned request displayed by the composer.
76#[derive(Clone, Debug, PartialEq)]
77pub struct ComposerOverlay {
78    pub request: FrontendRequest,
79    pub kind: ComposerOverlayKind,
80    pub input: String,
81    cursor: usize,
82}
83
84impl ComposerOverlay {
85    pub fn summary(&self) -> String {
86        let label = match self.kind {
87            ComposerOverlayKind::Approval | ComposerOverlayKind::ChildApproval => {
88                "Approval request"
89            }
90            ComposerOverlayKind::Elicitation => "MCP input request",
91            ComposerOverlayKind::Other => "Runtime input request",
92        };
93        semantic_value_summary(&self.request.payload, label, 512)
94    }
95}
96
97/// Runtime action produced by one editor operation.
98#[derive(Clone, Debug, PartialEq)]
99pub enum ComposerAction {
100    Submit(String),
101    Invoke(FrontendOperationInvocation),
102    Steer(String),
103    Interrupt,
104    Respond {
105        response: FrontendResponse,
106        request: FrontendRequest,
107        resolution: String,
108    },
109}
110
111/// Pure composer state. It owns no runtime and performs no I/O.
112#[derive(Clone, Debug)]
113pub struct ComposerModel {
114    capabilities: ComposerCapabilities,
115    input: String,
116    cursor: usize,
117    history: Vec<String>,
118    history_index: Option<usize>,
119    history_draft: Option<String>,
120    queued_steering: VecDeque<String>,
121    turn_state: ComposerTurnState,
122    overlays: VecDeque<ComposerOverlay>,
123    paste_burst: PasteBurst,
124    resolved_requests: HashSet<u64>,
125    canonical_resolved_requests: HashSet<u64>,
126    last_sequence: u64,
127    last_failure: Option<String>,
128}
129
130impl ComposerModel {
131    pub fn new(descriptor: &FrontendRuntimeDescriptor) -> Self {
132        Self {
133            capabilities: ComposerCapabilities::from_descriptor(descriptor),
134            input: String::new(),
135            cursor: 0,
136            history: Vec::new(),
137            history_index: None,
138            history_draft: None,
139            queued_steering: VecDeque::new(),
140            turn_state: match descriptor.turn_state {
141                FrontendTurnState::Idle => ComposerTurnState::Idle,
142                FrontendTurnState::Busy => ComposerTurnState::Working {
143                    since: Instant::now(),
144                },
145            },
146            overlays: VecDeque::new(),
147            paste_burst: PasteBurst::default(),
148            resolved_requests: HashSet::new(),
149            canonical_resolved_requests: HashSet::new(),
150            last_sequence: 0,
151            last_failure: None,
152        }
153    }
154
155    pub fn capabilities(&self) -> &ComposerCapabilities {
156        &self.capabilities
157    }
158
159    pub fn input(&self) -> &str {
160        &self.input
161    }
162
163    pub fn cursor(&self) -> usize {
164        self.cursor
165    }
166
167    pub fn history(&self) -> &[String] {
168        &self.history
169    }
170
171    pub fn queued_steering(&self) -> &VecDeque<String> {
172        &self.queued_steering
173    }
174
175    pub fn turn_state(&self) -> ComposerTurnState {
176        self.turn_state
177    }
178
179    pub fn overlay(&self) -> Option<&ComposerOverlay> {
180        self.overlays.front()
181    }
182
183    pub fn pending_overlay_count(&self) -> usize {
184        self.overlays.len()
185    }
186
187    pub fn last_failure(&self) -> Option<&str> {
188        self.last_failure.as_deref()
189    }
190
191    pub fn record_dispatch_failure(&mut self, action: &str) {
192        self.turn_state = ComposerTurnState::Idle;
193        self.last_failure = Some(format!("{action}_failure"));
194    }
195
196    pub fn restore_request(&mut self, request: FrontendRequest) -> bool {
197        if self.canonical_resolved_requests.contains(&request.id) {
198            return false;
199        }
200        self.resolved_requests.remove(&request.id);
201        if self
202            .overlays
203            .iter()
204            .any(|overlay| overlay.request.id == request.id)
205        {
206            return false;
207        }
208        let kind = overlay_kind(&request);
209        self.overlays.push_front(ComposerOverlay {
210            request,
211            kind,
212            input: String::new(),
213            cursor: 0,
214        });
215        true
216    }
217
218    /// Refresh capability declarations. An observed busy state may promote
219    /// the local state, but a potentially stale idle descriptor never clears
220    /// an optimistic submit; terminal events own that transition.
221    pub fn refresh_descriptor(&mut self, descriptor: &FrontendRuntimeDescriptor) {
222        self.capabilities = ComposerCapabilities::from_descriptor(descriptor);
223        if descriptor.turn_state == FrontendTurnState::Busy
224            && self.turn_state == ComposerTurnState::Idle
225        {
226            self.turn_state = ComposerTurnState::Working {
227                since: Instant::now(),
228            };
229        }
230    }
231
232    /// Apply a non-duplicate SDK event to local working/overlay state.
233    pub fn apply_event(&mut self, event: &FrontendEvent) -> bool {
234        if event.sequence <= self.last_sequence {
235            return false;
236        }
237        self.last_sequence = event.sequence;
238
239        match event.kind.as_str() {
240            "turn_started" | "user_message" if self.turn_state == ComposerTurnState::Idle => {
241                self.turn_state = ComposerTurnState::Working {
242                    since: Instant::now(),
243                };
244                true
245            }
246            "turn_succeeded" | "turn_interrupted" | "turn_failed" => {
247                self.turn_state = ComposerTurnState::Idle;
248                self.queued_steering.clear();
249                if event.kind == "turn_failed" {
250                    self.last_failure = Some(event.kind.clone());
251                }
252                true
253            }
254            "stream_error" | "transport_error" | "watch_error" | "runtime_disconnected" => {
255                self.turn_state = ComposerTurnState::Idle;
256                self.last_failure = Some(event.kind.clone());
257                true
258            }
259            "request" => self.open_request(event),
260            "request_resolved" => self.apply_request_resolution(event),
261            _ => false,
262        }
263    }
264
265    fn apply_request_resolution(&mut self, event: &FrontendEvent) -> bool {
266        let Some(request_id) = event.payload.get("request_id").and_then(Value::as_u64) else {
267            return false;
268        };
269        let previous = self.overlays.len();
270        self.overlays
271            .retain(|overlay| overlay.request.id != request_id);
272        self.resolved_requests.insert(request_id);
273        self.canonical_resolved_requests.insert(request_id);
274        self.overlays.len() != previous
275    }
276
277    /// Handle one key/paste atom. Bracketed paste is inserted atomically and
278    /// never interpreted as one command per line.
279    pub fn handle_input(&mut self, event: ComposerInputEvent) -> Option<ComposerAction> {
280        match event {
281            ComposerInputEvent::Key(key) => self.handle_key(key),
282            ComposerInputEvent::Paste(text) => {
283                self.paste_burst.clear_after_explicit_paste();
284                self.insert_text(&text);
285                None
286            }
287            ComposerInputEvent::Resize { .. } | ComposerInputEvent::FocusChanged(_) => None,
288        }
289    }
290
291    pub fn handle_key(&mut self, key: ComposerKey) -> Option<ComposerAction> {
292        self.handle_key_at(key, Instant::now())
293    }
294
295    fn handle_key_at(&mut self, key: ComposerKey, now: Instant) -> Option<ComposerAction> {
296        if key.release {
297            return None;
298        }
299        if !self.overlays.is_empty() {
300            self.paste_burst.clear_window_after_non_char();
301            return self.handle_overlay_key(key);
302        }
303
304        let control = key.control;
305        let shift = key.shift;
306        if let ComposerKeyCode::Char(character) = key.code {
307            if !control && !key.alt {
308                if self.paste_burst.on_plain_char_no_hold(now).is_some() {
309                    self.paste_burst.extend_window(now);
310                }
311                self.insert_char(character);
312                return None;
313            }
314        }
315        if key.code == ComposerKeyCode::Enter
316            && !shift
317            && !control
318            && self.paste_burst.direct_insert_newline_should_insert(now)
319        {
320            self.insert_text("\n");
321            self.paste_burst.extend_window(now);
322            return None;
323        }
324        self.paste_burst.clear_window_after_non_char();
325        match key.code {
326            ComposerKeyCode::Enter if shift || control => {
327                self.insert_text("\n");
328                None
329            }
330            ComposerKeyCode::Char('j') if control => {
331                self.insert_text("\n");
332                None
333            }
334            ComposerKeyCode::Enter => self.submit_or_steer(),
335            ComposerKeyCode::Char('c') if control => {
336                if matches!(self.turn_state, ComposerTurnState::Working { .. })
337                    && self.capabilities.can_interrupt
338                {
339                    Some(ComposerAction::Interrupt)
340                } else {
341                    self.clear_input();
342                    None
343                }
344            }
345            ComposerKeyCode::Backspace => {
346                self.backspace();
347                None
348            }
349            ComposerKeyCode::Delete => {
350                self.delete();
351                None
352            }
353            ComposerKeyCode::Left => {
354                self.cursor = previous_grapheme_boundary(&self.input, self.cursor);
355                None
356            }
357            ComposerKeyCode::Right => {
358                self.cursor = next_grapheme_boundary(&self.input, self.cursor);
359                None
360            }
361            ComposerKeyCode::Home => {
362                self.cursor = line_start(&self.input, self.cursor);
363                None
364            }
365            ComposerKeyCode::End => {
366                self.cursor = line_end(&self.input, self.cursor);
367                None
368            }
369            ComposerKeyCode::Up => {
370                self.history_previous();
371                None
372            }
373            ComposerKeyCode::Down => {
374                self.history_next();
375                None
376            }
377            _ => None,
378        }
379    }
380
381    pub fn set_input(&mut self, input: impl Into<String>) {
382        self.input = input.into();
383        self.cursor = self.input.len();
384        self.history_index = None;
385        self.history_draft = None;
386    }
387
388    fn submit_or_steer(&mut self) -> Option<ComposerAction> {
389        if self.input.trim().is_empty() {
390            return None;
391        }
392        let prompt = std::mem::take(&mut self.input);
393        self.cursor = 0;
394        self.history_index = None;
395        self.history_draft = None;
396        if self.history.last() != Some(&prompt) {
397            self.history.push(prompt.clone());
398        }
399
400        if self.turn_state == ComposerTurnState::Idle {
401            if let Some(operation) = self.capabilities.invocation_for_command(&prompt) {
402                self.turn_state = ComposerTurnState::Working {
403                    since: Instant::now(),
404                };
405                self.last_failure = None;
406                return Some(ComposerAction::Invoke(operation));
407            }
408        }
409
410        match self.turn_state {
411            ComposerTurnState::Idle if self.capabilities.can_submit => {
412                self.turn_state = ComposerTurnState::Working {
413                    since: Instant::now(),
414                };
415                self.last_failure = None;
416                Some(ComposerAction::Submit(prompt))
417            }
418            ComposerTurnState::Working { .. } if self.capabilities.can_steer => {
419                self.queued_steering.push_back(prompt.clone());
420                Some(ComposerAction::Steer(prompt))
421            }
422            _ => {
423                self.input = prompt;
424                self.cursor = self.input.len();
425                None
426            }
427        }
428    }
429
430    fn open_request(&mut self, event: &FrontendEvent) -> bool {
431        if !self.capabilities.can_respond {
432            return false;
433        }
434        let value = event
435            .payload
436            .get("request")
437            .unwrap_or(&event.payload)
438            .clone();
439        let Ok(request) = serde_json::from_value::<FrontendRequest>(value) else {
440            return false;
441        };
442        if self.resolved_requests.contains(&request.id)
443            || self
444                .overlays
445                .iter()
446                .any(|overlay| overlay.request.id == request.id)
447        {
448            return false;
449        }
450        let kind = overlay_kind(&request);
451        self.overlays.push_back(ComposerOverlay {
452            request,
453            kind,
454            input: String::new(),
455            cursor: 0,
456        });
457        true
458    }
459
460    fn handle_overlay_key(&mut self, key: ComposerKey) -> Option<ComposerAction> {
461        let kind = self.overlays.front()?.kind;
462        let control = key.control;
463        match kind {
464            ComposerOverlayKind::Approval | ComposerOverlayKind::ChildApproval => match key.code {
465                ComposerKeyCode::Char('y') => {
466                    self.resolve_approval(FrontendApprovalDecision::Allow)
467                }
468                ComposerKeyCode::Char('a') => {
469                    self.resolve_approval(FrontendApprovalDecision::AllowForSession)
470                }
471                ComposerKeyCode::Char('n') | ComposerKeyCode::Escape => {
472                    self.resolve_approval(FrontendApprovalDecision::Deny)
473                }
474                _ => None,
475            },
476            ComposerOverlayKind::Elicitation | ComposerOverlayKind::Other => match key.code {
477                ComposerKeyCode::Enter if !key.shift => {
478                    let content = self.overlays.front().map(|overlay| {
479                        if overlay.input.trim().is_empty() {
480                            None
481                        } else {
482                            serde_json::from_str(&overlay.input)
483                                .ok()
484                                .or_else(|| Some(Value::String(overlay.input.clone())))
485                        }
486                    })?;
487                    self.resolve_structured_request(FrontendElicitationAction::Accept, content)
488                }
489                ComposerKeyCode::Escape => {
490                    self.resolve_structured_request(FrontendElicitationAction::Cancel, None)
491                }
492                ComposerKeyCode::Char('c') if control => {
493                    self.resolve_structured_request(FrontendElicitationAction::Decline, None)
494                }
495                ComposerKeyCode::Char(character) if !control => {
496                    self.overlay_insert_char(character);
497                    None
498                }
499                ComposerKeyCode::Backspace => {
500                    self.overlay_backspace();
501                    None
502                }
503                ComposerKeyCode::Left => {
504                    let overlay = self.overlays.front_mut()?;
505                    overlay.cursor = previous_grapheme_boundary(&overlay.input, overlay.cursor);
506                    None
507                }
508                ComposerKeyCode::Right => {
509                    let overlay = self.overlays.front_mut()?;
510                    overlay.cursor = next_grapheme_boundary(&overlay.input, overlay.cursor);
511                    None
512                }
513                _ => None,
514            },
515        }
516    }
517
518    fn resolve_approval(&mut self, decision: FrontendApprovalDecision) -> Option<ComposerAction> {
519        let overlay = self.overlays.pop_front()?;
520        if !matches!(
521            overlay.kind,
522            ComposerOverlayKind::Approval | ComposerOverlayKind::ChildApproval
523        ) {
524            self.overlays.push_front(overlay);
525            return None;
526        }
527        self.resolved_requests.insert(overlay.request.id);
528        Some(ComposerAction::Respond {
529            response: FrontendResponse::Approval {
530                request_id: overlay.request.id,
531                decision,
532            },
533            request: overlay.request,
534            resolution: match decision {
535                FrontendApprovalDecision::Deny => "denied",
536                FrontendApprovalDecision::Allow => "allowed once",
537                FrontendApprovalDecision::AllowForSession => "allowed for session",
538            }
539            .into(),
540        })
541    }
542
543    fn resolve_structured_request(
544        &mut self,
545        action: FrontendElicitationAction,
546        content: Option<Value>,
547    ) -> Option<ComposerAction> {
548        let overlay = self.overlays.pop_front()?;
549        if !matches!(
550            overlay.kind,
551            ComposerOverlayKind::Elicitation | ComposerOverlayKind::Other
552        ) {
553            self.overlays.push_front(overlay);
554            return None;
555        }
556        self.resolved_requests.insert(overlay.request.id);
557        let response = if overlay.kind == ComposerOverlayKind::Elicitation {
558            FrontendResponse::Elicitation {
559                request_id: overlay.request.id,
560                action,
561                content,
562            }
563        } else {
564            FrontendResponse::Other {
565                request_id: overlay.request.id,
566                action,
567                content,
568            }
569        };
570        Some(ComposerAction::Respond {
571            response,
572            request: overlay.request,
573            resolution: match action {
574                FrontendElicitationAction::Accept => "accepted",
575                FrontendElicitationAction::Decline => "declined",
576                FrontendElicitationAction::Cancel => "cancelled",
577            }
578            .into(),
579        })
580    }
581
582    fn insert_char(&mut self, character: char) {
583        self.input.insert(self.cursor, character);
584        self.cursor += character.len_utf8();
585    }
586
587    fn insert_text(&mut self, text: &str) {
588        if let Some(overlay) = self.overlays.front_mut() {
589            if matches!(
590                overlay.kind,
591                ComposerOverlayKind::Elicitation | ComposerOverlayKind::Other
592            ) {
593                overlay.input.insert_str(overlay.cursor, text);
594                overlay.cursor += text.len();
595                return;
596            }
597        }
598        self.input.insert_str(self.cursor, text);
599        self.cursor += text.len();
600    }
601
602    fn backspace(&mut self) {
603        let start = previous_grapheme_boundary(&self.input, self.cursor);
604        if start < self.cursor {
605            self.input.replace_range(start..self.cursor, "");
606            self.cursor = start;
607        }
608    }
609
610    fn delete(&mut self) {
611        let end = next_grapheme_boundary(&self.input, self.cursor);
612        if end > self.cursor {
613            self.input.replace_range(self.cursor..end, "");
614        }
615    }
616
617    fn clear_input(&mut self) {
618        self.input.clear();
619        self.cursor = 0;
620        self.history_index = None;
621        self.history_draft = None;
622    }
623
624    fn history_previous(&mut self) {
625        if self.history.is_empty() || self.input.contains('\n') {
626            return;
627        }
628        let index = match self.history_index {
629            Some(0) => 0,
630            Some(index) => index - 1,
631            None => {
632                self.history_draft = Some(self.input.clone());
633                self.history.len() - 1
634            }
635        };
636        self.history_index = Some(index);
637        self.input.clone_from(&self.history[index]);
638        self.cursor = self.input.len();
639    }
640
641    fn history_next(&mut self) {
642        let Some(index) = self.history_index else {
643            return;
644        };
645        if index + 1 < self.history.len() {
646            self.history_index = Some(index + 1);
647            self.input.clone_from(&self.history[index + 1]);
648        } else {
649            self.history_index = None;
650            self.input = self.history_draft.take().unwrap_or_default();
651        }
652        self.cursor = self.input.len();
653    }
654
655    fn overlay_insert_char(&mut self, character: char) {
656        if let Some(overlay) = self.overlays.front_mut() {
657            overlay.input.insert(overlay.cursor, character);
658            overlay.cursor += character.len_utf8();
659        }
660    }
661
662    fn overlay_backspace(&mut self) {
663        if let Some(overlay) = self.overlays.front_mut() {
664            let start = previous_grapheme_boundary(&overlay.input, overlay.cursor);
665            if start < overlay.cursor {
666                overlay.input.replace_range(start..overlay.cursor, "");
667                overlay.cursor = start;
668            }
669        }
670    }
671}
672
673fn overlay_kind(request: &FrontendRequest) -> ComposerOverlayKind {
674    match request.kind {
675        FrontendRequestKind::Approval
676            if request.payload.get("child_agent_id").is_some()
677                || request.payload.get("child_id").is_some()
678                || request.payload.get("agent_id").is_some() =>
679        {
680            ComposerOverlayKind::ChildApproval
681        }
682        FrontendRequestKind::Approval => ComposerOverlayKind::Approval,
683        FrontendRequestKind::Elicitation => ComposerOverlayKind::Elicitation,
684        FrontendRequestKind::Other => ComposerOverlayKind::Other,
685    }
686}
687
688fn previous_grapheme_boundary(text: &str, cursor: usize) -> usize {
689    text[..cursor]
690        .grapheme_indices(true)
691        .map(|(index, _)| index)
692        .next_back()
693        .unwrap_or(0)
694}
695
696fn next_grapheme_boundary(text: &str, cursor: usize) -> usize {
697    text[cursor..]
698        .grapheme_indices(true)
699        .nth(1)
700        .map(|(index, _)| cursor + index)
701        .unwrap_or(text.len())
702}
703
704fn line_start(text: &str, cursor: usize) -> usize {
705    text[..cursor].rfind('\n').map_or(0, |index| index + 1)
706}
707
708fn line_end(text: &str, cursor: usize) -> usize {
709    text[cursor..]
710        .find('\n')
711        .map_or(text.len(), |index| cursor + index)
712}
713
714#[cfg(test)]
715mod tests {
716    use serde_json::json;
717    use supercode::FrontendActions;
718    use supercode::FrontendCommandDescriptor;
719    use supercode::FrontendConnectionState;
720    use supercode::FrontendDisplayCapabilities;
721    use supercode::FrontendOperationDescriptor;
722    use supercode::FrontendOperationKind;
723    use supercode::FRONTEND_RUNTIME_SCHEMA_VERSION;
724
725    use super::*;
726
727    #[derive(Clone, Copy)]
728    struct TestModifiers {
729        control: bool,
730        shift: bool,
731        alt: bool,
732    }
733
734    impl TestModifiers {
735        const NONE: Self = Self {
736            control: false,
737            shift: false,
738            alt: false,
739        };
740        const CONTROL: Self = Self {
741            control: true,
742            ..Self::NONE
743        };
744        const SHIFT: Self = Self {
745            shift: true,
746            ..Self::NONE
747        };
748    }
749
750    fn descriptor(turn_state: FrontendTurnState) -> FrontendRuntimeDescriptor {
751        FrontendRuntimeDescriptor {
752            schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
753            session_id: "test".into(),
754            source_harness: Some("claude-code".into()),
755            emulation_profile: Some("cc-parity".into()),
756            active_modules: vec![
757                "tui".into(),
758                "permissions".into(),
759                "mcp".into(),
760                "subagents".into(),
761            ],
762            commands: vec![FrontendCommandDescriptor {
763                name: "agents".into(),
764                description: None,
765                argument_hint: None,
766            }],
767            operations: vec![FrontendOperationDescriptor {
768                id: "prompt:agents".into(),
769                kind: FrontendOperationKind::Prompt,
770                command: Some(FrontendCommandDescriptor {
771                    name: "agents".into(),
772                    description: None,
773                    argument_hint: Some("[arguments]".into()),
774                }),
775            }],
776            actions: FrontendActions {
777                submit: true,
778                interrupt: true,
779                steer: true,
780                respond: true,
781                detach: true,
782                close: true,
783            },
784            display: FrontendDisplayCapabilities {
785                event_kinds: vec![],
786                opaque_fallback: true,
787            },
788            model: "test".into(),
789            turn_state,
790            connection_state: FrontendConnectionState::Connected,
791            extensions: Default::default(),
792        }
793    }
794
795    fn key(code: ComposerKeyCode, modifiers: TestModifiers) -> ComposerKey {
796        ComposerKey {
797            code,
798            control: modifiers.control,
799            shift: modifiers.shift,
800            alt: modifiers.alt,
801            release: false,
802        }
803    }
804
805    fn event(sequence: u64, kind: &str, payload: Value) -> FrontendEvent {
806        FrontendEvent {
807            sequence,
808            kind: kind.into(),
809            payload,
810        }
811    }
812
813    #[test]
814    fn editing_submit_and_paste_are_immediate_and_deterministic() {
815        let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Idle));
816        model.handle_key(key(ComposerKeyCode::Char('h'), TestModifiers::NONE));
817        model.handle_key(key(ComposerKeyCode::Char('i'), TestModifiers::NONE));
818        model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::SHIFT));
819        model.handle_input(ComposerInputEvent::Paste("one\ntwo\n/never-submit".into()));
820        assert_eq!(model.input(), "hi\none\ntwo\n/never-submit");
821        assert_eq!(model.history().len(), 0);
822
823        let before = Instant::now();
824        let action = model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE));
825        assert_eq!(
826            action,
827            Some(ComposerAction::Submit("hi\none\ntwo\n/never-submit".into()))
828        );
829        let ComposerTurnState::Working { since } = model.turn_state() else {
830            panic!("submit must synchronously enter working state");
831        };
832        assert!(since >= before);
833        assert!(before.elapsed().as_millis() < 100);
834        assert!(model.input().is_empty());
835        assert_eq!(model.history().len(), 1);
836        assert_eq!(
837            model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
838            None
839        );
840    }
841
842    #[test]
843    fn every_catalog_command_has_an_exact_typed_trigger() {
844        let descriptor = descriptor(FrontendTurnState::Idle);
845        for operation in &descriptor.operations {
846            let command = operation.command.as_ref().unwrap();
847            let mut model = ComposerModel::new(&descriptor);
848            model.set_input(format!("/{} inspect this", command.name));
849            assert_eq!(
850                model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
851                Some(ComposerAction::Invoke(
852                    FrontendOperationInvocation::Prompt {
853                        operation_id: operation.id.clone(),
854                        arguments: "inspect this".into(),
855                    }
856                )),
857                "rendered command /{} lacked its typed operation route",
858                command.name
859            );
860        }
861    }
862
863    #[test]
864    fn legacy_commands_and_modules_cannot_create_an_invoke_route() {
865        let mut descriptor = descriptor(FrontendTurnState::Idle);
866        descriptor.operations.clear();
867        descriptor.active_modules = vec![
868            "model_catalog".into(),
869            "session_tree".into(),
870            "subagents".into(),
871            "reduction".into(),
872        ];
873        let mut model = ComposerModel::new(&descriptor);
874        model.set_input("/agents inspect this");
875        assert_eq!(
876            model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
877            Some(ComposerAction::Submit("/agents inspect this".into()))
878        );
879    }
880
881    #[test]
882    fn unbracketed_rapid_key_stream_treats_enter_as_pasted_newline() {
883        let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Idle));
884        let start = Instant::now();
885        for (offset, character) in [(0, 'a'), (1, 'b'), (2, 'c')] {
886            assert_eq!(
887                model.handle_key_at(
888                    key(ComposerKeyCode::Char(character), TestModifiers::NONE),
889                    start + std::time::Duration::from_millis(offset),
890                ),
891                None
892            );
893        }
894        assert_eq!(
895            model.handle_key_at(
896                key(ComposerKeyCode::Enter, TestModifiers::NONE),
897                start + std::time::Duration::from_millis(3),
898            ),
899            None
900        );
901        assert_eq!(model.input(), "abc\n");
902        assert!(model.history().is_empty());
903        assert_eq!(
904            model.handle_key_at(
905                key(ComposerKeyCode::Enter, TestModifiers::NONE),
906                start + std::time::Duration::from_millis(200),
907            ),
908            Some(ComposerAction::Submit("abc\n".into()))
909        );
910    }
911
912    #[test]
913    fn one_fast_typed_character_then_enter_still_submits() {
914        let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Idle));
915        let start = Instant::now();
916        model.handle_key_at(key(ComposerKeyCode::Char('x'), TestModifiers::NONE), start);
917        assert_eq!(
918            model.handle_key_at(
919                key(ComposerKeyCode::Enter, TestModifiers::NONE),
920                start + std::time::Duration::from_millis(1),
921            ),
922            Some(ComposerAction::Submit("x".into()))
923        );
924    }
925
926    #[test]
927    fn only_terminal_events_return_working_state_to_idle() {
928        let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Idle));
929        model.set_input("go");
930        assert!(matches!(
931            model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
932            Some(ComposerAction::Submit(_))
933        ));
934        assert!(!model.apply_event(&event(
935            1,
936            "text_delta",
937            json!({"type":"text_delta", "text":"hello"}),
938        )));
939        assert!(matches!(
940            model.turn_state(),
941            ComposerTurnState::Working { .. }
942        ));
943        assert!(!model.apply_event(&event(
944            2,
945            "turn_completed",
946            json!({"type":"turn_completed"}),
947        )));
948        assert!(matches!(
949            model.turn_state(),
950            ComposerTurnState::Working { .. }
951        ));
952        assert!(model.apply_event(&event(
953            3,
954            "turn_succeeded",
955            json!({"type":"turn_succeeded", "schema_version":1}),
956        )));
957        assert_eq!(model.turn_state(), ComposerTurnState::Idle);
958    }
959
960    #[test]
961    fn versioned_turn_start_is_accepted_without_shape_coupling() {
962        let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Idle));
963        assert!(model.apply_event(&event(
964            1,
965            "turn_started",
966            json!({"type":"turn_started","schema_version":1,"turn_id":"turn-1"}),
967        )));
968        assert!(matches!(
969            model.turn_state(),
970            ComposerTurnState::Working { .. }
971        ));
972    }
973
974    #[test]
975    fn steering_and_interrupt_are_distinct_and_preserve_history() {
976        let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Busy));
977        model.set_input("look at the failing test");
978        assert_eq!(
979            model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
980            Some(ComposerAction::Steer("look at the failing test".into()))
981        );
982        assert_eq!(model.queued_steering().len(), 1);
983        assert_eq!(model.history(), ["look at the failing test"]);
984        assert_eq!(
985            model.handle_key(key(ComposerKeyCode::Char('c'), TestModifiers::CONTROL)),
986            Some(ComposerAction::Interrupt)
987        );
988        assert_eq!(model.queued_steering().len(), 1);
989        assert_eq!(model.history(), ["look at the failing test"]);
990    }
991
992    #[test]
993    fn approval_and_child_approval_emit_exactly_one_typed_response() {
994        for (id, payload, expected_kind) in [
995            (7, json!({"tool":"bash"}), ComposerOverlayKind::Approval),
996            (
997                8,
998                json!({"tool":"bash", "child_agent_id":"agent-2"}),
999                ComposerOverlayKind::ChildApproval,
1000            ),
1001        ] {
1002            let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Busy));
1003            let request = FrontendRequest {
1004                id,
1005                kind: FrontendRequestKind::Approval,
1006                payload,
1007            };
1008            assert!(model.apply_event(&event(
1009                id,
1010                "request",
1011                json!({"type":"request", "request":request}),
1012            )));
1013            assert_eq!(model.overlay().unwrap().kind, expected_kind);
1014            assert_eq!(
1015                model.handle_key(key(ComposerKeyCode::Char('y'), TestModifiers::NONE)),
1016                Some(ComposerAction::Respond {
1017                    response: FrontendResponse::Approval {
1018                        request_id: id,
1019                        decision: FrontendApprovalDecision::Allow,
1020                    },
1021                    request,
1022                    resolution: "allowed once".into(),
1023                })
1024            );
1025            assert_eq!(
1026                model.handle_key(key(ComposerKeyCode::Char('y'), TestModifiers::NONE)),
1027                None
1028            );
1029        }
1030    }
1031
1032    #[test]
1033    fn request_overlays_follow_runtime_events_and_respond_capability() {
1034        let mut enabled_descriptor = descriptor(FrontendTurnState::Busy);
1035        enabled_descriptor.active_modules.clear();
1036        let mut enabled = ComposerModel::new(&enabled_descriptor);
1037        assert!(enabled.apply_event(&event(
1038            1,
1039            "request",
1040            json!({"request":{"id":40,"kind":"approval","payload":{"tool":"bash"}}}),
1041        )));
1042        assert_eq!(
1043            enabled.overlay().unwrap().kind,
1044            ComposerOverlayKind::Approval
1045        );
1046
1047        let mut disabled_descriptor = descriptor(FrontendTurnState::Busy);
1048        disabled_descriptor.active_modules = vec!["permissions".into(), "mcp".into()];
1049        disabled_descriptor.actions.respond = false;
1050        let mut disabled = ComposerModel::new(&disabled_descriptor);
1051        assert!(!disabled.apply_event(&event(
1052            1,
1053            "request",
1054            json!({"request":{"id":41,"kind":"elicitation","payload":{"prompt":"Choose"}}}),
1055        )));
1056        assert!(disabled.overlay().is_none());
1057    }
1058
1059    #[test]
1060    fn elicitation_accepts_json_and_emits_once() {
1061        let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Busy));
1062        let request = FrontendRequest {
1063            id: 9,
1064            kind: FrontendRequestKind::Elicitation,
1065            payload: json!({"prompt":"Choose"}),
1066        };
1067        assert!(model.apply_event(&event(
1068            9,
1069            "request",
1070            json!({"type":"request", "request":request}),
1071        )));
1072        model.handle_input(ComposerInputEvent::Paste("{\"choice\":2}".into()));
1073        assert_eq!(
1074            model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
1075            Some(ComposerAction::Respond {
1076                response: FrontendResponse::Elicitation {
1077                    request_id: 9,
1078                    action: FrontendElicitationAction::Accept,
1079                    content: Some(json!({"choice":2})),
1080                },
1081                request,
1082                resolution: "accepted".into(),
1083            })
1084        );
1085        assert_eq!(
1086            model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
1087            None
1088        );
1089    }
1090
1091    #[test]
1092    fn concurrent_and_unknown_requests_queue_and_failed_response_can_be_restored() {
1093        let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Busy));
1094        let approval = FrontendRequest {
1095            id: 20,
1096            kind: FrontendRequestKind::Approval,
1097            payload: json!({"tool":"bash"}),
1098        };
1099        let other = json!({"id":21, "kind":"oauth_code", "payload":{"prompt":"Paste code"}});
1100        assert!(model.apply_event(&event(
1101            20,
1102            "request",
1103            json!({"type":"request", "request":approval}),
1104        )));
1105        assert!(model.apply_event(&event(
1106            21,
1107            "request",
1108            json!({"type":"request", "request":other}),
1109        )));
1110        assert_eq!(model.pending_overlay_count(), 2);
1111        assert_eq!(model.overlay().unwrap().request.id, 20);
1112
1113        let Some(ComposerAction::Respond { request, .. }) =
1114            model.handle_key(key(ComposerKeyCode::Char('n'), TestModifiers::NONE))
1115        else {
1116            panic!("approval should produce a response");
1117        };
1118        assert_eq!(request, approval);
1119        assert_eq!(model.overlay().unwrap().request.id, 21);
1120        assert_eq!(model.overlay().unwrap().kind, ComposerOverlayKind::Other);
1121
1122        model.restore_request(request);
1123        assert_eq!(model.pending_overlay_count(), 2);
1124        assert_eq!(model.overlay().unwrap().request.id, 20);
1125        assert_eq!(
1126            model.handle_key(key(ComposerKeyCode::Char('y'), TestModifiers::NONE)),
1127            Some(ComposerAction::Respond {
1128                response: FrontendResponse::Approval {
1129                    request_id: 20,
1130                    decision: FrontendApprovalDecision::Allow,
1131                },
1132                request: approval,
1133                resolution: "allowed once".into(),
1134            })
1135        );
1136        assert_eq!(model.overlay().unwrap().request.id, 21);
1137        assert!(matches!(
1138            model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
1139            Some(ComposerAction::Respond {
1140                response: FrontendResponse::Other {
1141                    request_id: 21,
1142                    action: FrontendElicitationAction::Accept,
1143                    ..
1144                },
1145                ..
1146            })
1147        ));
1148        assert_eq!(model.pending_overlay_count(), 0);
1149    }
1150
1151    #[test]
1152    fn canonical_request_resolution_dismisses_matching_overlay_for_sibling_frontend() {
1153        let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Busy));
1154        for id in [30, 31] {
1155            model.apply_event(&event(
1156                id,
1157                "request",
1158                json!({"request":{"id":id,"kind":"approval","payload":{"tool":"bash"}}}),
1159            ));
1160        }
1161        assert_eq!(model.pending_overlay_count(), 2);
1162        assert!(model.apply_event(&event(
1163            32,
1164            "request_resolved",
1165            json!({"request_id":30,"response":{"decision":"allow"}}),
1166        )));
1167        assert_eq!(model.pending_overlay_count(), 1);
1168        assert_eq!(model.overlay().unwrap().request.id, 31);
1169        assert!(!model.apply_event(&event(
1170            33,
1171            "request_resolved",
1172            json!({"request_id":30,"response":{"decision":"allow"}}),
1173        )));
1174    }
1175
1176    #[test]
1177    fn loser_response_completion_cannot_resurrect_a_canonically_resolved_overlay() {
1178        let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Busy));
1179        model.apply_event(&event(
1180            1,
1181            "request",
1182            json!({"request":{"id":30,"kind":"approval","payload":{"tool":"bash"}}}),
1183        ));
1184        let Some(ComposerAction::Respond { request, .. }) =
1185            model.handle_key(key(ComposerKeyCode::Char('y'), TestModifiers::NONE))
1186        else {
1187            panic!("approval should produce a response");
1188        };
1189        assert!(!model.apply_event(&event(
1190            2,
1191            "request_resolved",
1192            json!({"request_id":30,"response":{"kind":"approval","request_id":30,"decision":"deny"}}),
1193        )));
1194        assert!(!model.restore_request(request));
1195        assert_eq!(model.pending_overlay_count(), 0);
1196    }
1197
1198    #[test]
1199    fn unicode_editing_tracks_grapheme_boundaries() {
1200        let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Idle));
1201        model.set_input("a👩‍💻b");
1202        model.handle_key(key(ComposerKeyCode::Left, TestModifiers::NONE));
1203        model.handle_key(key(ComposerKeyCode::Backspace, TestModifiers::NONE));
1204        assert_eq!(model.input(), "ab");
1205        assert_eq!(model.cursor(), 1);
1206    }
1207}