Skip to main content

taskers_control/
controller.rs

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}