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#[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#[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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum ComposerTurnState {
61 Idle,
62 Working { since: Instant },
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum ComposerOverlayKind {
69 Approval,
70 ChildApproval,
71 Elicitation,
72 Other,
73}
74
75#[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#[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#[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 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 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 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}