1use std::sync::{Arc, Mutex};
2
3use taskers_domain::{
4 AppModel, BrowserProfileMode, Direction, DomainError, PaneId, PaneKind, SurfaceId,
5 SurfaceRecord, WindowId, WorkspaceId,
6};
7
8use crate::protocol::{
9 ControlCommand, ControlQuery, ControlResponse, IdentifyContext, IdentifyResult,
10};
11
12#[derive(Debug, Clone)]
13pub struct InMemoryController {
14 state: Arc<Mutex<ControllerState>>,
15}
16
17#[derive(Debug, Clone)]
18struct ControllerState {
19 model: AppModel,
20 revision: u64,
21}
22
23#[derive(Debug, Clone)]
24pub struct ControllerSnapshot {
25 pub model: AppModel,
26 pub revision: u64,
27}
28
29impl InMemoryController {
30 pub fn new(state: AppModel) -> Self {
31 Self {
32 state: Arc::new(Mutex::new(ControllerState {
33 model: state,
34 revision: 0,
35 })),
36 }
37 }
38
39 pub fn snapshot(&self) -> ControllerSnapshot {
40 let state = self.state.lock().expect("state mutex poisoned").clone();
41 ControllerSnapshot {
42 model: state.model,
43 revision: state.revision,
44 }
45 }
46
47 pub fn revision(&self) -> u64 {
48 self.state.lock().expect("state mutex poisoned").revision
49 }
50
51 pub fn handle(&self, command: ControlCommand) -> Result<ControlResponse, DomainError> {
52 let mut state = self.state.lock().expect("state mutex poisoned");
53 let model = &mut state.model;
54
55 let (response, mutated) = match command {
56 ControlCommand::CreateWorkspace { label } => {
57 let workspace_id = model.create_workspace(label);
58 (ControlResponse::WorkspaceCreated { workspace_id }, true)
59 }
60 ControlCommand::RenameWorkspace {
61 workspace_id,
62 label,
63 } => {
64 model.rename_workspace(workspace_id, label)?;
65 (
66 ControlResponse::Ack {
67 message: "workspace renamed".into(),
68 },
69 true,
70 )
71 }
72 ControlCommand::SwitchWorkspace {
73 window_id,
74 workspace_id,
75 } => {
76 let target_window = window_id.unwrap_or(model.active_window);
77 model.switch_workspace(target_window, workspace_id)?;
78 (
79 ControlResponse::Ack {
80 message: "workspace switched".into(),
81 },
82 true,
83 )
84 }
85 ControlCommand::SplitPane {
86 workspace_id,
87 pane_id,
88 axis,
89 } => {
90 let new_pane_id = model.split_pane(workspace_id, pane_id, axis)?;
91 (
92 ControlResponse::PaneSplit {
93 pane_id: new_pane_id,
94 },
95 true,
96 )
97 }
98 ControlCommand::SplitPaneDirection {
99 workspace_id,
100 pane_id,
101 direction,
102 } => {
103 let new_pane_id =
104 model.split_pane_direction(workspace_id, Some(pane_id), direction)?;
105 (
106 ControlResponse::PaneSplit {
107 pane_id: new_pane_id,
108 },
109 true,
110 )
111 }
112 ControlCommand::CreateWorkspaceWindow {
113 workspace_id,
114 direction,
115 preferred_column_width,
116 preferred_window_height,
117 } => {
118 let new_pane_id = model.create_workspace_window(workspace_id, direction)?;
119 match direction {
120 Direction::Left | Direction::Right => {
121 if let Some(width) = preferred_column_width
122 && let Some(workspace_column_id) = model
123 .workspaces
124 .get(&workspace_id)
125 .and_then(|workspace| workspace.active_column_id())
126 {
127 model.set_workspace_column_width(
128 workspace_id,
129 workspace_column_id,
130 width,
131 )?;
132 }
133 }
134 Direction::Up | Direction::Down => {
135 if let Some(height) = preferred_window_height
136 && let Some(workspace_window_id) = model
137 .workspaces
138 .get(&workspace_id)
139 .map(|workspace| workspace.active_window)
140 {
141 model.set_workspace_window_height(
142 workspace_id,
143 workspace_window_id,
144 height,
145 )?;
146 }
147 }
148 }
149 (
150 ControlResponse::WorkspaceWindowCreated {
151 pane_id: new_pane_id,
152 },
153 true,
154 )
155 }
156 ControlCommand::FocusWorkspaceWindow {
157 workspace_id,
158 workspace_window_id,
159 } => {
160 model.focus_workspace_window(workspace_id, workspace_window_id)?;
161 (
162 ControlResponse::Ack {
163 message: "workspace window focused".into(),
164 },
165 true,
166 )
167 }
168 ControlCommand::MoveWorkspaceWindow {
169 workspace_id,
170 workspace_window_id,
171 target,
172 } => {
173 model.move_workspace_window(workspace_id, workspace_window_id, target)?;
174 (
175 ControlResponse::Ack {
176 message: "workspace window moved".into(),
177 },
178 true,
179 )
180 }
181 ControlCommand::CreateWorkspaceWindowTab {
182 workspace_id,
183 workspace_window_id,
184 } => {
185 let (workspace_window_tab_id, pane_id) =
186 model.create_workspace_window_tab(workspace_id, workspace_window_id)?;
187 (
188 ControlResponse::WorkspaceWindowTabCreated {
189 pane_id,
190 workspace_window_tab_id,
191 },
192 true,
193 )
194 }
195 ControlCommand::FocusWorkspaceWindowTab {
196 workspace_id,
197 workspace_window_id,
198 workspace_window_tab_id,
199 } => {
200 model.focus_workspace_window_tab(
201 workspace_id,
202 workspace_window_id,
203 workspace_window_tab_id,
204 )?;
205 (
206 ControlResponse::Ack {
207 message: "workspace window tab focused".into(),
208 },
209 true,
210 )
211 }
212 ControlCommand::MoveWorkspaceWindowTab {
213 workspace_id,
214 workspace_window_id,
215 workspace_window_tab_id,
216 to_index,
217 } => {
218 model.move_workspace_window_tab(
219 workspace_id,
220 workspace_window_id,
221 workspace_window_tab_id,
222 to_index,
223 )?;
224 (
225 ControlResponse::Ack {
226 message: "workspace window tab moved".into(),
227 },
228 true,
229 )
230 }
231 ControlCommand::TransferWorkspaceWindowTab {
232 workspace_id,
233 source_workspace_window_id,
234 workspace_window_tab_id,
235 target_workspace_window_id,
236 to_index,
237 } => {
238 model.transfer_workspace_window_tab(
239 workspace_id,
240 source_workspace_window_id,
241 workspace_window_tab_id,
242 target_workspace_window_id,
243 to_index,
244 )?;
245 (
246 ControlResponse::Ack {
247 message: "workspace window tab transferred".into(),
248 },
249 true,
250 )
251 }
252 ControlCommand::ExtractWorkspaceWindowTab {
253 workspace_id,
254 source_workspace_window_id,
255 workspace_window_tab_id,
256 target,
257 } => {
258 model.extract_workspace_window_tab(
259 workspace_id,
260 source_workspace_window_id,
261 workspace_window_tab_id,
262 target,
263 )?;
264 (
265 ControlResponse::Ack {
266 message: "workspace window tab extracted".into(),
267 },
268 true,
269 )
270 }
271 ControlCommand::CloseWorkspaceWindowTab {
272 workspace_id,
273 workspace_window_id,
274 workspace_window_tab_id,
275 } => {
276 model.close_workspace_window_tab(
277 workspace_id,
278 workspace_window_id,
279 workspace_window_tab_id,
280 )?;
281 (
282 ControlResponse::Ack {
283 message: "workspace window tab closed".into(),
284 },
285 true,
286 )
287 }
288 ControlCommand::CreatePaneTab {
289 workspace_id,
290 pane_container_id,
291 kind,
292 } => {
293 let (pane_tab_id, pane_id) =
294 model.create_pane_tab(workspace_id, pane_container_id, kind)?;
295 (
296 ControlResponse::PaneTabCreated {
297 pane_id,
298 pane_tab_id,
299 },
300 true,
301 )
302 }
303 ControlCommand::FocusPaneTab {
304 workspace_id,
305 pane_container_id,
306 pane_tab_id,
307 } => {
308 model.focus_pane_tab(workspace_id, pane_container_id, pane_tab_id)?;
309 (
310 ControlResponse::Ack {
311 message: "pane tab focused".into(),
312 },
313 true,
314 )
315 }
316 ControlCommand::MovePaneTab {
317 workspace_id,
318 pane_container_id,
319 pane_tab_id,
320 to_index,
321 } => {
322 model.move_pane_tab(workspace_id, pane_container_id, pane_tab_id, to_index)?;
323 (
324 ControlResponse::Ack {
325 message: "pane tab moved".into(),
326 },
327 true,
328 )
329 }
330 ControlCommand::TransferPaneTab {
331 workspace_id,
332 source_pane_container_id,
333 pane_tab_id,
334 target_pane_container_id,
335 to_index,
336 } => {
337 model.transfer_pane_tab(
338 workspace_id,
339 source_pane_container_id,
340 pane_tab_id,
341 target_pane_container_id,
342 to_index,
343 )?;
344 (
345 ControlResponse::Ack {
346 message: "pane tab transferred".into(),
347 },
348 true,
349 )
350 }
351 ControlCommand::ClosePaneTab {
352 workspace_id,
353 pane_container_id,
354 pane_tab_id,
355 } => {
356 model.close_pane_tab(workspace_id, pane_container_id, pane_tab_id)?;
357 (
358 ControlResponse::Ack {
359 message: "pane tab closed".into(),
360 },
361 true,
362 )
363 }
364 ControlCommand::FocusPane {
365 workspace_id,
366 pane_id,
367 } => {
368 model.focus_pane(workspace_id, pane_id)?;
369 (
370 ControlResponse::Ack {
371 message: "pane focused".into(),
372 },
373 true,
374 )
375 }
376 ControlCommand::FocusPaneDirection {
377 workspace_id,
378 direction,
379 } => {
380 model.focus_pane_direction(workspace_id, direction)?;
381 (
382 ControlResponse::Ack {
383 message: "pane focus moved".into(),
384 },
385 true,
386 )
387 }
388 ControlCommand::ResizeActiveWindow {
389 workspace_id,
390 direction,
391 amount,
392 } => {
393 model.resize_active_window(workspace_id, direction, amount)?;
394 (
395 ControlResponse::Ack {
396 message: "workspace window resized".into(),
397 },
398 true,
399 )
400 }
401 ControlCommand::ResizeActivePaneSplit {
402 workspace_id,
403 direction,
404 amount,
405 } => {
406 model.resize_active_pane_split(workspace_id, direction, amount)?;
407 (
408 ControlResponse::Ack {
409 message: "pane split resized".into(),
410 },
411 true,
412 )
413 }
414 ControlCommand::SetWorkspaceColumnWidth {
415 workspace_id,
416 workspace_column_id,
417 width,
418 } => {
419 model.set_workspace_column_width(workspace_id, workspace_column_id, width)?;
420 (
421 ControlResponse::Ack {
422 message: "workspace column width updated".into(),
423 },
424 true,
425 )
426 }
427 ControlCommand::SetWorkspaceWindowHeight {
428 workspace_id,
429 workspace_window_id,
430 height,
431 } => {
432 model.set_workspace_window_height(workspace_id, workspace_window_id, height)?;
433 (
434 ControlResponse::Ack {
435 message: "workspace window height updated".into(),
436 },
437 true,
438 )
439 }
440 ControlCommand::SetWindowSplitRatio {
441 workspace_id,
442 workspace_window_id,
443 path,
444 ratio,
445 } => {
446 model.set_window_split_ratio(workspace_id, workspace_window_id, &path, ratio)?;
447 (
448 ControlResponse::Ack {
449 message: "window split ratio updated".into(),
450 },
451 true,
452 )
453 }
454 ControlCommand::SetPaneTabSplitRatio {
455 workspace_id,
456 pane_container_id,
457 pane_tab_id,
458 path,
459 ratio,
460 } => {
461 model.set_pane_tab_split_ratio(
462 workspace_id,
463 pane_container_id,
464 pane_tab_id,
465 &path,
466 ratio,
467 )?;
468 (
469 ControlResponse::Ack {
470 message: "pane tab split ratio updated".into(),
471 },
472 true,
473 )
474 }
475 ControlCommand::UpdatePaneMetadata { pane_id, patch } => {
476 model.update_pane_metadata(pane_id, patch)?;
477 (
478 ControlResponse::Ack {
479 message: "pane metadata updated".into(),
480 },
481 true,
482 )
483 }
484 ControlCommand::UpdateSurfaceMetadata { surface_id, patch } => {
485 model.update_surface_metadata(surface_id, patch)?;
486 (
487 ControlResponse::Ack {
488 message: "surface metadata updated".into(),
489 },
490 true,
491 )
492 }
493 ControlCommand::CreateSurface {
494 workspace_id,
495 pane_id,
496 kind,
497 browser_profile_mode,
498 } => {
499 let is_browser = matches!(kind, PaneKind::Browser);
500 let surface_id = model.create_surface(workspace_id, pane_id, kind)?;
501 if is_browser {
502 model.update_surface_metadata(
503 surface_id,
504 taskers_domain::PaneMetadataPatch {
505 browser_profile_mode: Some(
506 browser_profile_mode
507 .unwrap_or(BrowserProfileMode::PersistentDefault),
508 ),
509 ..taskers_domain::PaneMetadataPatch::default()
510 },
511 )?;
512 }
513 (ControlResponse::SurfaceCreated { surface_id }, true)
514 }
515 ControlCommand::FocusSurface {
516 workspace_id,
517 pane_id,
518 surface_id,
519 } => {
520 model.focus_surface(workspace_id, pane_id, surface_id)?;
521 (
522 ControlResponse::Ack {
523 message: "surface focused".into(),
524 },
525 true,
526 )
527 }
528 ControlCommand::StartSurfaceAgentSession {
529 workspace_id: _,
530 pane_id: _,
531 surface_id,
532 agent_kind,
533 } => {
534 let current = resolve_identify_context(model, None, None, Some(surface_id))?;
535 model.start_surface_agent_session(
536 current.workspace_id,
537 current.pane_id,
538 surface_id,
539 agent_kind,
540 )?;
541 (
542 ControlResponse::Ack {
543 message: "surface agent session started".into(),
544 },
545 true,
546 )
547 }
548 ControlCommand::StopSurfaceAgentSession {
549 workspace_id: _,
550 pane_id: _,
551 surface_id,
552 exit_status,
553 } => {
554 let current = resolve_identify_context(model, None, None, Some(surface_id))?;
555 model.stop_surface_agent_session(
556 current.workspace_id,
557 current.pane_id,
558 surface_id,
559 exit_status,
560 )?;
561 (
562 ControlResponse::Ack {
563 message: "surface agent session stopped".into(),
564 },
565 true,
566 )
567 }
568 ControlCommand::MarkSurfaceCompleted {
569 workspace_id,
570 pane_id,
571 surface_id,
572 } => {
573 model.mark_surface_completed(workspace_id, pane_id, surface_id)?;
574 (
575 ControlResponse::Ack {
576 message: "surface marked completed".into(),
577 },
578 true,
579 )
580 }
581 ControlCommand::CloseSurface {
582 workspace_id,
583 pane_id,
584 surface_id,
585 } => {
586 model.close_surface(workspace_id, pane_id, surface_id)?;
587 (
588 ControlResponse::Ack {
589 message: "surface closed".into(),
590 },
591 true,
592 )
593 }
594 ControlCommand::MoveSurface {
595 workspace_id,
596 pane_id,
597 surface_id,
598 to_index,
599 } => {
600 model.move_surface(workspace_id, pane_id, surface_id, to_index)?;
601 (
602 ControlResponse::Ack {
603 message: "surface moved".into(),
604 },
605 true,
606 )
607 }
608 ControlCommand::TransferSurface {
609 source_workspace_id,
610 source_pane_id,
611 surface_id,
612 target_workspace_id,
613 target_pane_id,
614 to_index,
615 } => {
616 model.transfer_surface(
617 source_workspace_id,
618 source_pane_id,
619 surface_id,
620 target_workspace_id,
621 target_pane_id,
622 to_index,
623 )?;
624 (
625 ControlResponse::Ack {
626 message: "surface transferred".into(),
627 },
628 true,
629 )
630 }
631 ControlCommand::MoveSurfaceToSplit {
632 source_workspace_id,
633 source_pane_id,
634 surface_id,
635 target_workspace_id,
636 target_pane_id,
637 direction,
638 } => {
639 let new_pane_id = model.move_surface_to_split(
640 source_workspace_id,
641 source_pane_id,
642 surface_id,
643 target_workspace_id,
644 target_pane_id,
645 direction,
646 )?;
647 (
648 ControlResponse::SurfaceMovedToSplit {
649 pane_id: new_pane_id,
650 },
651 true,
652 )
653 }
654 ControlCommand::MoveSurfaceToWorkspace {
655 source_workspace_id,
656 source_pane_id,
657 surface_id,
658 target_workspace_id,
659 } => {
660 let new_pane_id = model.move_surface_to_workspace(
661 source_workspace_id,
662 source_pane_id,
663 surface_id,
664 target_workspace_id,
665 )?;
666 (
667 ControlResponse::SurfaceMovedToWorkspace {
668 pane_id: new_pane_id,
669 },
670 true,
671 )
672 }
673 ControlCommand::SetWorkspaceViewport {
674 workspace_id,
675 viewport,
676 } => {
677 model.set_workspace_viewport(workspace_id, viewport)?;
678 (
679 ControlResponse::Ack {
680 message: "workspace viewport updated".into(),
681 },
682 true,
683 )
684 }
685 ControlCommand::ClosePane {
686 workspace_id,
687 pane_id,
688 } => {
689 model.close_pane(workspace_id, pane_id)?;
690 (
691 ControlResponse::Ack {
692 message: "pane closed".into(),
693 },
694 true,
695 )
696 }
697 ControlCommand::CloseWorkspace { workspace_id } => {
698 model.close_workspace(workspace_id)?;
699 (
700 ControlResponse::Ack {
701 message: "workspace closed".into(),
702 },
703 true,
704 )
705 }
706 ControlCommand::ReorderWorkspaces {
707 window_id,
708 workspace_ids,
709 } => {
710 model.reorder_workspaces(window_id, workspace_ids)?;
711 (
712 ControlResponse::Ack {
713 message: "workspaces reordered".into(),
714 },
715 true,
716 )
717 }
718 ControlCommand::EmitSignal {
719 workspace_id,
720 pane_id,
721 surface_id,
722 event,
723 } => {
724 if let Some(surface_id) = surface_id {
725 let current = resolve_identify_context(model, None, None, Some(surface_id))?;
726 model.apply_surface_signal(
727 current.workspace_id,
728 current.pane_id,
729 surface_id,
730 event,
731 )?;
732 } else {
733 model.apply_signal(workspace_id, pane_id, event)?;
734 }
735 (
736 ControlResponse::Ack {
737 message: "signal applied".into(),
738 },
739 true,
740 )
741 }
742 ControlCommand::AgentSetStatus { workspace_id, text } => {
743 model.set_workspace_status(workspace_id, text)?;
744 (
745 ControlResponse::Ack {
746 message: "workspace agent status updated".into(),
747 },
748 true,
749 )
750 }
751 ControlCommand::AgentClearStatus { workspace_id } => {
752 model.clear_workspace_status(workspace_id)?;
753 (
754 ControlResponse::Ack {
755 message: "workspace agent status cleared".into(),
756 },
757 true,
758 )
759 }
760 ControlCommand::AgentSetProgress {
761 workspace_id,
762 progress,
763 } => {
764 model.set_workspace_progress(workspace_id, progress)?;
765 (
766 ControlResponse::Ack {
767 message: "workspace progress updated".into(),
768 },
769 true,
770 )
771 }
772 ControlCommand::AgentClearProgress { workspace_id } => {
773 model.clear_workspace_progress(workspace_id)?;
774 (
775 ControlResponse::Ack {
776 message: "workspace progress cleared".into(),
777 },
778 true,
779 )
780 }
781 ControlCommand::AgentAppendLog {
782 workspace_id,
783 entry,
784 } => {
785 model.append_workspace_log(workspace_id, entry)?;
786 (
787 ControlResponse::Ack {
788 message: "workspace log appended".into(),
789 },
790 true,
791 )
792 }
793 ControlCommand::AgentClearLog { workspace_id } => {
794 model.clear_workspace_log(workspace_id)?;
795 (
796 ControlResponse::Ack {
797 message: "workspace log cleared".into(),
798 },
799 true,
800 )
801 }
802 ControlCommand::AgentCreateNotification {
803 target,
804 kind,
805 title,
806 subtitle,
807 external_id,
808 message,
809 state,
810 } => {
811 model.create_agent_notification(
812 target,
813 kind,
814 title,
815 subtitle,
816 external_id,
817 message,
818 state,
819 )?;
820 (
821 ControlResponse::Ack {
822 message: "agent notification created".into(),
823 },
824 true,
825 )
826 }
827 ControlCommand::OpenNotification {
828 window_id,
829 notification_id,
830 } => {
831 model
832 .open_notification(window_id.unwrap_or(model.active_window), notification_id)?;
833 (
834 ControlResponse::Ack {
835 message: "notification opened".into(),
836 },
837 true,
838 )
839 }
840 ControlCommand::ClearNotification { notification_id } => {
841 model.clear_notification(notification_id)?;
842 (
843 ControlResponse::Ack {
844 message: "notification cleared".into(),
845 },
846 true,
847 )
848 }
849 ControlCommand::MarkNotificationDelivery {
850 notification_id,
851 delivery,
852 } => {
853 model.mark_notification_delivery(notification_id, delivery)?;
854 (
855 ControlResponse::Ack {
856 message: "notification delivery updated".into(),
857 },
858 true,
859 )
860 }
861 ControlCommand::AgentClearNotifications { target } => {
862 model.clear_agent_notifications(target)?;
863 (
864 ControlResponse::Ack {
865 message: "agent notifications cleared".into(),
866 },
867 true,
868 )
869 }
870 ControlCommand::DismissSurfaceAlert {
871 workspace_id,
872 pane_id,
873 surface_id,
874 } => {
875 model.dismiss_surface_alert(workspace_id, pane_id, surface_id)?;
876 (
877 ControlResponse::Ack {
878 message: "surface alert dismissed".into(),
879 },
880 true,
881 )
882 }
883 ControlCommand::DismissInterruptedAgentResume {
884 workspace_id,
885 pane_id,
886 surface_id,
887 } => {
888 model.dismiss_interrupted_agent_resume(workspace_id, pane_id, surface_id)?;
889 (
890 ControlResponse::Ack {
891 message: "interrupted agent resume dismissed".into(),
892 },
893 true,
894 )
895 }
896 ControlCommand::AgentTriggerFlash {
897 workspace_id,
898 pane_id,
899 surface_id,
900 } => {
901 model.trigger_surface_flash(workspace_id, pane_id, surface_id)?;
902 (
903 ControlResponse::Ack {
904 message: "surface flash triggered".into(),
905 },
906 true,
907 )
908 }
909 ControlCommand::AgentFocusLatestUnread { window_id } => {
910 model.focus_latest_unread(window_id.unwrap_or(model.active_window))?;
911 (
912 ControlResponse::Ack {
913 message: "focused latest unread activity".into(),
914 },
915 true,
916 )
917 }
918 ControlCommand::Browser { .. } => {
919 return Err(DomainError::InvalidOperation(
920 "browser automation commands require a live GTK host",
921 ));
922 }
923 ControlCommand::Screenshot { .. } => {
924 return Err(DomainError::InvalidOperation(
925 "screenshot commands require a live GTK host",
926 ));
927 }
928 ControlCommand::TerminalDebug { .. } => {
929 return Err(DomainError::InvalidOperation(
930 "terminal debug commands require a live GTK host",
931 ));
932 }
933 ControlCommand::Vcs { .. } => {
934 return Err(DomainError::InvalidOperation(
935 "vcs commands require app runtime support",
936 ));
937 }
938 ControlCommand::QueryStatus { query } => match query {
939 ControlQuery::ActiveWindow | ControlQuery::All => (
940 ControlResponse::Status {
941 session: model.snapshot(),
942 },
943 false,
944 ),
945 ControlQuery::Window { window_id } => (window_snapshot(model, window_id)?, false),
946 ControlQuery::Workspace { workspace_id } => {
947 (workspace_snapshot(model, workspace_id)?, false)
948 }
949 ControlQuery::Identify {
950 workspace_id,
951 pane_id,
952 surface_id,
953 } => (
954 ControlResponse::Identify {
955 result: identify_snapshot(model, workspace_id, pane_id, surface_id)?,
956 },
957 false,
958 ),
959 },
960 };
961
962 if mutated {
963 state.revision = state.revision.saturating_add(1);
964 }
965
966 Ok(response)
967 }
968}
969
970fn window_snapshot(model: &AppModel, window_id: WindowId) -> Result<ControlResponse, DomainError> {
971 let _ = model
972 .windows
973 .get(&window_id)
974 .ok_or(DomainError::MissingWindow(window_id))?;
975 Ok(ControlResponse::Status {
976 session: model.snapshot(),
977 })
978}
979
980fn workspace_snapshot(
981 model: &AppModel,
982 workspace_id: WorkspaceId,
983) -> Result<ControlResponse, DomainError> {
984 let _ = model
985 .workspaces
986 .get(&workspace_id)
987 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
988 Ok(ControlResponse::WorkspaceState {
989 workspace_id,
990 session: model.snapshot(),
991 })
992}
993
994fn identify_snapshot(
995 model: &AppModel,
996 workspace_id: Option<WorkspaceId>,
997 pane_id: Option<PaneId>,
998 surface_id: Option<SurfaceId>,
999) -> Result<IdentifyResult, DomainError> {
1000 let focused = focused_identify_context(model)?;
1001 let caller = if workspace_id.is_some() || pane_id.is_some() || surface_id.is_some() {
1002 Some(resolve_identify_context(
1003 model,
1004 workspace_id,
1005 pane_id,
1006 surface_id,
1007 )?)
1008 } else {
1009 None
1010 };
1011
1012 Ok(IdentifyResult { focused, caller })
1013}
1014
1015fn focused_identify_context(model: &AppModel) -> Result<IdentifyContext, DomainError> {
1016 let window_id = model.active_window;
1017 let workspace = model
1018 .active_workspace()
1019 .ok_or(DomainError::InvalidOperation("app has no active workspace"))?;
1020 let pane = workspace
1021 .panes
1022 .get(&workspace.active_pane)
1023 .ok_or(DomainError::MissingPane(workspace.active_pane))?;
1024 let surface = pane
1025 .surfaces
1026 .get(&pane.active_surface)
1027 .ok_or(DomainError::MissingSurface(pane.active_surface))?;
1028
1029 Ok(identify_context_from_parts(
1030 window_id, workspace, pane.id, surface,
1031 ))
1032}
1033
1034fn resolve_identify_context(
1035 model: &AppModel,
1036 workspace_id: Option<WorkspaceId>,
1037 pane_id: Option<PaneId>,
1038 surface_id: Option<SurfaceId>,
1039) -> Result<IdentifyContext, DomainError> {
1040 let window_id = model.active_window;
1041
1042 if let Some(surface_id) = surface_id {
1043 for (candidate_workspace_id, workspace) in &model.workspaces {
1044 if workspace_id.is_some_and(|expected| expected != *candidate_workspace_id) {
1045 continue;
1046 }
1047 for (candidate_pane_id, pane) in &workspace.panes {
1048 if pane_id.is_some_and(|expected| expected != *candidate_pane_id) {
1049 continue;
1050 }
1051 if let Some(surface) = pane.surfaces.get(&surface_id) {
1052 return Ok(identify_context_from_parts(
1053 window_id,
1054 workspace,
1055 *candidate_pane_id,
1056 surface,
1057 ));
1058 }
1059 }
1060 }
1061 return Err(DomainError::MissingSurface(surface_id));
1062 }
1063
1064 if let Some(pane_id) = pane_id {
1065 for (candidate_workspace_id, workspace) in &model.workspaces {
1066 if workspace_id.is_some_and(|expected| expected != *candidate_workspace_id) {
1067 continue;
1068 }
1069 if let Some(pane) = workspace.panes.get(&pane_id) {
1070 let surface = pane
1071 .surfaces
1072 .get(&pane.active_surface)
1073 .ok_or(DomainError::MissingSurface(pane.active_surface))?;
1074 return Ok(identify_context_from_parts(
1075 window_id, workspace, pane_id, surface,
1076 ));
1077 }
1078 }
1079 return Err(DomainError::MissingPane(pane_id));
1080 }
1081
1082 if let Some(workspace_id) = workspace_id {
1083 let workspace = model
1084 .workspaces
1085 .get(&workspace_id)
1086 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1087 let pane = workspace
1088 .panes
1089 .get(&workspace.active_pane)
1090 .ok_or(DomainError::MissingPane(workspace.active_pane))?;
1091 let surface = pane
1092 .surfaces
1093 .get(&pane.active_surface)
1094 .ok_or(DomainError::MissingSurface(pane.active_surface))?;
1095 return Ok(identify_context_from_parts(
1096 window_id, workspace, pane.id, surface,
1097 ));
1098 }
1099
1100 focused_identify_context(model)
1101}
1102
1103fn identify_context_from_parts(
1104 window_id: WindowId,
1105 workspace: &taskers_domain::Workspace,
1106 pane_id: PaneId,
1107 surface: &SurfaceRecord,
1108) -> IdentifyContext {
1109 IdentifyContext {
1110 window_id,
1111 workspace_id: workspace.id,
1112 workspace_label: workspace.label.clone(),
1113 workspace_window_id: workspace.window_for_pane(pane_id),
1114 pane_id,
1115 surface_id: surface.id,
1116 surface_kind: surface.kind.clone(),
1117 title: normalized_value(surface.metadata.title.as_deref()),
1118 cwd: normalized_value(surface.metadata.cwd.as_deref()),
1119 url: normalized_value(surface.metadata.url.as_deref()),
1120 loading: matches!(surface.kind, PaneKind::Browser).then_some(false),
1121 }
1122}
1123
1124fn normalized_value(value: Option<&str>) -> Option<String> {
1125 value
1126 .map(str::trim)
1127 .filter(|value| !value.is_empty())
1128 .map(str::to_owned)
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133 use taskers_domain::{AppModel, BrowserProfileMode, PaneKind, SignalEvent, SignalKind};
1134
1135 use crate::{
1136 ControlCommand, ControlQuery, ControlResponse, ScreenshotCommand, ScreenshotTarget,
1137 };
1138
1139 use super::InMemoryController;
1140
1141 #[test]
1142 fn revision_increments_for_mutations_but_not_queries() {
1143 let controller = InMemoryController::new(AppModel::new("Main"));
1144 assert_eq!(controller.revision(), 0);
1145
1146 controller
1147 .handle(ControlCommand::QueryStatus {
1148 query: ControlQuery::All,
1149 })
1150 .expect("query status");
1151 assert_eq!(controller.revision(), 0);
1152
1153 controller
1154 .handle(ControlCommand::CreateWorkspace {
1155 label: "Docs".into(),
1156 })
1157 .expect("create workspace");
1158 assert_eq!(controller.revision(), 1);
1159 assert_eq!(controller.snapshot().revision, 1);
1160 }
1161
1162 #[test]
1163 fn revision_increments_for_signal_mutations() {
1164 let controller = InMemoryController::new(AppModel::new("Main"));
1165 let snapshot = controller.snapshot();
1166 let workspace = snapshot.model.active_workspace().expect("workspace");
1167
1168 controller
1169 .handle(ControlCommand::EmitSignal {
1170 workspace_id: workspace.id,
1171 pane_id: workspace.active_pane,
1172 surface_id: None,
1173 event: SignalEvent::new("pty", SignalKind::Progress, Some("Running".into())),
1174 })
1175 .expect("emit signal");
1176
1177 assert_eq!(controller.revision(), 1);
1178 }
1179
1180 #[test]
1181 fn create_workspace_window_applies_preferred_column_width_when_requested() {
1182 let controller = InMemoryController::new(AppModel::new("Main"));
1183 let snapshot = controller.snapshot();
1184 let workspace_id = snapshot.model.active_workspace_id().expect("workspace");
1185
1186 controller
1187 .handle(ControlCommand::CreateWorkspaceWindow {
1188 workspace_id,
1189 direction: taskers_domain::Direction::Right,
1190 preferred_column_width: Some(taskers_domain::MIN_WORKSPACE_WINDOW_WIDTH),
1191 preferred_window_height: None,
1192 })
1193 .expect("create workspace window");
1194
1195 let snapshot = controller.snapshot();
1196 let workspace = snapshot
1197 .model
1198 .workspaces
1199 .get(&workspace_id)
1200 .expect("workspace");
1201 let active_column_id = workspace.active_column_id().expect("active column");
1202 let active_column = workspace
1203 .columns
1204 .get(&active_column_id)
1205 .expect("active column record");
1206
1207 assert_eq!(
1208 active_column.width,
1209 taskers_domain::MIN_WORKSPACE_WINDOW_WIDTH
1210 );
1211 }
1212
1213 #[test]
1214 fn surface_signals_follow_a_moved_surface_even_with_stale_pane_context() {
1215 let controller = InMemoryController::new(AppModel::new("Main"));
1216 let snapshot = controller.snapshot();
1217 let source_workspace = snapshot.model.active_workspace().expect("workspace");
1218 let source_workspace_id = source_workspace.id;
1219 let source_pane_id = source_workspace.active_pane;
1220
1221 controller
1222 .handle(ControlCommand::CreateSurface {
1223 workspace_id: source_workspace_id,
1224 pane_id: source_pane_id,
1225 kind: PaneKind::Browser,
1226 browser_profile_mode: Some(BrowserProfileMode::PersistentDefault),
1227 })
1228 .expect("create moved surface");
1229 let moved_surface_id = controller
1230 .snapshot()
1231 .model
1232 .workspaces
1233 .get(&source_workspace_id)
1234 .and_then(|workspace| workspace.panes.get(&source_pane_id))
1235 .map(|pane| pane.active_surface)
1236 .expect("moved surface");
1237
1238 controller
1239 .handle(ControlCommand::CreateWorkspace {
1240 label: "Docs".into(),
1241 })
1242 .expect("create target workspace");
1243 let target_workspace_id = controller
1244 .snapshot()
1245 .model
1246 .active_workspace_id()
1247 .expect("target workspace");
1248
1249 controller
1250 .handle(ControlCommand::MoveSurfaceToWorkspace {
1251 source_workspace_id,
1252 source_pane_id,
1253 surface_id: moved_surface_id,
1254 target_workspace_id,
1255 })
1256 .expect("move surface");
1257
1258 controller
1259 .handle(ControlCommand::EmitSignal {
1260 workspace_id: source_workspace_id,
1261 pane_id: source_pane_id,
1262 surface_id: Some(moved_surface_id),
1263 event: SignalEvent::new("pty", SignalKind::Progress, Some("Running".into())),
1264 })
1265 .expect("emit moved surface signal");
1266
1267 let snapshot = controller.snapshot();
1268 let target_surface = snapshot
1269 .model
1270 .workspaces
1271 .values()
1272 .flat_map(|workspace| {
1273 workspace.panes.values().flat_map(move |pane| {
1274 pane.surfaces
1275 .values()
1276 .map(move |surface| (workspace, pane, surface))
1277 })
1278 })
1279 .find(|(_, _, surface)| surface.id == moved_surface_id)
1280 .expect("target surface");
1281
1282 assert_eq!(target_surface.0.id, target_workspace_id);
1283 assert_eq!(
1284 target_surface.2.attention,
1285 taskers_domain::AttentionState::Busy
1286 );
1287 assert_eq!(
1288 snapshot
1289 .model
1290 .workspaces
1291 .get(&source_workspace_id)
1292 .and_then(|workspace| workspace.panes.get(&source_pane_id))
1293 .and_then(|pane| pane.active_surface())
1294 .map(|surface| surface.attention),
1295 Some(taskers_domain::AttentionState::Normal)
1296 );
1297 }
1298
1299 #[test]
1300 fn surface_agent_start_follows_a_moved_surface_even_with_stale_pane_context() {
1301 let controller = InMemoryController::new(AppModel::new("Main"));
1302 let snapshot = controller.snapshot();
1303 let source_workspace = snapshot.model.active_workspace().expect("workspace");
1304 let source_workspace_id = source_workspace.id;
1305 let source_pane_id = source_workspace.active_pane;
1306
1307 controller
1308 .handle(ControlCommand::CreateSurface {
1309 workspace_id: source_workspace_id,
1310 pane_id: source_pane_id,
1311 kind: PaneKind::Browser,
1312 browser_profile_mode: Some(BrowserProfileMode::PersistentDefault),
1313 })
1314 .expect("create moved surface");
1315 let moved_surface_id = controller
1316 .snapshot()
1317 .model
1318 .workspaces
1319 .get(&source_workspace_id)
1320 .and_then(|workspace| workspace.panes.get(&source_pane_id))
1321 .map(|pane| pane.active_surface)
1322 .expect("moved surface");
1323
1324 controller
1325 .handle(ControlCommand::CreateWorkspace {
1326 label: "Docs".into(),
1327 })
1328 .expect("create target workspace");
1329 let target_workspace_id = controller
1330 .snapshot()
1331 .model
1332 .active_workspace_id()
1333 .expect("target workspace");
1334
1335 controller
1336 .handle(ControlCommand::MoveSurfaceToWorkspace {
1337 source_workspace_id,
1338 source_pane_id,
1339 surface_id: moved_surface_id,
1340 target_workspace_id,
1341 })
1342 .expect("move surface");
1343
1344 controller
1345 .handle(ControlCommand::StartSurfaceAgentSession {
1346 workspace_id: source_workspace_id,
1347 pane_id: source_pane_id,
1348 surface_id: moved_surface_id,
1349 agent_kind: "codex".into(),
1350 })
1351 .expect("start moved surface agent session");
1352
1353 let snapshot = controller.snapshot();
1354 let target_surface = snapshot
1355 .model
1356 .workspaces
1357 .values()
1358 .flat_map(|workspace| {
1359 workspace.panes.values().flat_map(move |pane| {
1360 pane.surfaces
1361 .values()
1362 .map(move |surface| (workspace, pane, surface))
1363 })
1364 })
1365 .find(|(_, _, surface)| surface.id == moved_surface_id)
1366 .expect("target surface");
1367
1368 assert_eq!(target_surface.0.id, target_workspace_id);
1369 assert!(target_surface.2.agent_process.is_some());
1370 assert!(target_surface.2.agent_session.is_none());
1371 assert_eq!(
1372 target_surface.2.metadata.agent_kind.as_deref(),
1373 Some("codex")
1374 );
1375 assert!(target_surface.2.metadata.agent_active);
1376 assert!(
1377 snapshot
1378 .model
1379 .workspaces
1380 .get(&source_workspace_id)
1381 .and_then(|workspace| workspace.panes.get(&source_pane_id))
1382 .and_then(|pane| pane.active_surface())
1383 .and_then(|surface| surface.agent_process.as_ref())
1384 .is_none()
1385 );
1386 }
1387
1388 #[test]
1389 fn surface_agent_stop_follows_a_moved_surface_even_with_stale_pane_context() {
1390 let controller = InMemoryController::new(AppModel::new("Main"));
1391 let snapshot = controller.snapshot();
1392 let source_workspace = snapshot.model.active_workspace().expect("workspace");
1393 let source_workspace_id = source_workspace.id;
1394 let source_pane_id = source_workspace.active_pane;
1395
1396 let moved_surface_id = source_workspace
1397 .panes
1398 .get(&source_pane_id)
1399 .and_then(|pane| pane.active_surface())
1400 .map(|surface| surface.id)
1401 .expect("surface");
1402
1403 controller
1404 .handle(ControlCommand::StartSurfaceAgentSession {
1405 workspace_id: source_workspace_id,
1406 pane_id: source_pane_id,
1407 surface_id: moved_surface_id,
1408 agent_kind: "codex".into(),
1409 })
1410 .expect("start surface agent session");
1411
1412 controller
1413 .handle(ControlCommand::CreateWorkspace {
1414 label: "Docs".into(),
1415 })
1416 .expect("create target workspace");
1417 let target_workspace_id = controller
1418 .snapshot()
1419 .model
1420 .active_workspace_id()
1421 .expect("target workspace");
1422
1423 controller
1424 .handle(ControlCommand::MoveSurfaceToWorkspace {
1425 source_workspace_id,
1426 source_pane_id,
1427 surface_id: moved_surface_id,
1428 target_workspace_id,
1429 })
1430 .expect("move surface");
1431
1432 controller
1433 .handle(ControlCommand::StopSurfaceAgentSession {
1434 workspace_id: source_workspace_id,
1435 pane_id: source_pane_id,
1436 surface_id: moved_surface_id,
1437 exit_status: 1,
1438 })
1439 .expect("stop moved surface agent session");
1440
1441 let snapshot = controller.snapshot();
1442 let target_surface = snapshot
1443 .model
1444 .workspaces
1445 .values()
1446 .flat_map(|workspace| {
1447 workspace.panes.values().flat_map(move |pane| {
1448 pane.surfaces
1449 .values()
1450 .map(move |surface| (workspace, pane, surface))
1451 })
1452 })
1453 .find(|(_, _, surface)| surface.id == moved_surface_id)
1454 .expect("target surface");
1455
1456 assert_eq!(target_surface.0.id, target_workspace_id);
1457 assert!(target_surface.2.agent_process.is_none());
1458 assert!(target_surface.2.agent_session.is_none());
1459 assert_eq!(
1460 target_surface.2.attention,
1461 taskers_domain::AttentionState::Error
1462 );
1463 assert!(
1464 snapshot
1465 .model
1466 .workspaces
1467 .get(&source_workspace_id)
1468 .and_then(|workspace| workspace.panes.get(&source_pane_id))
1469 .and_then(|pane| pane.active_surface())
1470 .and_then(|surface| surface.agent_process.as_ref())
1471 .is_none()
1472 );
1473 }
1474
1475 #[test]
1476 fn identify_returns_focused_context_and_optional_caller() {
1477 let controller = InMemoryController::new(AppModel::new("Main"));
1478 let snapshot = controller.snapshot();
1479 let workspace = snapshot.model.active_workspace().expect("workspace");
1480 let pane = workspace
1481 .panes
1482 .get(&workspace.active_pane)
1483 .expect("active pane");
1484 let surface = pane.active_surface().expect("active surface");
1485
1486 let response = controller
1487 .handle(ControlCommand::QueryStatus {
1488 query: ControlQuery::Identify {
1489 workspace_id: None,
1490 pane_id: None,
1491 surface_id: None,
1492 },
1493 })
1494 .expect("identify focused");
1495 let ControlResponse::Identify { result } = response else {
1496 panic!("unexpected identify response");
1497 };
1498 assert_eq!(result.focused.workspace_id, workspace.id);
1499 assert_eq!(result.focused.pane_id, workspace.active_pane);
1500 assert_eq!(result.focused.surface_id, surface.id);
1501 assert_eq!(result.focused.surface_kind, PaneKind::Terminal);
1502 assert!(result.caller.is_none());
1503
1504 let response = controller
1505 .handle(ControlCommand::QueryStatus {
1506 query: ControlQuery::Identify {
1507 workspace_id: Some(workspace.id),
1508 pane_id: Some(workspace.active_pane),
1509 surface_id: Some(surface.id),
1510 },
1511 })
1512 .expect("identify caller");
1513 let ControlResponse::Identify { result } = response else {
1514 panic!("unexpected identify response");
1515 };
1516 let caller = result.caller.expect("caller context");
1517 assert_eq!(caller.workspace_id, workspace.id);
1518 assert_eq!(caller.pane_id, workspace.active_pane);
1519 assert_eq!(caller.surface_id, surface.id);
1520 }
1521
1522 #[test]
1523 fn create_surface_applies_requested_browser_profile_mode() {
1524 let controller = InMemoryController::new(AppModel::new("Main"));
1525 let snapshot = controller.snapshot();
1526 let workspace = snapshot.model.active_workspace().expect("workspace");
1527
1528 controller
1529 .handle(ControlCommand::CreateSurface {
1530 workspace_id: workspace.id,
1531 pane_id: workspace.active_pane,
1532 kind: PaneKind::Browser,
1533 browser_profile_mode: Some(BrowserProfileMode::Ephemeral),
1534 })
1535 .expect("create browser surface");
1536
1537 let snapshot = controller.snapshot();
1538 let browser_surface = snapshot
1539 .model
1540 .workspaces
1541 .get(&workspace.id)
1542 .and_then(|workspace| workspace.panes.get(&workspace.active_pane))
1543 .and_then(|pane| pane.active_surface())
1544 .expect("browser surface");
1545
1546 assert_eq!(
1547 browser_surface.metadata.browser_profile_mode,
1548 BrowserProfileMode::Ephemeral
1549 );
1550 }
1551
1552 #[test]
1553 fn screenshot_commands_require_live_host() {
1554 let controller = InMemoryController::new(AppModel::new("Main"));
1555 let snapshot = controller.snapshot();
1556 let workspace = snapshot.model.active_workspace().expect("workspace");
1557
1558 let error = controller
1559 .handle(ControlCommand::Screenshot {
1560 screenshot_command: ScreenshotCommand::Capture {
1561 target: ScreenshotTarget::WorkspaceCanvas {
1562 workspace_id: workspace.id,
1563 },
1564 path: None,
1565 },
1566 })
1567 .expect_err("screenshot should require live host");
1568
1569 assert!(
1570 error.to_string().contains("live GTK host"),
1571 "unexpected error: {error}"
1572 );
1573 }
1574}