1use std::{
2 env,
3 future::pending,
4 io::{self, Write},
5 path::PathBuf,
6 process::Command as ProcessCommand,
7};
8
9use anyhow::{Context, anyhow, bail};
10use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
11use taskers_control::{
12 BrowserControlCommand, BrowserGetCommand, BrowserLoadState, BrowserPredicateCommand,
13 BrowserTarget, BrowserWaitCondition, ControlClient, ControlCommand, ControlQuery,
14 ControlResponse, InMemoryController, ScreenshotCommand, ScreenshotTarget, TerminalDebugCommand,
15 bind_socket, default_socket_path, serve,
16};
17use taskers_domain::{
18 AgentTarget, AppModel, AttentionState, BrowserProfileMode, Direction, KEYBOARD_RESIZE_STEP,
19 PaneId, PaneKind, PaneMetadataPatch, ProgressState, SignalEvent, SignalKind, SplitAxis,
20 SurfaceId, WorkspaceId, WorkspaceLogEntry,
21};
22use taskers_paths::default_terminal_socket_path;
23use taskers_runtime::TerminalSessionClient;
24use time::OffsetDateTime;
25
26#[derive(Debug, Parser)]
27#[command(name = "taskersctl")]
28#[command(about = "Local control CLI for the taskers workspace app")]
29struct Cli {
30 #[command(subcommand)]
31 command: Command,
32}
33
34fn parse_boolish(value: &str) -> Result<bool, String> {
35 match value.trim().to_ascii_lowercase().as_str() {
36 "true" | "1" | "yes" | "on" => Ok(true),
37 "false" | "0" | "no" | "off" => Ok(false),
38 _ => Err(format!("invalid boolean value: {value}")),
39 }
40}
41
42#[cfg(test)]
43mod bool_parse_tests {
44 use super::parse_boolish;
45
46 #[test]
47 fn parse_boolish_accepts_numeric_and_text_booleans() {
48 assert_eq!(parse_boolish("1"), Ok(true));
49 assert_eq!(parse_boolish("0"), Ok(false));
50 assert_eq!(parse_boolish("true"), Ok(true));
51 assert_eq!(parse_boolish("false"), Ok(false));
52 }
53}
54
55#[derive(Debug, Subcommand)]
56enum Command {
57 Serve {
58 #[arg(long)]
59 socket: Option<PathBuf>,
60 #[arg(long, default_value_t = true)]
61 demo: bool,
62 },
63 Query {
64 #[command(subcommand)]
65 query: QueryCommand,
66 },
67 Signal {
68 #[arg(long)]
69 socket: Option<PathBuf>,
70 #[arg(long)]
71 workspace: Option<WorkspaceId>,
72 #[arg(long)]
73 pane: Option<PaneId>,
74 #[arg(long)]
75 surface: Option<SurfaceId>,
76 #[arg(long)]
77 kind: CliSignalKind,
78 #[arg(long)]
79 message: Option<String>,
80 #[arg(long)]
81 title: Option<String>,
82 #[arg(long)]
83 cwd: Option<String>,
84 #[arg(long)]
85 repo: Option<String>,
86 #[arg(long)]
87 branch: Option<String>,
88 #[arg(long)]
89 agent: Option<String>,
90 #[arg(long, value_parser = parse_boolish)]
91 agent_active: Option<bool>,
92 #[arg(long)]
93 command: Option<String>,
94 #[arg(long, hide = true)]
95 source: Option<String>,
96 },
97 Notify {
98 #[arg(long)]
99 socket: Option<PathBuf>,
100 #[arg(long)]
101 workspace: Option<WorkspaceId>,
102 #[arg(long)]
103 pane: Option<PaneId>,
104 #[arg(long)]
105 surface: Option<SurfaceId>,
106 #[arg(long)]
107 title: String,
108 #[arg(long)]
109 subtitle: Option<String>,
110 #[arg(long)]
111 body: Option<String>,
112 #[arg(long = "notification-id")]
113 notification_id: Option<String>,
114 #[arg(long)]
115 agent: Option<String>,
116 },
117 Agent {
118 #[command(subcommand)]
119 command: AgentCommand,
120 },
121 Workspace {
122 #[command(subcommand)]
123 command: WorkspaceCommand,
124 },
125 AgentHook {
126 #[command(subcommand)]
127 command: AgentHookCommand,
128 },
129 Browser {
130 #[command(subcommand)]
131 command: BrowserCommand,
132 },
133 Screenshot {
134 #[command(flatten)]
135 screenshot: ScreenshotArgs,
136 },
137 Completion {
138 #[arg(value_enum)]
139 shell: CompletionShell,
140 },
141 #[command(name = "completion-query", hide = true)]
142 CompletionQuery {
143 #[command(flatten)]
144 query: CompletionQueryArgs,
145 },
146 Identify {
147 #[arg(long)]
148 socket: Option<PathBuf>,
149 #[arg(long)]
150 workspace: Option<WorkspaceId>,
151 #[arg(long)]
152 pane: Option<PaneId>,
153 #[arg(long)]
154 surface: Option<SurfaceId>,
155 },
156 Debug {
157 #[command(subcommand)]
158 command: DebugCommand,
159 },
160 Pane {
161 #[command(subcommand)]
162 command: PaneCommand,
163 },
164 Surface {
165 #[command(subcommand)]
166 command: SurfaceCommand,
167 },
168 #[command(hide = true)]
169 Session {
170 #[command(subcommand)]
171 command: SessionCommand,
172 },
173}
174
175#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
176enum CompletionShell {
177 Bash,
178 Fish,
179 Zsh,
180}
181
182#[derive(Debug, Clone, Default, Args)]
183struct CompletionQueryArgs {
184 #[arg(long)]
185 path: Option<String>,
186 #[arg(long, allow_hyphen_values = true)]
187 flag: Option<String>,
188 #[arg(long)]
189 positional: Option<usize>,
190 #[arg(long)]
191 socket: Option<PathBuf>,
192 #[arg(long)]
193 workspace: Option<WorkspaceId>,
194 #[arg(long)]
195 pane: Option<PaneId>,
196 #[arg(long)]
197 surface: Option<SurfaceId>,
198}
199
200#[derive(Debug, Subcommand)]
201enum QueryCommand {
202 Status {
203 #[arg(long)]
204 socket: Option<PathBuf>,
205 },
206 Agents {
207 #[arg(long)]
208 socket: Option<PathBuf>,
209 },
210 Notifications {
211 #[arg(long)]
212 socket: Option<PathBuf>,
213 },
214 Tree {
215 #[arg(long)]
216 socket: Option<PathBuf>,
217 },
218}
219
220#[derive(Debug, Subcommand)]
221enum WorkspaceCommand {
222 List {
223 #[arg(long)]
224 socket: Option<PathBuf>,
225 },
226 New {
227 #[arg(long)]
228 socket: Option<PathBuf>,
229 #[arg(long)]
230 label: String,
231 },
232 Switch {
233 #[arg(long)]
234 socket: Option<PathBuf>,
235 #[arg(long)]
236 workspace: WorkspaceId,
237 },
238 Rename {
239 #[arg(long)]
240 socket: Option<PathBuf>,
241 #[arg(long)]
242 workspace: WorkspaceId,
243 #[arg(long)]
244 label: String,
245 },
246 Close {
247 #[arg(long)]
248 socket: Option<PathBuf>,
249 #[arg(long)]
250 workspace: WorkspaceId,
251 },
252}
253
254#[derive(Debug, Subcommand)]
255enum AgentHookCommand {
256 SessionStart {
257 #[arg(long)]
258 socket: Option<PathBuf>,
259 #[arg(long)]
260 workspace: Option<WorkspaceId>,
261 #[arg(long)]
262 pane: Option<PaneId>,
263 #[arg(long)]
264 surface: Option<SurfaceId>,
265 #[arg(long)]
266 agent: Option<String>,
267 #[arg(long)]
268 title: Option<String>,
269 #[arg(long)]
270 message: Option<String>,
271 },
272 Active {
273 #[arg(long)]
274 socket: Option<PathBuf>,
275 #[arg(long)]
276 workspace: Option<WorkspaceId>,
277 #[arg(long)]
278 pane: Option<PaneId>,
279 #[arg(long)]
280 surface: Option<SurfaceId>,
281 #[arg(long)]
282 agent: Option<String>,
283 #[arg(long)]
284 title: Option<String>,
285 #[arg(long)]
286 message: Option<String>,
287 },
288 Progress {
289 #[arg(long)]
290 socket: Option<PathBuf>,
291 #[arg(long)]
292 workspace: Option<WorkspaceId>,
293 #[arg(long)]
294 pane: Option<PaneId>,
295 #[arg(long)]
296 surface: Option<SurfaceId>,
297 #[arg(long)]
298 agent: Option<String>,
299 #[arg(long)]
300 title: Option<String>,
301 #[arg(long)]
302 message: Option<String>,
303 },
304 Waiting {
305 #[arg(long)]
306 socket: Option<PathBuf>,
307 #[arg(long)]
308 workspace: Option<WorkspaceId>,
309 #[arg(long)]
310 pane: Option<PaneId>,
311 #[arg(long)]
312 surface: Option<SurfaceId>,
313 #[arg(long)]
314 agent: Option<String>,
315 #[arg(long)]
316 title: Option<String>,
317 #[arg(long)]
318 message: Option<String>,
319 },
320 Notification {
321 #[arg(long)]
322 socket: Option<PathBuf>,
323 #[arg(long)]
324 workspace: Option<WorkspaceId>,
325 #[arg(long)]
326 pane: Option<PaneId>,
327 #[arg(long)]
328 surface: Option<SurfaceId>,
329 #[arg(long)]
330 agent: Option<String>,
331 #[arg(long)]
332 title: Option<String>,
333 #[arg(long)]
334 message: Option<String>,
335 },
336 Stop {
337 #[arg(long)]
338 socket: Option<PathBuf>,
339 #[arg(long)]
340 workspace: Option<WorkspaceId>,
341 #[arg(long)]
342 pane: Option<PaneId>,
343 #[arg(long)]
344 surface: Option<SurfaceId>,
345 #[arg(long)]
346 agent: Option<String>,
347 #[arg(long)]
348 title: Option<String>,
349 #[arg(long)]
350 message: Option<String>,
351 },
352}
353
354#[derive(Debug, Subcommand)]
355enum AgentCommand {
356 Status {
357 #[command(subcommand)]
358 command: AgentStatusCommand,
359 },
360 Progress {
361 #[command(subcommand)]
362 command: AgentProgressCommand,
363 },
364 Log {
365 #[command(subcommand)]
366 command: AgentLogCommand,
367 },
368 Notify {
369 #[command(subcommand)]
370 command: AgentNotifyCommand,
371 },
372 Flash {
373 #[arg(long)]
374 socket: Option<PathBuf>,
375 #[arg(long)]
376 workspace: Option<WorkspaceId>,
377 #[arg(long)]
378 pane: Option<PaneId>,
379 #[arg(long)]
380 surface: Option<SurfaceId>,
381 },
382 FocusUnread {
383 #[arg(long)]
384 socket: Option<PathBuf>,
385 },
386}
387
388#[derive(Debug, Subcommand)]
389enum AgentStatusCommand {
390 Set {
391 #[arg(long)]
392 socket: Option<PathBuf>,
393 #[arg(long)]
394 workspace: Option<WorkspaceId>,
395 #[arg(long)]
396 text: String,
397 },
398 Clear {
399 #[arg(long)]
400 socket: Option<PathBuf>,
401 #[arg(long)]
402 workspace: Option<WorkspaceId>,
403 },
404}
405
406#[derive(Debug, Subcommand)]
407enum AgentProgressCommand {
408 Set {
409 #[arg(long)]
410 socket: Option<PathBuf>,
411 #[arg(long)]
412 workspace: Option<WorkspaceId>,
413 #[arg(long)]
414 value: u16,
415 #[arg(long)]
416 label: Option<String>,
417 },
418 Clear {
419 #[arg(long)]
420 socket: Option<PathBuf>,
421 #[arg(long)]
422 workspace: Option<WorkspaceId>,
423 },
424}
425
426#[derive(Debug, Subcommand)]
427enum AgentLogCommand {
428 Append {
429 #[arg(long)]
430 socket: Option<PathBuf>,
431 #[arg(long)]
432 workspace: Option<WorkspaceId>,
433 #[arg(long)]
434 message: String,
435 #[arg(long)]
436 source: Option<String>,
437 },
438 List {
439 #[arg(long)]
440 socket: Option<PathBuf>,
441 #[arg(long)]
442 workspace: Option<WorkspaceId>,
443 },
444 Clear {
445 #[arg(long)]
446 socket: Option<PathBuf>,
447 #[arg(long)]
448 workspace: Option<WorkspaceId>,
449 },
450}
451
452#[derive(Debug, Subcommand)]
453enum AgentNotifyCommand {
454 Create {
455 #[arg(long)]
456 socket: Option<PathBuf>,
457 #[arg(long)]
458 workspace: Option<WorkspaceId>,
459 #[arg(long)]
460 pane: Option<PaneId>,
461 #[arg(long)]
462 surface: Option<SurfaceId>,
463 #[arg(long, value_enum, default_value_t = CliAgentTargetScope::Surface)]
464 scope: CliAgentTargetScope,
465 #[arg(long)]
466 title: Option<String>,
467 #[arg(long)]
468 subtitle: Option<String>,
469 #[arg(long = "notification-id")]
470 notification_id: Option<String>,
471 #[arg(long)]
472 message: String,
473 #[arg(long, value_enum, default_value_t = CliAttentionState::Waiting)]
474 state: CliAttentionState,
475 },
476 List {
477 #[arg(long)]
478 socket: Option<PathBuf>,
479 #[arg(long)]
480 workspace: Option<WorkspaceId>,
481 },
482 Clear {
483 #[arg(long)]
484 socket: Option<PathBuf>,
485 #[arg(long)]
486 workspace: Option<WorkspaceId>,
487 #[arg(long)]
488 pane: Option<PaneId>,
489 #[arg(long)]
490 surface: Option<SurfaceId>,
491 #[arg(long, value_enum, default_value_t = CliAgentTargetScope::Surface)]
492 scope: CliAgentTargetScope,
493 },
494}
495
496#[derive(Debug, Subcommand)]
497enum BrowserCommand {
498 Open {
499 #[arg(long)]
500 socket: Option<PathBuf>,
501 #[arg(long)]
502 workspace: Option<WorkspaceId>,
503 #[arg(long)]
504 pane: Option<PaneId>,
505 #[arg(long)]
506 url: Option<String>,
507 #[arg(long, default_value_t = false)]
508 ephemeral: bool,
509 },
510 Navigate {
511 #[command(flatten)]
512 browser: BrowserSurfaceArgs,
513 #[arg(long)]
514 url: String,
515 },
516 Back {
517 #[command(flatten)]
518 browser: BrowserSurfaceArgs,
519 },
520 Forward {
521 #[command(flatten)]
522 browser: BrowserSurfaceArgs,
523 },
524 Reload {
525 #[command(flatten)]
526 browser: BrowserSurfaceArgs,
527 },
528 Snapshot {
529 #[command(flatten)]
530 browser: BrowserSurfaceArgs,
531 },
532 Eval {
533 #[command(flatten)]
534 browser: BrowserSurfaceArgs,
535 #[arg(long)]
536 script: String,
537 },
538 Wait {
539 #[command(flatten)]
540 browser: BrowserSurfaceArgs,
541 #[arg(long)]
542 selector: Option<String>,
543 #[arg(long)]
544 text: Option<String>,
545 #[arg(long)]
546 url_contains: Option<String>,
547 #[arg(long, value_enum)]
548 load_state: Option<CliBrowserLoadState>,
549 #[arg(long)]
550 script: Option<String>,
551 #[arg(long)]
552 delay_ms: Option<u64>,
553 #[arg(long, default_value_t = 3_000)]
554 timeout_ms: u64,
555 #[arg(long, default_value_t = 100)]
556 poll_interval_ms: u64,
557 },
558 Click {
559 #[command(flatten)]
560 browser: BrowserSurfaceArgs,
561 #[command(flatten)]
562 target: BrowserTargetArgs,
563 #[arg(long, default_value_t = false)]
564 snapshot_after: bool,
565 },
566 Dblclick {
567 #[command(flatten)]
568 browser: BrowserSurfaceArgs,
569 #[command(flatten)]
570 target: BrowserTargetArgs,
571 #[arg(long, default_value_t = false)]
572 snapshot_after: bool,
573 },
574 Type {
575 #[command(flatten)]
576 browser: BrowserSurfaceArgs,
577 #[command(flatten)]
578 target: BrowserTargetArgs,
579 #[arg(long)]
580 text: String,
581 #[arg(long, default_value_t = false)]
582 snapshot_after: bool,
583 },
584 Fill {
585 #[command(flatten)]
586 browser: BrowserSurfaceArgs,
587 #[command(flatten)]
588 target: BrowserTargetArgs,
589 #[arg(long)]
590 text: String,
591 #[arg(long, default_value_t = false)]
592 snapshot_after: bool,
593 },
594 Press {
595 #[command(flatten)]
596 browser: BrowserSurfaceArgs,
597 #[command(flatten)]
598 target: BrowserOptionalTargetArgs,
599 #[arg(long)]
600 key: String,
601 #[arg(long, default_value_t = false)]
602 snapshot_after: bool,
603 },
604 Keydown {
605 #[command(flatten)]
606 browser: BrowserSurfaceArgs,
607 #[command(flatten)]
608 target: BrowserOptionalTargetArgs,
609 #[arg(long)]
610 key: String,
611 #[arg(long, default_value_t = false)]
612 snapshot_after: bool,
613 },
614 Keyup {
615 #[command(flatten)]
616 browser: BrowserSurfaceArgs,
617 #[command(flatten)]
618 target: BrowserOptionalTargetArgs,
619 #[arg(long)]
620 key: String,
621 #[arg(long, default_value_t = false)]
622 snapshot_after: bool,
623 },
624 Hover {
625 #[command(flatten)]
626 browser: BrowserSurfaceArgs,
627 #[command(flatten)]
628 target: BrowserTargetArgs,
629 #[arg(long, default_value_t = false)]
630 snapshot_after: bool,
631 },
632 Focus {
633 #[command(flatten)]
634 browser: BrowserSurfaceArgs,
635 #[command(flatten)]
636 target: BrowserTargetArgs,
637 #[arg(long, default_value_t = false)]
638 snapshot_after: bool,
639 },
640 Check {
641 #[command(flatten)]
642 browser: BrowserSurfaceArgs,
643 #[command(flatten)]
644 target: BrowserTargetArgs,
645 #[arg(long, default_value_t = false)]
646 snapshot_after: bool,
647 },
648 Uncheck {
649 #[command(flatten)]
650 browser: BrowserSurfaceArgs,
651 #[command(flatten)]
652 target: BrowserTargetArgs,
653 #[arg(long, default_value_t = false)]
654 snapshot_after: bool,
655 },
656 Select {
657 #[command(flatten)]
658 browser: BrowserSurfaceArgs,
659 #[command(flatten)]
660 target: BrowserTargetArgs,
661 #[arg(long = "value")]
662 values: Vec<String>,
663 #[arg(long, default_value_t = false)]
664 snapshot_after: bool,
665 },
666 Scroll {
667 #[command(flatten)]
668 browser: BrowserSurfaceArgs,
669 #[command(flatten)]
670 target: BrowserOptionalTargetArgs,
671 #[arg(long, default_value_t = 0)]
672 dx: i32,
673 #[arg(long, default_value_t = 0)]
674 dy: i32,
675 #[arg(long, default_value_t = false)]
676 snapshot_after: bool,
677 },
678 ScrollIntoView {
679 #[command(flatten)]
680 browser: BrowserSurfaceArgs,
681 #[command(flatten)]
682 target: BrowserTargetArgs,
683 #[arg(long, default_value_t = false)]
684 snapshot_after: bool,
685 },
686 Get {
687 #[command(flatten)]
688 browser: BrowserSurfaceArgs,
689 #[command(subcommand)]
690 command: BrowserGetSubcommand,
691 },
692 Is {
693 #[command(flatten)]
694 browser: BrowserSurfaceArgs,
695 #[command(subcommand)]
696 command: BrowserIsSubcommand,
697 },
698 Screenshot {
699 #[command(flatten)]
700 browser: BrowserSurfaceArgs,
701 #[arg(long)]
702 out: Option<String>,
703 #[arg(long, short = 'f', default_value_t = false)]
704 full: bool,
705 },
706 FocusWebview {
707 #[command(flatten)]
708 browser: BrowserSurfaceArgs,
709 },
710 IsWebviewFocused {
711 #[command(flatten)]
712 browser: BrowserSurfaceArgs,
713 },
714 ClearData {
715 #[command(flatten)]
716 browser: BrowserSurfaceArgs,
717 #[arg(long)]
718 origin_filter: Option<String>,
719 },
720}
721
722#[derive(Debug, Subcommand)]
723enum DebugCommand {
724 Terminal {
725 #[command(subcommand)]
726 command: TerminalDebugCliCommand,
727 },
728}
729
730#[derive(Debug, Subcommand)]
731enum TerminalDebugCliCommand {
732 IsFocused {
733 #[command(flatten)]
734 terminal: TerminalSurfaceArgs,
735 },
736 ReadText {
737 #[command(flatten)]
738 terminal: TerminalSurfaceArgs,
739 #[arg(long)]
740 tail_lines: Option<usize>,
741 },
742 RenderStats {
743 #[command(flatten)]
744 terminal: TerminalSurfaceArgs,
745 },
746}
747
748#[derive(Debug, Clone, Args)]
749struct BrowserSurfaceArgs {
750 #[arg(long)]
751 socket: Option<PathBuf>,
752 #[arg(long)]
753 workspace: Option<WorkspaceId>,
754 #[arg(long)]
755 pane: Option<PaneId>,
756 #[arg(long)]
757 surface: Option<SurfaceId>,
758}
759
760#[derive(Debug, Clone, Args)]
761struct TerminalSurfaceArgs {
762 #[arg(long)]
763 socket: Option<PathBuf>,
764 #[arg(long)]
765 workspace: Option<WorkspaceId>,
766 #[arg(long)]
767 pane: Option<PaneId>,
768 #[arg(long)]
769 surface: Option<SurfaceId>,
770}
771
772#[derive(Debug, Clone, Args)]
773struct ScreenshotArgs {
774 #[arg(long)]
775 socket: Option<PathBuf>,
776 #[arg(
777 long,
778 value_enum,
779 help = "Taskers-owned screenshot target. V1 supports surface, pane, workspace_window, and workspace_canvas; app_window capture is deferred."
780 )]
781 target: CliScreenshotTarget,
782 #[arg(long)]
783 workspace: Option<WorkspaceId>,
784 #[arg(long)]
785 pane: Option<PaneId>,
786 #[arg(long)]
787 surface: Option<SurfaceId>,
788 #[arg(long)]
789 out: Option<String>,
790}
791
792#[derive(Debug, Clone, Copy, ValueEnum)]
793enum CliScreenshotTarget {
794 Surface,
795 Pane,
796 #[value(name = "workspace_window", alias = "workspace-window")]
797 WorkspaceWindow,
798 #[value(name = "workspace_canvas", alias = "workspace-canvas")]
799 WorkspaceCanvas,
800}
801
802#[derive(Debug, Clone, Args)]
803struct BrowserTargetArgs {
804 #[arg(long = "ref")]
805 reference: Option<String>,
806 #[arg(long)]
807 selector: Option<String>,
808}
809
810#[derive(Debug, Clone, Args)]
811struct BrowserOptionalTargetArgs {
812 #[arg(long = "ref")]
813 reference: Option<String>,
814 #[arg(long)]
815 selector: Option<String>,
816}
817
818#[derive(Debug, Clone, Copy, ValueEnum)]
819enum CliBrowserLoadState {
820 Started,
821 Redirected,
822 Committed,
823 Finished,
824}
825
826#[derive(Debug, Subcommand)]
827enum BrowserGetSubcommand {
828 Url,
829 Title,
830 Text {
831 #[command(flatten)]
832 target: BrowserTargetArgs,
833 },
834 Html {
835 #[command(flatten)]
836 target: BrowserTargetArgs,
837 },
838 Value {
839 #[command(flatten)]
840 target: BrowserTargetArgs,
841 },
842 Attr {
843 #[command(flatten)]
844 target: BrowserTargetArgs,
845 #[arg(long)]
846 name: String,
847 },
848 Count {
849 #[arg(long)]
850 selector: String,
851 },
852 Box {
853 #[command(flatten)]
854 target: BrowserTargetArgs,
855 },
856 Styles {
857 #[command(flatten)]
858 target: BrowserTargetArgs,
859 #[arg(long = "property")]
860 properties: Vec<String>,
861 },
862}
863
864#[derive(Debug, Subcommand)]
865enum BrowserIsSubcommand {
866 Visible {
867 #[command(flatten)]
868 target: BrowserTargetArgs,
869 },
870 Enabled {
871 #[command(flatten)]
872 target: BrowserTargetArgs,
873 },
874 Checked {
875 #[command(flatten)]
876 target: BrowserTargetArgs,
877 },
878}
879
880#[derive(Debug, Subcommand)]
881enum PaneCommand {
882 NewWindow {
883 #[arg(long)]
884 socket: Option<PathBuf>,
885 #[arg(long)]
886 workspace: WorkspaceId,
887 #[arg(long, value_enum, default_value_t = CliDirection::Right)]
888 direction: CliDirection,
889 },
890 Split {
891 #[arg(long)]
892 socket: Option<PathBuf>,
893 #[arg(long)]
894 workspace: WorkspaceId,
895 #[arg(long)]
896 pane: Option<PaneId>,
897 #[arg(long, value_enum, default_value_t = CliAxis::Vertical)]
898 axis: CliAxis,
899 #[arg(long, value_enum, default_value_t = CliPaneKind::Terminal)]
900 kind: CliPaneKind,
901 #[arg(long)]
902 url: Option<String>,
903 #[arg(long, default_value_t = false)]
904 ephemeral: bool,
905 },
906 Focus {
907 #[arg(long)]
908 socket: Option<PathBuf>,
909 #[arg(long)]
910 workspace: WorkspaceId,
911 #[arg(long)]
912 pane: PaneId,
913 },
914 FocusDirection {
915 #[arg(long)]
916 socket: Option<PathBuf>,
917 #[arg(long)]
918 workspace: WorkspaceId,
919 #[arg(long, value_enum)]
920 direction: CliDirection,
921 },
922 ResizeWindow {
923 #[arg(long)]
924 socket: Option<PathBuf>,
925 #[arg(long)]
926 workspace: WorkspaceId,
927 #[arg(long, value_enum)]
928 direction: CliDirection,
929 #[arg(long, default_value_t = KEYBOARD_RESIZE_STEP)]
930 amount: i32,
931 },
932 ResizeSplit {
933 #[arg(long)]
934 socket: Option<PathBuf>,
935 #[arg(long)]
936 workspace: WorkspaceId,
937 #[arg(long, value_enum)]
938 direction: CliDirection,
939 #[arg(long, default_value_t = KEYBOARD_RESIZE_STEP)]
940 amount: i32,
941 },
942 Close {
943 #[arg(long)]
944 socket: Option<PathBuf>,
945 #[arg(long)]
946 workspace: WorkspaceId,
947 #[arg(long)]
948 pane: PaneId,
949 },
950 Update {
951 #[arg(long)]
952 socket: Option<PathBuf>,
953 #[arg(long)]
954 pane: PaneId,
955 #[arg(long)]
956 title: Option<String>,
957 #[arg(long)]
958 cwd: Option<String>,
959 #[arg(long)]
960 repo: Option<String>,
961 #[arg(long)]
962 branch: Option<String>,
963 #[arg(long)]
964 agent: Option<String>,
965 },
966}
967
968#[derive(Debug, Subcommand)]
969enum SurfaceCommand {
970 New {
971 #[arg(long)]
972 socket: Option<PathBuf>,
973 #[arg(long)]
974 workspace: WorkspaceId,
975 #[arg(long)]
976 pane: PaneId,
977 #[arg(long, value_enum, default_value_t = CliPaneKind::Terminal)]
978 kind: CliPaneKind,
979 #[arg(long)]
980 url: Option<String>,
981 #[arg(long, default_value_t = false)]
982 ephemeral: bool,
983 },
984 Focus {
985 #[arg(long)]
986 socket: Option<PathBuf>,
987 #[arg(long)]
988 workspace: WorkspaceId,
989 #[arg(long)]
990 pane: PaneId,
991 #[arg(long)]
992 surface: SurfaceId,
993 },
994 Complete {
995 #[arg(long)]
996 socket: Option<PathBuf>,
997 #[arg(long)]
998 workspace: WorkspaceId,
999 #[arg(long)]
1000 pane: PaneId,
1001 #[arg(long)]
1002 surface: SurfaceId,
1003 },
1004 AgentStart {
1005 #[arg(long)]
1006 socket: Option<PathBuf>,
1007 #[arg(long)]
1008 workspace: WorkspaceId,
1009 #[arg(long)]
1010 pane: PaneId,
1011 #[arg(long)]
1012 surface: SurfaceId,
1013 #[arg(long)]
1014 agent: String,
1015 },
1016 AgentStop {
1017 #[arg(long)]
1018 socket: Option<PathBuf>,
1019 #[arg(long)]
1020 workspace: WorkspaceId,
1021 #[arg(long)]
1022 pane: PaneId,
1023 #[arg(long)]
1024 surface: SurfaceId,
1025 #[arg(long = "exit-status")]
1026 exit_status: i32,
1027 },
1028 DismissAlert {
1029 #[arg(long)]
1030 socket: Option<PathBuf>,
1031 #[arg(long)]
1032 workspace: WorkspaceId,
1033 #[arg(long)]
1034 pane: PaneId,
1035 #[arg(long)]
1036 surface: SurfaceId,
1037 },
1038 Close {
1039 #[arg(long)]
1040 socket: Option<PathBuf>,
1041 #[arg(long)]
1042 workspace: WorkspaceId,
1043 #[arg(long)]
1044 pane: PaneId,
1045 #[arg(long)]
1046 surface: SurfaceId,
1047 },
1048}
1049
1050#[derive(Debug, Subcommand)]
1051enum SessionCommand {
1052 Attach {
1053 #[arg(long)]
1054 socket: Option<PathBuf>,
1055 #[arg(long)]
1056 session: String,
1057 #[arg(
1058 trailing_var_arg = true,
1059 allow_hyphen_values = true,
1060 num_args = 0..
1061 )]
1062 shell_args: Vec<String>,
1063 },
1064 Terminate {
1065 #[arg(long)]
1066 socket: Option<PathBuf>,
1067 #[arg(long)]
1068 session: String,
1069 },
1070}
1071
1072#[derive(Debug, Clone, Copy, ValueEnum)]
1073enum CliSignalKind {
1074 Metadata,
1075 Started,
1076 Progress,
1077 Completed,
1078 WaitingInput,
1079 Error,
1080 Notification,
1081}
1082
1083#[derive(Debug, Clone, Copy, ValueEnum)]
1084enum CliAxis {
1085 Horizontal,
1086 Vertical,
1087}
1088
1089#[derive(Debug, Clone, Copy, ValueEnum)]
1090enum CliDirection {
1091 Left,
1092 Right,
1093 Up,
1094 Down,
1095}
1096
1097#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
1098enum CliPaneKind {
1099 Terminal,
1100 Browser,
1101}
1102
1103#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
1104enum CliAgentTargetScope {
1105 Workspace,
1106 Pane,
1107 Surface,
1108}
1109
1110#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
1111enum CliAttentionState {
1112 Normal,
1113 Busy,
1114 Completed,
1115 Waiting,
1116 Error,
1117}
1118
1119impl From<CliSignalKind> for SignalKind {
1120 fn from(value: CliSignalKind) -> Self {
1121 match value {
1122 CliSignalKind::Metadata => SignalKind::Metadata,
1123 CliSignalKind::Started => SignalKind::Started,
1124 CliSignalKind::Progress => SignalKind::Progress,
1125 CliSignalKind::Completed => SignalKind::Completed,
1126 CliSignalKind::WaitingInput => SignalKind::WaitingInput,
1127 CliSignalKind::Error => SignalKind::Error,
1128 CliSignalKind::Notification => SignalKind::Notification,
1129 }
1130 }
1131}
1132
1133impl From<CliAxis> for SplitAxis {
1134 fn from(value: CliAxis) -> Self {
1135 match value {
1136 CliAxis::Horizontal => SplitAxis::Horizontal,
1137 CliAxis::Vertical => SplitAxis::Vertical,
1138 }
1139 }
1140}
1141
1142impl From<CliDirection> for Direction {
1143 fn from(value: CliDirection) -> Self {
1144 match value {
1145 CliDirection::Left => Direction::Left,
1146 CliDirection::Right => Direction::Right,
1147 CliDirection::Up => Direction::Up,
1148 CliDirection::Down => Direction::Down,
1149 }
1150 }
1151}
1152
1153impl From<CliPaneKind> for PaneKind {
1154 fn from(value: CliPaneKind) -> Self {
1155 match value {
1156 CliPaneKind::Terminal => PaneKind::Terminal,
1157 CliPaneKind::Browser => PaneKind::Browser,
1158 }
1159 }
1160}
1161
1162fn browser_profile_mode(ephemeral: bool) -> BrowserProfileMode {
1163 if ephemeral {
1164 BrowserProfileMode::Ephemeral
1165 } else {
1166 BrowserProfileMode::PersistentDefault
1167 }
1168}
1169
1170impl From<CliAttentionState> for AttentionState {
1171 fn from(value: CliAttentionState) -> Self {
1172 match value {
1173 CliAttentionState::Normal => AttentionState::Normal,
1174 CliAttentionState::Busy => AttentionState::Busy,
1175 CliAttentionState::Completed => AttentionState::Completed,
1176 CliAttentionState::Waiting => AttentionState::WaitingInput,
1177 CliAttentionState::Error => AttentionState::Error,
1178 }
1179 }
1180}
1181
1182impl From<CliBrowserLoadState> for BrowserLoadState {
1183 fn from(value: CliBrowserLoadState) -> Self {
1184 match value {
1185 CliBrowserLoadState::Started => BrowserLoadState::Started,
1186 CliBrowserLoadState::Redirected => BrowserLoadState::Redirected,
1187 CliBrowserLoadState::Committed => BrowserLoadState::Committed,
1188 CliBrowserLoadState::Finished => BrowserLoadState::Finished,
1189 }
1190 }
1191}
1192
1193#[derive(Debug, Clone)]
1194struct CompletionNode {
1195 path: Vec<String>,
1196 subcommands: Vec<String>,
1197 flags: Vec<String>,
1198 value_flags: Vec<String>,
1199}
1200
1201fn cli_command() -> clap::Command {
1202 Cli::command()
1203}
1204
1205fn render_completion(shell: CompletionShell) -> String {
1206 let nodes = completion_nodes(&cli_command());
1207 match shell {
1208 CompletionShell::Bash => render_bash_completion(&nodes),
1209 CompletionShell::Fish => render_fish_completion(&nodes),
1210 CompletionShell::Zsh => render_zsh_completion(&nodes),
1211 }
1212}
1213
1214fn write_completion(shell: CompletionShell, mut writer: impl Write) -> anyhow::Result<()> {
1215 writer.write_all(render_completion(shell).as_bytes())?;
1216 writer.flush()?;
1217 Ok(())
1218}
1219
1220fn completion_nodes(root: &clap::Command) -> Vec<CompletionNode> {
1221 let mut nodes = Vec::new();
1222 collect_completion_nodes(root, Vec::new(), &mut nodes);
1223 nodes
1224}
1225
1226fn collect_completion_nodes(
1227 command: &clap::Command,
1228 path: Vec<String>,
1229 nodes: &mut Vec<CompletionNode>,
1230) {
1231 let subcommands = command
1232 .get_subcommands()
1233 .filter(|subcommand| !subcommand.is_hide_set())
1234 .map(|subcommand| subcommand.get_name().to_string())
1235 .collect::<Vec<_>>();
1236 let mut flags = Vec::new();
1237 let mut value_flags = Vec::new();
1238
1239 for arg in command.get_arguments().filter(|arg| !arg.is_hide_set()) {
1240 let takes_values = arg.get_action().takes_values();
1241
1242 if let Some(long) = arg.get_long() {
1243 let flag = format!("--{long}");
1244 push_unique(&mut flags, flag.clone());
1245 if takes_values {
1246 push_unique(&mut value_flags, flag);
1247 }
1248 }
1249
1250 if let Some(short) = arg.get_short() {
1251 let flag = format!("-{short}");
1252 push_unique(&mut flags, flag.clone());
1253 if takes_values {
1254 push_unique(&mut value_flags, flag);
1255 }
1256 }
1257 }
1258
1259 nodes.push(CompletionNode {
1260 path: path.clone(),
1261 subcommands,
1262 flags,
1263 value_flags,
1264 });
1265
1266 for subcommand in command
1267 .get_subcommands()
1268 .filter(|subcommand| !subcommand.is_hide_set())
1269 {
1270 let mut child_path = path.clone();
1271 child_path.push(subcommand.get_name().to_string());
1272 collect_completion_nodes(subcommand, child_path, nodes);
1273 }
1274}
1275
1276fn push_unique(values: &mut Vec<String>, value: String) {
1277 if !values.contains(&value) {
1278 values.push(value);
1279 }
1280}
1281
1282async fn completion_query_candidates(query: &CompletionQueryArgs) -> Vec<String> {
1283 let Some(command) = completion_command_for_path(query.path.as_deref().unwrap_or_default())
1284 else {
1285 return Vec::new();
1286 };
1287
1288 let Some(arg) = completion_arg_for_query(&command, query.flag.as_deref(), query.positional)
1289 else {
1290 return Vec::new();
1291 };
1292
1293 let mut candidates = completion_static_candidates(arg);
1294 let dynamic = completion_dynamic_candidates(
1295 arg.get_id().as_str(),
1296 query.socket.clone(),
1297 query.workspace,
1298 query.pane,
1299 query.surface,
1300 )
1301 .await;
1302 for candidate in dynamic {
1303 push_unique(&mut candidates, candidate);
1304 }
1305 candidates
1306}
1307
1308fn completion_command_for_path(path: &str) -> Option<clap::Command> {
1309 let mut command = cli_command();
1310 for segment in path
1311 .split_whitespace()
1312 .filter(|segment| !segment.is_empty())
1313 {
1314 let next = {
1315 command
1316 .get_subcommands()
1317 .find(|subcommand| !subcommand.is_hide_set() && subcommand.get_name() == segment)?
1318 .clone()
1319 };
1320 command = next;
1321 }
1322 Some(command)
1323}
1324
1325fn completion_arg_for_query<'a>(
1326 command: &'a clap::Command,
1327 flag: Option<&str>,
1328 positional: Option<usize>,
1329) -> Option<&'a clap::Arg> {
1330 if let Some(flag) = flag {
1331 if let Some(long) = flag.strip_prefix("--") {
1332 return command
1333 .get_arguments()
1334 .find(|arg| !arg.is_hide_set() && arg.get_long() == Some(long));
1335 }
1336 if let Some(short) = flag.strip_prefix('-') {
1337 let mut chars = short.chars();
1338 let short = chars.next()?;
1339 if chars.next().is_some() {
1340 return None;
1341 }
1342 return command
1343 .get_arguments()
1344 .find(|arg| !arg.is_hide_set() && arg.get_short() == Some(short));
1345 }
1346 }
1347
1348 positional.and_then(|index| {
1349 command
1350 .get_positionals()
1351 .filter(|arg| !arg.is_hide_set())
1352 .nth(index)
1353 })
1354}
1355
1356fn completion_static_candidates(arg: &clap::Arg) -> Vec<String> {
1357 arg.get_possible_values()
1358 .into_iter()
1359 .filter(|value| !value.is_hide_set())
1360 .map(|value| value.get_name().to_string())
1361 .collect()
1362}
1363
1364async fn completion_dynamic_candidates(
1365 arg_id: &str,
1366 socket: Option<PathBuf>,
1367 workspace: Option<WorkspaceId>,
1368 pane: Option<PaneId>,
1369 _surface: Option<SurfaceId>,
1370) -> Vec<String> {
1371 let client = ControlClient::new(resolve_socket_path(socket));
1372 let Ok(model) = query_model(&client).await else {
1373 return Vec::new();
1374 };
1375
1376 match arg_id {
1377 "workspace" => {
1378 let mut values = model
1379 .workspaces
1380 .keys()
1381 .map(ToString::to_string)
1382 .collect::<Vec<_>>();
1383 values.sort();
1384 values
1385 }
1386 "pane" => {
1387 let Ok(workspace_id) = resolve_workspace_id_from_model(&model, workspace) else {
1388 return Vec::new();
1389 };
1390 let Some(workspace_record) = model.workspaces.get(&workspace_id) else {
1391 return Vec::new();
1392 };
1393 let mut values = workspace_record
1394 .panes
1395 .keys()
1396 .map(ToString::to_string)
1397 .collect::<Vec<_>>();
1398 values.sort();
1399 values
1400 }
1401 "surface" => {
1402 let Ok(workspace_id) = resolve_workspace_id_from_model(&model, workspace) else {
1403 return Vec::new();
1404 };
1405 let Some(workspace_record) = model.workspaces.get(&workspace_id) else {
1406 return Vec::new();
1407 };
1408 let pane_id = pane
1409 .or_else(env_pane_id)
1410 .unwrap_or(workspace_record.active_pane);
1411 let Some(pane_record) = workspace_record.panes.get(&pane_id) else {
1412 return Vec::new();
1413 };
1414 let mut values = pane_record
1415 .surfaces
1416 .keys()
1417 .map(ToString::to_string)
1418 .collect::<Vec<_>>();
1419 values.sort();
1420 values
1421 }
1422 _ => Vec::new(),
1423 }
1424}
1425
1426fn render_bash_completion(nodes: &[CompletionNode]) -> String {
1427 format!(
1428 r#"_taskersctl_subcommands() {{
1429 case "$1" in
1430{subcommands_cases} * ) ;;
1431 esac
1432}}
1433
1434_taskersctl_flags() {{
1435 case "$1" in
1436{flags_cases} * ) ;;
1437 esac
1438}}
1439
1440_taskersctl_value_flags() {{
1441 case "$1" in
1442{value_flags_cases} * ) ;;
1443 esac
1444}}
1445
1446_taskersctl_query_values() {{
1447 local path="$1" flag="$2" positional="$3" socket="$4" workspace="$5" pane="$6" surface="$7"
1448 local args=(completion-query)
1449 [[ -n "$path" ]] && args+=(--path "$path")
1450 [[ -n "$flag" ]] && args+=("--flag=$flag")
1451 [[ -n "$positional" ]] && args+=(--positional "$positional")
1452 [[ -n "$socket" ]] && args+=(--socket "$socket")
1453 [[ -n "$workspace" ]] && args+=(--workspace "$workspace")
1454 [[ -n "$pane" ]] && args+=(--pane "$pane")
1455 [[ -n "$surface" ]] && args+=(--surface "$surface")
1456 taskersctl "${{args[@]}}" 2>/dev/null
1457}}
1458
1459_taskersctl() {{
1460 local cur path subcommands flags value_flags word expect_value=0 expect_flag=""
1461 local selected_socket="" selected_workspace="" selected_pane="" selected_surface=""
1462 local positionals_used=0
1463 local i eq_flag eq_value joined
1464 local -a dynamic
1465 COMPREPLY=()
1466 cur="${{COMP_WORDS[COMP_CWORD]}}"
1467 path=""
1468
1469 for ((i=1; i<COMP_CWORD; i++)); do
1470 word="${{COMP_WORDS[i]}}"
1471 if (( expect_value )); then
1472 case "$expect_flag" in
1473 --socket) selected_socket="$word" ;;
1474 --workspace) selected_workspace="$word" ;;
1475 --pane) selected_pane="$word" ;;
1476 --surface) selected_surface="$word" ;;
1477 esac
1478 expect_value=0
1479 expect_flag=""
1480 continue
1481 fi
1482 [[ -z "$word" ]] && continue
1483
1484 if [[ "$word" == --*=* ]]; then
1485 eq_flag="${{word%%=*}}"
1486 eq_value="${{word#*=}}"
1487 case "$eq_flag" in
1488 --socket) selected_socket="$eq_value" ;;
1489 --workspace) selected_workspace="$eq_value" ;;
1490 --pane) selected_pane="$eq_value" ;;
1491 --surface) selected_surface="$eq_value" ;;
1492 esac
1493 value_flags="$(_taskersctl_value_flags "$path")"
1494 case " $value_flags " in
1495 *" $eq_flag "*) continue ;;
1496 esac
1497 fi
1498
1499 if [[ "$word" == -* ]]; then
1500 value_flags="$(_taskersctl_value_flags "$path")"
1501 case " $value_flags " in
1502 *" $word "*) expect_value=1; expect_flag="$word" ;;
1503 esac
1504 continue
1505 fi
1506
1507 subcommands="$(_taskersctl_subcommands "$path")"
1508 case " $subcommands " in
1509 *" $word "*) path="${{path:+$path }}$word" ;;
1510 *) positionals_used=$((positionals_used + 1)) ;;
1511 esac
1512 done
1513
1514 if (( expect_value )); then
1515 mapfile -t dynamic < <(_taskersctl_query_values "$path" "$expect_flag" "" "$selected_socket" "$selected_workspace" "$selected_pane" "$selected_surface")
1516 joined="${{dynamic[*]}}"
1517 COMPREPLY=( $(compgen -W "$joined" -- "$cur") )
1518 return 0
1519 fi
1520
1521 subcommands="$(_taskersctl_subcommands "$path")"
1522 flags="$(_taskersctl_flags "$path")"
1523 mapfile -t dynamic < <(_taskersctl_query_values "$path" "" "$positionals_used" "$selected_socket" "$selected_workspace" "$selected_pane" "$selected_surface")
1524 joined="${{dynamic[*]}}"
1525 if [[ "$cur" == -* ]]; then
1526 COMPREPLY=( $(compgen -W "$flags" -- "$cur") )
1527 else
1528 COMPREPLY=( $(compgen -W "$subcommands $flags $joined" -- "$cur") )
1529 fi
1530}}
1531
1532complete -F _taskersctl taskersctl
1533"#,
1534 subcommands_cases = render_bash_case_body(nodes, |node| &node.subcommands),
1535 flags_cases = render_bash_case_body(nodes, |node| &node.flags),
1536 value_flags_cases = render_bash_case_body(nodes, |node| &node.value_flags),
1537 )
1538}
1539
1540fn render_zsh_completion(nodes: &[CompletionNode]) -> String {
1541 format!(
1542 r#"#compdef taskersctl
1543
1544__taskersctl_subcommands() {{
1545 case "$1" in
1546{subcommands_cases} * ) ;;
1547 esac
1548}}
1549
1550__taskersctl_flags() {{
1551 case "$1" in
1552{flags_cases} * ) ;;
1553 esac
1554}}
1555
1556__taskersctl_value_flags() {{
1557 case "$1" in
1558{value_flags_cases} * ) ;;
1559 esac
1560}}
1561
1562__taskersctl_query_values() {{
1563 local path="$1" flag="$2" positional="$3" socket="$4" workspace="$5" pane="$6" surface="$7"
1564 local -a args
1565 args=(completion-query)
1566 [[ -n "$path" ]] && args+=(--path "$path")
1567 [[ -n "$flag" ]] && args+=("--flag=$flag")
1568 [[ -n "$positional" ]] && args+=(--positional "$positional")
1569 [[ -n "$socket" ]] && args+=(--socket "$socket")
1570 [[ -n "$workspace" ]] && args+=(--workspace "$workspace")
1571 [[ -n "$pane" ]] && args+=(--pane "$pane")
1572 [[ -n "$surface" ]] && args+=(--surface "$surface")
1573 taskersctl $args 2>/dev/null
1574}}
1575
1576_taskersctl() {{
1577 local cur path subcommands_text flags_text value_flags_text word expect_value=0 expect_flag=""
1578 local selected_socket="" selected_workspace="" selected_pane="" selected_surface=""
1579 local positionals_used=0
1580 local eq_flag eq_value
1581 local -a candidates
1582 local -a dynamic
1583 local i
1584 cur="${{words[CURRENT]}}"
1585 path=""
1586
1587 for ((i=2; i<CURRENT; i++)); do
1588 word="${{words[i]}}"
1589 if (( expect_value )); then
1590 case "$expect_flag" in
1591 --socket) selected_socket="$word" ;;
1592 --workspace) selected_workspace="$word" ;;
1593 --pane) selected_pane="$word" ;;
1594 --surface) selected_surface="$word" ;;
1595 esac
1596 expect_value=0
1597 expect_flag=""
1598 continue
1599 fi
1600 [[ -z "$word" ]] && continue
1601
1602 if [[ "$word" == --*=* ]]; then
1603 eq_flag="${{word%%=*}}"
1604 eq_value="${{word#*=}}"
1605 case "$eq_flag" in
1606 --socket) selected_socket="$eq_value" ;;
1607 --workspace) selected_workspace="$eq_value" ;;
1608 --pane) selected_pane="$eq_value" ;;
1609 --surface) selected_surface="$eq_value" ;;
1610 esac
1611 value_flags_text="$(__taskersctl_value_flags "$path")"
1612 [[ " $value_flags_text " == *" $eq_flag "* ]] && continue
1613 fi
1614
1615 if [[ "$word" == -* ]]; then
1616 value_flags_text="$(__taskersctl_value_flags "$path")"
1617 if [[ " $value_flags_text " == *" $word "* ]]; then
1618 expect_value=1
1619 expect_flag="$word"
1620 fi
1621 continue
1622 fi
1623
1624 subcommands_text="$(__taskersctl_subcommands "$path")"
1625 if [[ " $subcommands_text " == *" $word "* ]]; then
1626 path="${{path:+$path }}$word"
1627 else
1628 (( positionals_used += 1 ))
1629 fi
1630 done
1631
1632 if (( expect_value )); then
1633 candidates=("${{(@f)$(__taskersctl_query_values "$path" "$expect_flag" "" "$selected_socket" "$selected_workspace" "$selected_pane" "$selected_surface")}}")
1634 (( $#candidates )) && compadd -- $candidates
1635 return 0
1636 fi
1637
1638 subcommands_text="$(__taskersctl_subcommands "$path")"
1639 flags_text="$(__taskersctl_flags "$path")"
1640 dynamic=("${{(@f)$(__taskersctl_query_values "$path" "" "$positionals_used" "$selected_socket" "$selected_workspace" "$selected_pane" "$selected_surface")}}")
1641 if [[ "$cur" == -* ]]; then
1642 candidates=(${{=flags_text}})
1643 else
1644 candidates=(${{=subcommands_text}} ${{=flags_text}} $dynamic)
1645 fi
1646 compadd -- $candidates
1647}}
1648
1649(( $+functions[compdef] )) && compdef _taskersctl taskersctl
1650"#,
1651 subcommands_cases = render_bash_case_body(nodes, |node| &node.subcommands),
1652 flags_cases = render_bash_case_body(nodes, |node| &node.flags),
1653 value_flags_cases = render_bash_case_body(nodes, |node| &node.value_flags),
1654 )
1655}
1656
1657fn render_fish_completion(nodes: &[CompletionNode]) -> String {
1658 format!(
1659 r#"function __taskersctl_subcommands
1660 switch "$argv[1]"
1661{subcommands_cases} case '*'
1662 end
1663end
1664
1665function __taskersctl_flags
1666 switch "$argv[1]"
1667{flags_cases} case '*'
1668 end
1669end
1670
1671function __taskersctl_value_flags
1672 switch "$argv[1]"
1673{value_flags_cases} case '*'
1674 end
1675end
1676
1677function __taskersctl_query_values
1678 set -l args completion-query
1679 test -n "$argv[1]"; and set -a args --path "$argv[1]"
1680 test -n "$argv[2]"; and set -a args --flag="$argv[2]"
1681 test -n "$argv[3]"; and set -a args --positional "$argv[3]"
1682 test -n "$argv[4]"; and set -a args --socket "$argv[4]"
1683 test -n "$argv[5]"; and set -a args --workspace "$argv[5]"
1684 test -n "$argv[6]"; and set -a args --pane "$argv[6]"
1685 test -n "$argv[7]"; and set -a args --surface "$argv[7]"
1686 taskersctl $args 2>/dev/null
1687end
1688
1689function __taskersctl_complete
1690 set -l tokens (commandline -opc)
1691 set -e tokens[1]
1692 set -l path
1693 set -l expect_value 0
1694 set -l expect_flag
1695 set -l selected_socket
1696 set -l selected_workspace
1697 set -l selected_pane
1698 set -l selected_surface
1699 set -l positionals_used 0
1700
1701 for word in $tokens
1702 if test $expect_value -eq 1
1703 switch $expect_flag
1704 case --socket
1705 set selected_socket $word
1706 case --workspace
1707 set selected_workspace $word
1708 case --pane
1709 set selected_pane $word
1710 case --surface
1711 set selected_surface $word
1712 end
1713 set expect_value 0
1714 set expect_flag
1715 continue
1716 end
1717 if test -z "$word"
1718 continue
1719 end
1720
1721 if string match -qr '^--[^=]+=.*$' -- $word
1722 set -l opt (string replace -r '=.*$' '' -- $word)
1723 set -l opt_value (string replace -r '^[^=]*=' '' -- $word)
1724 switch $opt
1725 case --socket
1726 set selected_socket $opt_value
1727 case --workspace
1728 set selected_workspace $opt_value
1729 case --pane
1730 set selected_pane $opt_value
1731 case --surface
1732 set selected_surface $opt_value
1733 end
1734 set -l value_flags (__taskersctl_value_flags "$path")
1735 if contains -- $opt $value_flags
1736 continue
1737 end
1738 end
1739
1740 if string match -qr '^-' -- $word
1741 set -l value_flags (__taskersctl_value_flags "$path")
1742 if contains -- $word $value_flags
1743 set expect_value 1
1744 set expect_flag $word
1745 end
1746 continue
1747 end
1748
1749 set -l subcommands (__taskersctl_subcommands "$path")
1750 if contains -- $word $subcommands
1751 if test -n "$path"
1752 set path "$path $word"
1753 else
1754 set path "$word"
1755 end
1756 else
1757 set positionals_used (math $positionals_used + 1)
1758 end
1759 end
1760
1761 if test $expect_value -eq 1
1762 __taskersctl_query_values "$path" "$expect_flag" "" "$selected_socket" "$selected_workspace" "$selected_pane" "$selected_surface"
1763 return
1764 end
1765
1766 set -l token (commandline -ct)
1767 set -l subcommands (__taskersctl_subcommands "$path")
1768 set -l flags (__taskersctl_flags "$path")
1769 set -l dynamic (__taskersctl_query_values "$path" "" "$positionals_used" "$selected_socket" "$selected_workspace" "$selected_pane" "$selected_surface")
1770 if string match -qr '^-' -- $token
1771 printf '%s\n' $flags
1772 else
1773 printf '%s\n' $subcommands $flags $dynamic
1774 end
1775end
1776
1777complete -f -c taskersctl -a '(__taskersctl_complete)'
1778"#,
1779 subcommands_cases = render_fish_case_body(nodes, |node| &node.subcommands),
1780 flags_cases = render_fish_case_body(nodes, |node| &node.flags),
1781 value_flags_cases = render_fish_case_body(nodes, |node| &node.value_flags),
1782 )
1783}
1784
1785fn render_bash_case_body<'a>(
1786 nodes: &'a [CompletionNode],
1787 values: impl Fn(&'a CompletionNode) -> &'a [String],
1788) -> String {
1789 let mut output = String::new();
1790 for node in nodes {
1791 output.push_str(" ");
1792 output.push_str(&completion_case_key(&node.path));
1793 output.push_str(" ) printf '%s' '");
1794 output.push_str(&shell_words(values(node)));
1795 output.push_str("' ;;\n");
1796 }
1797 output
1798}
1799
1800fn render_fish_case_body<'a>(
1801 nodes: &'a [CompletionNode],
1802 values: impl Fn(&'a CompletionNode) -> &'a [String],
1803) -> String {
1804 let mut output = String::new();
1805 for node in nodes {
1806 output.push_str(" case '");
1807 output.push_str(&completion_path(&node.path));
1808 output.push_str("'\n");
1809 for value in values(node) {
1810 output.push_str(" echo '");
1811 output.push_str(value);
1812 output.push_str("'\n");
1813 }
1814 }
1815 output
1816}
1817
1818fn completion_case_key(path: &[String]) -> String {
1819 let path = completion_path(path);
1820 if path.is_empty() {
1821 "''".into()
1822 } else {
1823 format!("'{path}'")
1824 }
1825}
1826
1827fn completion_path(path: &[String]) -> String {
1828 path.join(" ")
1829}
1830
1831fn shell_words(values: &[String]) -> String {
1832 values.join(" ")
1833}
1834
1835pub async fn run() -> anyhow::Result<()> {
1836 let cli = Cli::parse();
1837
1838 match cli.command {
1839 Command::Serve { socket, demo } => {
1840 let socket = resolve_socket_path(socket);
1841 let listener = bind_socket(&socket)
1842 .with_context(|| format!("failed to bind socket at {}", socket.display()))?;
1843 let initial_model = if demo {
1844 AppModel::demo()
1845 } else {
1846 AppModel::new("Main")
1847 };
1848 let controller = InMemoryController::new(initial_model);
1849 eprintln!("serving taskers control API on {}", socket.display());
1850 serve(listener, controller, pending()).await?;
1851 }
1852 Command::Session { command } => match command {
1853 SessionCommand::Attach {
1854 socket,
1855 session,
1856 shell_args,
1857 } => {
1858 let client = TerminalSessionClient::new(resolve_terminal_socket_path(socket));
1859 client.attach_or_create(&session, &shell_args)?;
1860 }
1861 SessionCommand::Terminate { socket, session } => {
1862 let client = TerminalSessionClient::new(resolve_terminal_socket_path(socket));
1863 client.terminate_session(&session)?;
1864 }
1865 },
1866 Command::Query { query } => match query {
1867 QueryCommand::Status { socket } => {
1868 let client = ControlClient::new(resolve_socket_path(socket));
1869 let response = client
1870 .send(ControlCommand::QueryStatus {
1871 query: ControlQuery::All,
1872 })
1873 .await?;
1874 println!("{}", serde_json::to_string_pretty(&response)?);
1875 }
1876 QueryCommand::Agents { socket } => {
1877 let client = ControlClient::new(resolve_socket_path(socket));
1878 let model = query_model(&client).await?;
1879 let payload = model
1880 .workspace_summaries(model.active_window)?
1881 .into_iter()
1882 .flat_map(|workspace| {
1883 let workspace_id = workspace.workspace_id;
1884 let workspace_label = workspace.label.clone();
1885 workspace.agent_summaries.into_iter().map(move |agent| {
1886 serde_json::json!({
1887 "workspace_id": workspace_id,
1888 "workspace_label": workspace_label,
1889 "workspace_window_id": agent.workspace_window_id,
1890 "pane_id": agent.pane_id,
1891 "surface_id": agent.surface_id,
1892 "agent_kind": agent.agent_kind,
1893 "title": agent.title,
1894 "state": format!("{:?}", agent.state).to_lowercase(),
1895 "last_signal_at": agent.last_signal_at,
1896 })
1897 })
1898 })
1899 .collect::<Vec<_>>();
1900 println!("{}", serde_json::to_string_pretty(&payload)?);
1901 }
1902 QueryCommand::Notifications { socket } => {
1903 let client = ControlClient::new(resolve_socket_path(socket));
1904 let model = query_model(&client).await?;
1905 let payload = model
1906 .activity_items()
1907 .into_iter()
1908 .map(|item| {
1909 serde_json::json!({
1910 "workspace_id": item.workspace_id,
1911 "workspace_window_id": item.workspace_window_id,
1912 "pane_id": item.pane_id,
1913 "surface_id": item.surface_id,
1914 "kind": format!("{:?}", item.kind).to_lowercase(),
1915 "state": format!("{:?}", item.state).to_lowercase(),
1916 "title": item.title,
1917 "message": item.message,
1918 "created_at": item.created_at,
1919 })
1920 })
1921 .collect::<Vec<_>>();
1922 println!("{}", serde_json::to_string_pretty(&payload)?);
1923 }
1924 QueryCommand::Tree { socket } => {
1925 let client = ControlClient::new(resolve_socket_path(socket));
1926 let model = query_model(&client).await?;
1927 println!("{}", serde_json::to_string_pretty(&model)?);
1928 }
1929 },
1930 Command::Signal {
1931 socket,
1932 workspace,
1933 pane,
1934 surface,
1935 kind,
1936 message,
1937 title,
1938 cwd,
1939 repo,
1940 branch,
1941 agent,
1942 agent_active,
1943 command,
1944 source,
1945 } => {
1946 let workspace_id = workspace
1947 .or_else(env_workspace_id)
1948 .context("missing workspace id; pass --workspace or run from inside Taskers")?;
1949 let pane_id = pane
1950 .or_else(env_pane_id)
1951 .context("missing pane id; pass --pane or run from inside Taskers")?;
1952 let surface_id = surface.or_else(env_surface_id);
1953 let client = ControlClient::new(resolve_socket_path(socket));
1954 let metadata = if title.is_some()
1955 || cwd.is_some()
1956 || repo.is_some()
1957 || branch.is_some()
1958 || agent.is_some()
1959 || agent_active.is_some()
1960 || command.is_some()
1961 {
1962 Some(taskers_domain::SignalPaneMetadata {
1963 title,
1964 agent_title: None,
1965 cwd,
1966 repo_name: repo,
1967 git_branch: branch,
1968 ports: Vec::new(),
1969 agent_kind: agent,
1970 agent_active,
1971 agent_command: command,
1972 })
1973 } else {
1974 None
1975 };
1976 let response = client
1977 .send(ControlCommand::EmitSignal {
1978 workspace_id,
1979 pane_id,
1980 surface_id,
1981 event: SignalEvent {
1982 source: source.unwrap_or_else(|| "taskers-cli".into()),
1983 kind: kind.into(),
1984 message,
1985 metadata,
1986 timestamp: OffsetDateTime::now_utc(),
1987 },
1988 })
1989 .await?;
1990 println!("{}", serde_json::to_string_pretty(&response)?);
1991 }
1992 Command::Notify {
1993 socket,
1994 workspace,
1995 pane,
1996 surface,
1997 title,
1998 subtitle,
1999 body,
2000 notification_id,
2001 agent: _agent,
2002 } => {
2003 let client = ControlClient::new(resolve_socket_path(socket));
2004 let model = query_model(&client).await?;
2005 ensure_implicit_notify_target_context(workspace, pane, surface)?;
2006 let target = resolve_agent_target(
2007 &model,
2008 workspace,
2009 pane,
2010 surface,
2011 CliAgentTargetScope::Surface,
2012 )?;
2013 let normalized_title = title.trim();
2014 let normalized_body = body
2015 .as_deref()
2016 .map(str::trim)
2017 .filter(|value| !value.is_empty())
2018 .map(str::to_owned);
2019 let message = normalized_body.unwrap_or_else(|| normalized_title.to_string());
2020 let response = client
2021 .send(ControlCommand::AgentCreateNotification {
2022 target,
2023 kind: SignalKind::Notification,
2024 title: Some(normalized_title.to_string()),
2025 subtitle,
2026 external_id: notification_id,
2027 message,
2028 state: AttentionState::WaitingInput,
2029 })
2030 .await?;
2031 println!("{}", serde_json::to_string_pretty(&response)?);
2032 }
2033 Command::Agent { command } => match command {
2034 AgentCommand::Status { command } => match command {
2035 AgentStatusCommand::Set {
2036 socket,
2037 workspace,
2038 text,
2039 } => {
2040 let client = ControlClient::new(resolve_socket_path(socket));
2041 let model = query_model(&client).await?;
2042 let workspace_id = resolve_workspace_id_from_model(&model, workspace)?;
2043 let response = send_control_command(
2044 &client,
2045 ControlCommand::AgentSetStatus { workspace_id, text },
2046 )
2047 .await?;
2048 println!("{}", serde_json::to_string_pretty(&response)?);
2049 }
2050 AgentStatusCommand::Clear { socket, workspace } => {
2051 let client = ControlClient::new(resolve_socket_path(socket));
2052 let model = query_model(&client).await?;
2053 let workspace_id = resolve_workspace_id_from_model(&model, workspace)?;
2054 let response = send_control_command(
2055 &client,
2056 ControlCommand::AgentClearStatus { workspace_id },
2057 )
2058 .await?;
2059 println!("{}", serde_json::to_string_pretty(&response)?);
2060 }
2061 },
2062 AgentCommand::Progress { command } => match command {
2063 AgentProgressCommand::Set {
2064 socket,
2065 workspace,
2066 value,
2067 label,
2068 } => {
2069 let client = ControlClient::new(resolve_socket_path(socket));
2070 let model = query_model(&client).await?;
2071 let workspace_id = resolve_workspace_id_from_model(&model, workspace)?;
2072 let response = send_control_command(
2073 &client,
2074 ControlCommand::AgentSetProgress {
2075 workspace_id,
2076 progress: ProgressState { value, label },
2077 },
2078 )
2079 .await?;
2080 println!("{}", serde_json::to_string_pretty(&response)?);
2081 }
2082 AgentProgressCommand::Clear { socket, workspace } => {
2083 let client = ControlClient::new(resolve_socket_path(socket));
2084 let model = query_model(&client).await?;
2085 let workspace_id = resolve_workspace_id_from_model(&model, workspace)?;
2086 let response = send_control_command(
2087 &client,
2088 ControlCommand::AgentClearProgress { workspace_id },
2089 )
2090 .await?;
2091 println!("{}", serde_json::to_string_pretty(&response)?);
2092 }
2093 },
2094 AgentCommand::Log { command } => match command {
2095 AgentLogCommand::Append {
2096 socket,
2097 workspace,
2098 message,
2099 source,
2100 } => {
2101 let client = ControlClient::new(resolve_socket_path(socket));
2102 let model = query_model(&client).await?;
2103 let workspace_id = resolve_workspace_id_from_model(&model, workspace)?;
2104 let response = send_control_command(
2105 &client,
2106 ControlCommand::AgentAppendLog {
2107 workspace_id,
2108 entry: WorkspaceLogEntry {
2109 source,
2110 message,
2111 created_at: OffsetDateTime::now_utc(),
2112 },
2113 },
2114 )
2115 .await?;
2116 println!("{}", serde_json::to_string_pretty(&response)?);
2117 }
2118 AgentLogCommand::List { socket, workspace } => {
2119 let client = ControlClient::new(resolve_socket_path(socket));
2120 let model = query_model(&client).await?;
2121 let workspace_id = resolve_workspace_id_from_model(&model, workspace)?;
2122 let workspace = model
2123 .workspaces
2124 .get(&workspace_id)
2125 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
2126 println!("{}", serde_json::to_string_pretty(&workspace.log_entries)?);
2127 }
2128 AgentLogCommand::Clear { socket, workspace } => {
2129 let client = ControlClient::new(resolve_socket_path(socket));
2130 let model = query_model(&client).await?;
2131 let workspace_id = resolve_workspace_id_from_model(&model, workspace)?;
2132 let response = send_control_command(
2133 &client,
2134 ControlCommand::AgentClearLog { workspace_id },
2135 )
2136 .await?;
2137 println!("{}", serde_json::to_string_pretty(&response)?);
2138 }
2139 },
2140 AgentCommand::Notify { command } => match command {
2141 AgentNotifyCommand::Create {
2142 socket,
2143 workspace,
2144 pane,
2145 surface,
2146 scope,
2147 title,
2148 subtitle,
2149 notification_id,
2150 message,
2151 state,
2152 } => {
2153 let client = ControlClient::new(resolve_socket_path(socket));
2154 let model = query_model(&client).await?;
2155 let target = resolve_agent_target(&model, workspace, pane, surface, scope)?;
2156 let response = send_control_command(
2157 &client,
2158 ControlCommand::AgentCreateNotification {
2159 target,
2160 kind: SignalKind::Notification,
2161 title,
2162 subtitle,
2163 external_id: notification_id,
2164 message,
2165 state: state.into(),
2166 },
2167 )
2168 .await?;
2169 println!("{}", serde_json::to_string_pretty(&response)?);
2170 }
2171 AgentNotifyCommand::List { socket, workspace } => {
2172 let client = ControlClient::new(resolve_socket_path(socket));
2173 let model = query_model(&client).await?;
2174 let workspace_filter = workspace.or_else(env_workspace_id);
2175 let payload = model
2176 .activity_items()
2177 .into_iter()
2178 .filter(|item| {
2179 workspace_filter
2180 .is_none_or(|workspace_id| item.workspace_id == workspace_id)
2181 })
2182 .map(|item| {
2183 serde_json::json!({
2184 "workspace_id": item.workspace_id,
2185 "workspace_window_id": item.workspace_window_id,
2186 "notification_id": item.notification_id,
2187 "pane_id": item.pane_id,
2188 "surface_id": item.surface_id,
2189 "kind": format!("{:?}", item.kind).to_lowercase(),
2190 "state": format!("{:?}", item.state).to_lowercase(),
2191 "title": item.title,
2192 "subtitle": item.subtitle,
2193 "message": item.message,
2194 "read_at": item.read_at,
2195 "created_at": item.created_at,
2196 })
2197 })
2198 .collect::<Vec<_>>();
2199 println!("{}", serde_json::to_string_pretty(&payload)?);
2200 }
2201 AgentNotifyCommand::Clear {
2202 socket,
2203 workspace,
2204 pane,
2205 surface,
2206 scope,
2207 } => {
2208 let client = ControlClient::new(resolve_socket_path(socket));
2209 let model = query_model(&client).await?;
2210 let target = resolve_agent_target(&model, workspace, pane, surface, scope)?;
2211 let response = send_control_command(
2212 &client,
2213 ControlCommand::AgentClearNotifications { target },
2214 )
2215 .await?;
2216 println!("{}", serde_json::to_string_pretty(&response)?);
2217 }
2218 },
2219 AgentCommand::Flash {
2220 socket,
2221 workspace,
2222 pane,
2223 surface,
2224 } => {
2225 let client = ControlClient::new(resolve_socket_path(socket));
2226 let model = query_model(&client).await?;
2227 let target = resolve_agent_target(
2228 &model,
2229 workspace,
2230 pane,
2231 surface,
2232 CliAgentTargetScope::Surface,
2233 )?;
2234 let AgentTarget::Surface {
2235 workspace_id,
2236 pane_id,
2237 surface_id,
2238 } = target
2239 else {
2240 bail!("surface flash requires a surface target");
2241 };
2242 let response = send_control_command(
2243 &client,
2244 ControlCommand::AgentTriggerFlash {
2245 workspace_id,
2246 pane_id,
2247 surface_id,
2248 },
2249 )
2250 .await?;
2251 println!("{}", serde_json::to_string_pretty(&response)?);
2252 }
2253 AgentCommand::FocusUnread { socket } => {
2254 let client = ControlClient::new(resolve_socket_path(socket));
2255 let response = send_control_command(
2256 &client,
2257 ControlCommand::AgentFocusLatestUnread { window_id: None },
2258 )
2259 .await?;
2260 println!("{}", serde_json::to_string_pretty(&response)?);
2261 }
2262 },
2263 Command::Workspace { command } => match command {
2264 WorkspaceCommand::List { socket } => {
2265 let client = ControlClient::new(resolve_socket_path(socket));
2266 let model = query_model(&client).await?;
2267 let active_workspace = model.active_workspace_id();
2268 let payload = model
2269 .workspace_summaries(model.active_window)?
2270 .into_iter()
2271 .map(|workspace| {
2272 serde_json::json!({
2273 "workspace_id": workspace.workspace_id,
2274 "label": workspace.label,
2275 "active": active_workspace == Some(workspace.workspace_id),
2276 "unread_count": workspace.unread_count,
2277 "highest_attention": format!("{:?}", workspace.highest_attention).to_lowercase(),
2278 "display_attention": format!("{:?}", workspace.display_attention).to_lowercase(),
2279 "agent_count": workspace.agent_summaries.len(),
2280 "repo_hint": workspace.repo_hint,
2281 "latest_notification": workspace.latest_notification,
2282 })
2283 })
2284 .collect::<Vec<_>>();
2285 println!("{}", serde_json::to_string_pretty(&payload)?);
2286 }
2287 WorkspaceCommand::New { socket, label } => {
2288 let client = ControlClient::new(resolve_socket_path(socket));
2289 let response = client
2290 .send(ControlCommand::CreateWorkspace { label })
2291 .await?;
2292 println!("{}", serde_json::to_string_pretty(&response)?);
2293 }
2294 WorkspaceCommand::Switch { socket, workspace } => {
2295 let client = ControlClient::new(resolve_socket_path(socket));
2296 let response = client
2297 .send(ControlCommand::SwitchWorkspace {
2298 window_id: None,
2299 workspace_id: workspace,
2300 })
2301 .await?;
2302 println!("{}", serde_json::to_string_pretty(&response)?);
2303 }
2304 WorkspaceCommand::Rename {
2305 socket,
2306 workspace,
2307 label,
2308 } => {
2309 let client = ControlClient::new(resolve_socket_path(socket));
2310 let response = client
2311 .send(ControlCommand::RenameWorkspace {
2312 workspace_id: workspace,
2313 label,
2314 })
2315 .await?;
2316 println!("{}", serde_json::to_string_pretty(&response)?);
2317 }
2318 WorkspaceCommand::Close { socket, workspace } => {
2319 let client = ControlClient::new(resolve_socket_path(socket));
2320 let response = client
2321 .send(ControlCommand::CloseWorkspace {
2322 workspace_id: workspace,
2323 })
2324 .await?;
2325 println!("{}", serde_json::to_string_pretty(&response)?);
2326 }
2327 },
2328 Command::AgentHook { command } => match command {
2329 AgentHookCommand::SessionStart {
2330 socket,
2331 workspace,
2332 pane,
2333 surface,
2334 agent,
2335 title,
2336 message,
2337 } => {
2338 emit_agent_hook(
2339 socket,
2340 workspace,
2341 pane,
2342 surface,
2343 agent,
2344 title,
2345 message,
2346 CliSignalKind::Started,
2347 )
2348 .await?;
2349 }
2350 AgentHookCommand::Active {
2351 socket,
2352 workspace,
2353 pane,
2354 surface,
2355 agent,
2356 title,
2357 message,
2358 }
2359 | AgentHookCommand::Progress {
2360 socket,
2361 workspace,
2362 pane,
2363 surface,
2364 agent,
2365 title,
2366 message,
2367 } => {
2368 emit_agent_hook(
2369 socket,
2370 workspace,
2371 pane,
2372 surface,
2373 agent,
2374 title,
2375 message,
2376 CliSignalKind::Progress,
2377 )
2378 .await?;
2379 }
2380 AgentHookCommand::Waiting {
2381 socket,
2382 workspace,
2383 pane,
2384 surface,
2385 agent,
2386 title,
2387 message,
2388 } => {
2389 emit_agent_hook(
2390 socket,
2391 workspace,
2392 pane,
2393 surface,
2394 agent,
2395 title,
2396 message,
2397 CliSignalKind::WaitingInput,
2398 )
2399 .await?;
2400 }
2401 AgentHookCommand::Notification {
2402 socket,
2403 workspace,
2404 pane,
2405 surface,
2406 agent,
2407 title,
2408 message,
2409 } => {
2410 emit_agent_hook(
2411 socket,
2412 workspace,
2413 pane,
2414 surface,
2415 agent,
2416 title,
2417 message,
2418 CliSignalKind::Notification,
2419 )
2420 .await?;
2421 }
2422 AgentHookCommand::Stop {
2423 socket,
2424 workspace,
2425 pane,
2426 surface,
2427 agent,
2428 title,
2429 message,
2430 } => {
2431 emit_agent_hook(
2432 socket,
2433 workspace,
2434 pane,
2435 surface,
2436 agent,
2437 title,
2438 message,
2439 CliSignalKind::Completed,
2440 )
2441 .await?;
2442 }
2443 },
2444 Command::Browser { command } => {
2445 handle_browser_cli_command(command).await?;
2446 }
2447 Command::Screenshot { screenshot } => {
2448 handle_screenshot_cli_command(screenshot).await?;
2449 }
2450 Command::Completion { shell } => {
2451 write_completion(shell, io::stdout())?;
2452 }
2453 Command::CompletionQuery { query } => {
2454 for candidate in completion_query_candidates(&query).await {
2455 println!("{candidate}");
2456 }
2457 }
2458 Command::Identify {
2459 socket,
2460 workspace,
2461 pane,
2462 surface,
2463 } => {
2464 let client = ControlClient::new(resolve_socket_path(socket));
2465 let response = send_control_command(
2466 &client,
2467 ControlCommand::QueryStatus {
2468 query: ControlQuery::Identify {
2469 workspace_id: workspace.or_else(env_workspace_id),
2470 pane_id: pane.or_else(env_pane_id),
2471 surface_id: surface.or_else(env_surface_id),
2472 },
2473 },
2474 )
2475 .await?;
2476 match response {
2477 ControlResponse::Identify { result } => {
2478 println!("{}", serde_json::to_string_pretty(&result)?);
2479 }
2480 other => bail!("unexpected identify response: {other:?}"),
2481 }
2482 }
2483 Command::Debug { command } => match command {
2484 DebugCommand::Terminal { command } => {
2485 handle_terminal_debug_cli_command(command).await?;
2486 }
2487 },
2488 Command::Pane { command } => match command {
2489 PaneCommand::NewWindow {
2490 socket,
2491 workspace,
2492 direction,
2493 } => {
2494 let client = ControlClient::new(resolve_socket_path(socket));
2495 let response = client
2496 .send(ControlCommand::CreateWorkspaceWindow {
2497 workspace_id: workspace,
2498 direction: direction.into(),
2499 preferred_column_width: None,
2500 preferred_window_height: None,
2501 })
2502 .await?;
2503 println!("{}", serde_json::to_string_pretty(&response)?);
2504 }
2505 PaneCommand::Split {
2506 socket,
2507 workspace,
2508 pane,
2509 axis,
2510 kind,
2511 url,
2512 ephemeral,
2513 } => {
2514 if url.is_some() && kind != CliPaneKind::Browser {
2515 bail!("--url requires --kind browser");
2516 }
2517 if ephemeral && kind != CliPaneKind::Browser {
2518 bail!("--ephemeral requires --kind browser");
2519 }
2520
2521 let client = ControlClient::new(resolve_socket_path(socket));
2522 if kind == CliPaneKind::Terminal {
2523 let response = client
2524 .send(ControlCommand::SplitPane {
2525 workspace_id: workspace,
2526 pane_id: pane,
2527 axis: axis.into(),
2528 })
2529 .await?;
2530 println!("{}", serde_json::to_string_pretty(&response)?);
2531 } else {
2532 let response = send_control_command(
2533 &client,
2534 ControlCommand::SplitPane {
2535 workspace_id: workspace,
2536 pane_id: pane,
2537 axis: axis.into(),
2538 },
2539 )
2540 .await?;
2541 let pane_id = match response {
2542 ControlResponse::PaneSplit { pane_id } => pane_id,
2543 other => bail!("unexpected split response: {other:?}"),
2544 };
2545 let placeholder_surface_id =
2546 active_surface_for_pane(&query_model(&client).await?, workspace, pane_id)?;
2547 let surface_id = create_surface(
2548 &client,
2549 workspace,
2550 pane_id,
2551 kind.into(),
2552 Some(browser_profile_mode(ephemeral)),
2553 url.clone(),
2554 )
2555 .await?;
2556 send_control_command(
2557 &client,
2558 ControlCommand::CloseSurface {
2559 workspace_id: workspace,
2560 pane_id,
2561 surface_id: placeholder_surface_id,
2562 },
2563 )
2564 .await?;
2565 println!(
2566 "{}",
2567 serde_json::to_string_pretty(&serde_json::json!({
2568 "status": "browser_surface_opened",
2569 "workspace_id": workspace,
2570 "pane_id": pane_id,
2571 "surface_id": surface_id,
2572 "replaced_surface_id": placeholder_surface_id,
2573 "url": url,
2574 }))?
2575 );
2576 }
2577 }
2578 PaneCommand::Focus {
2579 socket,
2580 workspace,
2581 pane,
2582 } => {
2583 let client = ControlClient::new(resolve_socket_path(socket));
2584 let response = client
2585 .send(ControlCommand::FocusPane {
2586 workspace_id: workspace,
2587 pane_id: pane,
2588 })
2589 .await?;
2590 println!("{}", serde_json::to_string_pretty(&response)?);
2591 }
2592 PaneCommand::FocusDirection {
2593 socket,
2594 workspace,
2595 direction,
2596 } => {
2597 let client = ControlClient::new(resolve_socket_path(socket));
2598 let response = client
2599 .send(ControlCommand::FocusPaneDirection {
2600 workspace_id: workspace,
2601 direction: direction.into(),
2602 })
2603 .await?;
2604 println!("{}", serde_json::to_string_pretty(&response)?);
2605 }
2606 PaneCommand::ResizeWindow {
2607 socket,
2608 workspace,
2609 direction,
2610 amount,
2611 } => {
2612 let client = ControlClient::new(resolve_socket_path(socket));
2613 let response = client
2614 .send(ControlCommand::ResizeActiveWindow {
2615 workspace_id: workspace,
2616 direction: direction.into(),
2617 amount,
2618 })
2619 .await?;
2620 println!("{}", serde_json::to_string_pretty(&response)?);
2621 }
2622 PaneCommand::ResizeSplit {
2623 socket,
2624 workspace,
2625 direction,
2626 amount,
2627 } => {
2628 let client = ControlClient::new(resolve_socket_path(socket));
2629 let response = client
2630 .send(ControlCommand::ResizeActivePaneSplit {
2631 workspace_id: workspace,
2632 direction: direction.into(),
2633 amount,
2634 })
2635 .await?;
2636 println!("{}", serde_json::to_string_pretty(&response)?);
2637 }
2638 PaneCommand::Close {
2639 socket,
2640 workspace,
2641 pane,
2642 } => {
2643 let client = ControlClient::new(resolve_socket_path(socket));
2644 let response = client
2645 .send(ControlCommand::ClosePane {
2646 workspace_id: workspace,
2647 pane_id: pane,
2648 })
2649 .await?;
2650 println!("{}", serde_json::to_string_pretty(&response)?);
2651 }
2652 PaneCommand::Update {
2653 socket,
2654 pane,
2655 title,
2656 cwd,
2657 repo,
2658 branch,
2659 agent,
2660 } => {
2661 let client = ControlClient::new(resolve_socket_path(socket));
2662 let response = client
2663 .send(ControlCommand::UpdatePaneMetadata {
2664 pane_id: pane,
2665 patch: PaneMetadataPatch {
2666 title,
2667 cwd,
2668 url: None,
2669 browser_profile_mode: None,
2670 repo_name: repo,
2671 git_branch: branch,
2672 ports: None,
2673 agent_kind: agent,
2674 },
2675 })
2676 .await?;
2677 println!("{}", serde_json::to_string_pretty(&response)?);
2678 }
2679 },
2680 Command::Surface { command } => match command {
2681 SurfaceCommand::New {
2682 socket,
2683 workspace,
2684 pane,
2685 kind,
2686 url,
2687 ephemeral,
2688 } => {
2689 if url.is_some() && kind != CliPaneKind::Browser {
2690 bail!("--url requires --kind browser");
2691 }
2692 if ephemeral && kind != CliPaneKind::Browser {
2693 bail!("--ephemeral requires --kind browser");
2694 }
2695
2696 let client = ControlClient::new(resolve_socket_path(socket));
2697 if kind == CliPaneKind::Terminal {
2698 let response = client
2699 .send(ControlCommand::CreateSurface {
2700 workspace_id: workspace,
2701 pane_id: pane,
2702 kind: PaneKind::Terminal,
2703 browser_profile_mode: None,
2704 })
2705 .await?;
2706 println!("{}", serde_json::to_string_pretty(&response)?);
2707 } else {
2708 let surface_id = create_surface(
2709 &client,
2710 workspace,
2711 pane,
2712 kind.into(),
2713 Some(browser_profile_mode(ephemeral)),
2714 url.clone(),
2715 )
2716 .await?;
2717 println!(
2718 "{}",
2719 serde_json::to_string_pretty(&serde_json::json!({
2720 "status": "surface_created",
2721 "workspace_id": workspace,
2722 "pane_id": pane,
2723 "surface_id": surface_id,
2724 "kind": "browser",
2725 "url": url,
2726 "profile_mode": browser_profile_mode(ephemeral),
2727 }))?
2728 );
2729 }
2730 }
2731 SurfaceCommand::Focus {
2732 socket,
2733 workspace,
2734 pane,
2735 surface,
2736 } => {
2737 let client = ControlClient::new(resolve_socket_path(socket));
2738 let response = client
2739 .send(ControlCommand::FocusSurface {
2740 workspace_id: workspace,
2741 pane_id: pane,
2742 surface_id: surface,
2743 })
2744 .await?;
2745 println!("{}", serde_json::to_string_pretty(&response)?);
2746 }
2747 SurfaceCommand::Complete {
2748 socket,
2749 workspace,
2750 pane,
2751 surface,
2752 } => {
2753 let client = ControlClient::new(resolve_socket_path(socket));
2754 let response = client
2755 .send(ControlCommand::MarkSurfaceCompleted {
2756 workspace_id: workspace,
2757 pane_id: pane,
2758 surface_id: surface,
2759 })
2760 .await?;
2761 println!("{}", serde_json::to_string_pretty(&response)?);
2762 }
2763 SurfaceCommand::AgentStart {
2764 socket,
2765 workspace,
2766 pane,
2767 surface,
2768 agent,
2769 } => {
2770 let client = ControlClient::new(resolve_socket_path(socket));
2771 let response = client
2772 .send(ControlCommand::StartSurfaceAgentSession {
2773 workspace_id: workspace,
2774 pane_id: pane,
2775 surface_id: surface,
2776 agent_kind: agent,
2777 })
2778 .await?;
2779 println!("{}", serde_json::to_string_pretty(&response)?);
2780 }
2781 SurfaceCommand::AgentStop {
2782 socket,
2783 workspace,
2784 pane,
2785 surface,
2786 exit_status,
2787 } => {
2788 let client = ControlClient::new(resolve_socket_path(socket));
2789 let response = client
2790 .send(ControlCommand::StopSurfaceAgentSession {
2791 workspace_id: workspace,
2792 pane_id: pane,
2793 surface_id: surface,
2794 exit_status,
2795 })
2796 .await?;
2797 println!("{}", serde_json::to_string_pretty(&response)?);
2798 }
2799 SurfaceCommand::DismissAlert {
2800 socket,
2801 workspace,
2802 pane,
2803 surface,
2804 } => {
2805 let client = ControlClient::new(resolve_socket_path(socket));
2806 let response = client
2807 .send(ControlCommand::DismissSurfaceAlert {
2808 workspace_id: workspace,
2809 pane_id: pane,
2810 surface_id: surface,
2811 })
2812 .await?;
2813 println!("{}", serde_json::to_string_pretty(&response)?);
2814 }
2815 SurfaceCommand::Close {
2816 socket,
2817 workspace,
2818 pane,
2819 surface,
2820 } => {
2821 let client = ControlClient::new(resolve_socket_path(socket));
2822 let response = client
2823 .send(ControlCommand::CloseSurface {
2824 workspace_id: workspace,
2825 pane_id: pane,
2826 surface_id: surface,
2827 })
2828 .await?;
2829 println!("{}", serde_json::to_string_pretty(&response)?);
2830 }
2831 },
2832 }
2833
2834 Ok(())
2835}
2836
2837fn env_workspace_id() -> Option<WorkspaceId> {
2838 if !taskers_env_context_matches_current_tty() {
2839 return None;
2840 }
2841 env::var("TASKERS_WORKSPACE_ID")
2842 .ok()
2843 .and_then(|value| value.parse().ok())
2844}
2845
2846fn env_pane_id() -> Option<PaneId> {
2847 if !taskers_env_context_matches_current_tty() {
2848 return None;
2849 }
2850 env::var("TASKERS_PANE_ID")
2851 .ok()
2852 .and_then(|value| value.parse().ok())
2853}
2854
2855fn env_surface_id() -> Option<SurfaceId> {
2856 if !taskers_env_context_matches_current_tty() {
2857 return None;
2858 }
2859 env::var("TASKERS_SURFACE_ID")
2860 .ok()
2861 .and_then(|value| value.parse().ok())
2862}
2863
2864fn env_tty_name() -> Option<String> {
2865 env::var("TASKERS_TTY_NAME")
2866 .ok()
2867 .map(|value| value.trim().to_string())
2868 .filter(|value| !value.is_empty())
2869}
2870
2871fn current_process_tty_name() -> Option<String> {
2872 let output = ProcessCommand::new("ps")
2873 .args(["-o", "tty=", "-p", &std::process::id().to_string()])
2874 .output()
2875 .ok()?;
2876 if !output.status.success() {
2877 return None;
2878 }
2879
2880 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
2881 if raw.is_empty() || raw == "?" {
2882 return None;
2883 }
2884 if raw.starts_with('/') {
2885 Some(raw)
2886 } else {
2887 Some(format!("/dev/{raw}"))
2888 }
2889}
2890
2891fn taskers_env_context_matches_current_tty() -> bool {
2892 match env_tty_name() {
2893 Some(expected) => current_process_tty_name().is_some_and(|current| current == expected),
2894 None => true,
2895 }
2896}
2897
2898fn has_implicit_notify_target_context() -> bool {
2899 env_workspace_id().is_some() && env_pane_id().is_some() && env_surface_id().is_some()
2900}
2901
2902fn ensure_implicit_notify_target_context(
2903 workspace: Option<WorkspaceId>,
2904 pane: Option<PaneId>,
2905 surface: Option<SurfaceId>,
2906) -> anyhow::Result<()> {
2907 if workspace.is_some()
2908 || pane.is_some()
2909 || surface.is_some()
2910 || has_implicit_notify_target_context()
2911 {
2912 return Ok(());
2913 }
2914
2915 bail!(
2916 "notify requires embedded Taskers pane context; pass --workspace/--pane/--surface when running outside Taskers"
2917 )
2918}
2919
2920fn resolve_socket_path(socket: Option<PathBuf>) -> PathBuf {
2921 socket
2922 .or_else(|| env::var_os("TASKERS_SOCKET").map(PathBuf::from))
2923 .unwrap_or_else(default_socket_path)
2924}
2925
2926fn resolve_terminal_socket_path(socket: Option<PathBuf>) -> PathBuf {
2927 socket
2928 .or_else(|| env::var_os("TASKERS_TERMINAL_SOCKET").map(PathBuf::from))
2929 .unwrap_or_else(default_terminal_socket_path)
2930}
2931
2932async fn send_control_command(
2933 client: &ControlClient,
2934 command: ControlCommand,
2935) -> anyhow::Result<ControlResponse> {
2936 let response = client.send(command).await?;
2937 response.response.map_err(|error| anyhow!(error))
2938}
2939
2940async fn query_model(client: &ControlClient) -> anyhow::Result<AppModel> {
2941 let response = send_control_command(
2942 client,
2943 ControlCommand::QueryStatus {
2944 query: ControlQuery::All,
2945 },
2946 )
2947 .await?;
2948 match response {
2949 ControlResponse::Status { session } => Ok(session.model),
2950 other => bail!("unexpected query response: {other:?}"),
2951 }
2952}
2953
2954async fn resolve_surface_context(
2955 client: &ControlClient,
2956 surface_id: SurfaceId,
2957) -> anyhow::Result<(WorkspaceId, PaneId, SurfaceId)> {
2958 let response = send_control_command(
2959 client,
2960 ControlCommand::QueryStatus {
2961 query: ControlQuery::Identify {
2962 workspace_id: None,
2963 pane_id: None,
2964 surface_id: Some(surface_id),
2965 },
2966 },
2967 )
2968 .await?;
2969
2970 let ControlResponse::Identify { result } = response else {
2971 bail!("unexpected identify response: {response:?}");
2972 };
2973 let caller = result
2974 .caller
2975 .ok_or_else(|| anyhow!("missing identify caller context for surface {surface_id}"))?;
2976 Ok((caller.workspace_id, caller.pane_id, caller.surface_id))
2977}
2978
2979fn active_surface_for_pane(
2980 model: &AppModel,
2981 workspace_id: WorkspaceId,
2982 pane_id: PaneId,
2983) -> anyhow::Result<SurfaceId> {
2984 model
2985 .workspaces
2986 .get(&workspace_id)
2987 .and_then(|workspace| workspace.panes.get(&pane_id))
2988 .map(|pane| pane.active_surface)
2989 .ok_or_else(|| anyhow!("pane {pane_id} is not present in workspace {workspace_id}"))
2990}
2991
2992fn resolve_workspace_id_from_model(
2993 model: &AppModel,
2994 workspace: Option<WorkspaceId>,
2995) -> anyhow::Result<WorkspaceId> {
2996 workspace
2997 .or_else(env_workspace_id)
2998 .or_else(|| model.active_workspace_id())
2999 .context("missing workspace id; pass --workspace or run from inside Taskers")
3000}
3001
3002fn resolve_workspace_window_screenshot_target(
3003 model: &AppModel,
3004 workspace: Option<WorkspaceId>,
3005) -> anyhow::Result<ScreenshotTarget> {
3006 let workspace_id = resolve_workspace_id_from_model(model, workspace)?;
3007 let workspace = model
3008 .workspaces
3009 .get(&workspace_id)
3010 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
3011 if !workspace.windows.contains_key(&workspace.active_window) {
3012 bail!("workspace {workspace_id} has no active workspace window");
3013 }
3014 Ok(ScreenshotTarget::WorkspaceWindow { workspace_id })
3015}
3016
3017fn resolve_agent_target(
3018 model: &AppModel,
3019 workspace: Option<WorkspaceId>,
3020 pane: Option<PaneId>,
3021 surface: Option<SurfaceId>,
3022 scope: CliAgentTargetScope,
3023) -> anyhow::Result<AgentTarget> {
3024 let workspace_id = resolve_workspace_id_from_model(model, workspace)?;
3025 let workspace_record = model
3026 .workspaces
3027 .get(&workspace_id)
3028 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
3029
3030 let resolved_pane = pane
3031 .or_else(env_pane_id)
3032 .unwrap_or(workspace_record.active_pane);
3033 let pane_record = workspace_record.panes.get(&resolved_pane).ok_or_else(|| {
3034 anyhow!("pane {resolved_pane} is not present in workspace {workspace_id}")
3035 })?;
3036 let resolved_surface = surface
3037 .or_else(env_surface_id)
3038 .unwrap_or(pane_record.active_surface);
3039
3040 match scope {
3041 CliAgentTargetScope::Workspace => Ok(AgentTarget::Workspace { workspace_id }),
3042 CliAgentTargetScope::Pane => Ok(AgentTarget::Pane {
3043 workspace_id,
3044 pane_id: resolved_pane,
3045 }),
3046 CliAgentTargetScope::Surface => Ok(AgentTarget::Surface {
3047 workspace_id,
3048 pane_id: resolved_pane,
3049 surface_id: resolved_surface,
3050 }),
3051 }
3052}
3053
3054async fn create_surface(
3055 client: &ControlClient,
3056 workspace_id: WorkspaceId,
3057 pane_id: PaneId,
3058 kind: PaneKind,
3059 browser_profile_mode: Option<BrowserProfileMode>,
3060 url: Option<String>,
3061) -> anyhow::Result<SurfaceId> {
3062 let response = send_control_command(
3063 client,
3064 ControlCommand::CreateSurface {
3065 workspace_id,
3066 pane_id,
3067 kind,
3068 browser_profile_mode,
3069 },
3070 )
3071 .await?;
3072 let surface_id = match response {
3073 ControlResponse::SurfaceCreated { surface_id } => surface_id,
3074 other => bail!("unexpected create surface response: {other:?}"),
3075 };
3076
3077 if let Some(url) = url {
3078 send_control_command(
3079 client,
3080 ControlCommand::UpdateSurfaceMetadata {
3081 surface_id,
3082 patch: PaneMetadataPatch {
3083 title: None,
3084 cwd: None,
3085 url: Some(url),
3086 browser_profile_mode: None,
3087 repo_name: None,
3088 git_branch: None,
3089 ports: None,
3090 agent_kind: None,
3091 },
3092 },
3093 )
3094 .await?;
3095 }
3096
3097 Ok(surface_id)
3098}
3099
3100async fn handle_browser_cli_command(command: BrowserCommand) -> anyhow::Result<()> {
3101 match command {
3102 BrowserCommand::Open {
3103 socket,
3104 workspace,
3105 pane,
3106 url,
3107 ephemeral,
3108 } => {
3109 let client = ControlClient::new(resolve_socket_path(socket));
3110 let model = query_model(&client).await?;
3111 let workspace_id = resolve_workspace_id_from_model(&model, workspace)?;
3112 let target_pane = pane.or_else(env_pane_id).or_else(|| {
3113 model
3114 .workspaces
3115 .get(&workspace_id)
3116 .map(|workspace| workspace.active_pane)
3117 });
3118 let response = send_control_command(
3119 &client,
3120 ControlCommand::SplitPane {
3121 workspace_id,
3122 pane_id: target_pane,
3123 axis: SplitAxis::Horizontal,
3124 },
3125 )
3126 .await?;
3127 let pane_id = match response {
3128 ControlResponse::PaneSplit { pane_id } => pane_id,
3129 other => bail!("unexpected browser open response: {other:?}"),
3130 };
3131 let placeholder_surface_id =
3132 active_surface_for_pane(&query_model(&client).await?, workspace_id, pane_id)?;
3133 let surface_id = create_surface(
3134 &client,
3135 workspace_id,
3136 pane_id,
3137 PaneKind::Browser,
3138 Some(browser_profile_mode(ephemeral)),
3139 url.clone(),
3140 )
3141 .await?;
3142 send_control_command(
3143 &client,
3144 ControlCommand::CloseSurface {
3145 workspace_id,
3146 pane_id,
3147 surface_id: placeholder_surface_id,
3148 },
3149 )
3150 .await?;
3151 println!(
3152 "{}",
3153 serde_json::to_string_pretty(&serde_json::json!({
3154 "status": "browser_opened",
3155 "workspace_id": workspace_id,
3156 "pane_id": pane_id,
3157 "surface_id": surface_id,
3158 "url": url,
3159 "profile_mode": browser_profile_mode(ephemeral),
3160 }))?
3161 );
3162 }
3163 BrowserCommand::Navigate { browser, url } => {
3164 let client = ControlClient::new(resolve_socket_path(browser.socket.clone()));
3165 let (_, _, surface_id) = resolve_browser_surface(&client, &browser).await?;
3166 let result =
3167 send_browser_command(&client, BrowserControlCommand::Navigate { surface_id, url })
3168 .await?;
3169 print_browser_result(&result)?;
3170 }
3171 BrowserCommand::Back { browser } => {
3172 run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Back {
3173 surface_id,
3174 })
3175 .await?;
3176 }
3177 BrowserCommand::Forward { browser } => {
3178 run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Forward {
3179 surface_id,
3180 })
3181 .await?;
3182 }
3183 BrowserCommand::Reload { browser } => {
3184 run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Reload {
3185 surface_id,
3186 })
3187 .await?;
3188 }
3189 BrowserCommand::Snapshot { browser } => {
3190 run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Snapshot {
3191 surface_id,
3192 })
3193 .await?;
3194 }
3195 BrowserCommand::Eval { browser, script } => {
3196 run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Eval {
3197 surface_id,
3198 script,
3199 })
3200 .await?;
3201 }
3202 BrowserCommand::Wait {
3203 browser,
3204 selector,
3205 text,
3206 url_contains,
3207 load_state,
3208 script,
3209 delay_ms,
3210 timeout_ms,
3211 poll_interval_ms,
3212 } => {
3213 let condition =
3214 resolve_wait_condition(selector, text, url_contains, load_state, script, delay_ms)?;
3215 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Wait {
3216 surface_id,
3217 condition,
3218 timeout_ms,
3219 poll_interval_ms,
3220 })
3221 .await?;
3222 }
3223 BrowserCommand::Click {
3224 browser,
3225 target,
3226 snapshot_after,
3227 } => {
3228 let target = resolve_required_browser_target(target)?;
3229 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Click {
3230 surface_id,
3231 target,
3232 snapshot_after,
3233 })
3234 .await?;
3235 }
3236 BrowserCommand::Dblclick {
3237 browser,
3238 target,
3239 snapshot_after,
3240 } => {
3241 let target = resolve_required_browser_target(target)?;
3242 run_browser_surface_command(&browser, move |surface_id| {
3243 BrowserControlCommand::Dblclick {
3244 surface_id,
3245 target,
3246 snapshot_after,
3247 }
3248 })
3249 .await?;
3250 }
3251 BrowserCommand::Type {
3252 browser,
3253 target,
3254 text,
3255 snapshot_after,
3256 } => {
3257 let target = resolve_required_browser_target(target)?;
3258 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Type {
3259 surface_id,
3260 target,
3261 text,
3262 snapshot_after,
3263 })
3264 .await?;
3265 }
3266 BrowserCommand::Fill {
3267 browser,
3268 target,
3269 text,
3270 snapshot_after,
3271 } => {
3272 let target = resolve_required_browser_target(target)?;
3273 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Fill {
3274 surface_id,
3275 target,
3276 text,
3277 snapshot_after,
3278 })
3279 .await?;
3280 }
3281 BrowserCommand::Press {
3282 browser,
3283 target,
3284 key,
3285 snapshot_after,
3286 } => {
3287 let target = resolve_optional_browser_target(target)?;
3288 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Press {
3289 surface_id,
3290 target,
3291 key,
3292 snapshot_after,
3293 })
3294 .await?;
3295 }
3296 BrowserCommand::Keydown {
3297 browser,
3298 target,
3299 key,
3300 snapshot_after,
3301 } => {
3302 let target = resolve_optional_browser_target(target)?;
3303 run_browser_surface_command(&browser, move |surface_id| {
3304 BrowserControlCommand::Keydown {
3305 surface_id,
3306 target,
3307 key,
3308 snapshot_after,
3309 }
3310 })
3311 .await?;
3312 }
3313 BrowserCommand::Keyup {
3314 browser,
3315 target,
3316 key,
3317 snapshot_after,
3318 } => {
3319 let target = resolve_optional_browser_target(target)?;
3320 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Keyup {
3321 surface_id,
3322 target,
3323 key,
3324 snapshot_after,
3325 })
3326 .await?;
3327 }
3328 BrowserCommand::Hover {
3329 browser,
3330 target,
3331 snapshot_after,
3332 } => {
3333 let target = resolve_required_browser_target(target)?;
3334 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Hover {
3335 surface_id,
3336 target,
3337 snapshot_after,
3338 })
3339 .await?;
3340 }
3341 BrowserCommand::Focus {
3342 browser,
3343 target,
3344 snapshot_after,
3345 } => {
3346 let target = resolve_required_browser_target(target)?;
3347 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Focus {
3348 surface_id,
3349 target,
3350 snapshot_after,
3351 })
3352 .await?;
3353 }
3354 BrowserCommand::Check {
3355 browser,
3356 target,
3357 snapshot_after,
3358 } => {
3359 let target = resolve_required_browser_target(target)?;
3360 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Check {
3361 surface_id,
3362 target,
3363 snapshot_after,
3364 })
3365 .await?;
3366 }
3367 BrowserCommand::Uncheck {
3368 browser,
3369 target,
3370 snapshot_after,
3371 } => {
3372 let target = resolve_required_browser_target(target)?;
3373 run_browser_surface_command(&browser, move |surface_id| {
3374 BrowserControlCommand::Uncheck {
3375 surface_id,
3376 target,
3377 snapshot_after,
3378 }
3379 })
3380 .await?;
3381 }
3382 BrowserCommand::Select {
3383 browser,
3384 target,
3385 values,
3386 snapshot_after,
3387 } => {
3388 let target = resolve_required_browser_target(target)?;
3389 run_browser_surface_command(&browser, move |surface_id| {
3390 BrowserControlCommand::Select {
3391 surface_id,
3392 target,
3393 values,
3394 snapshot_after,
3395 }
3396 })
3397 .await?;
3398 }
3399 BrowserCommand::Scroll {
3400 browser,
3401 target,
3402 dx,
3403 dy,
3404 snapshot_after,
3405 } => {
3406 let target = resolve_optional_browser_target(target)?;
3407 run_browser_surface_command(&browser, move |surface_id| {
3408 BrowserControlCommand::Scroll {
3409 surface_id,
3410 target,
3411 dx,
3412 dy,
3413 snapshot_after,
3414 }
3415 })
3416 .await?;
3417 }
3418 BrowserCommand::ScrollIntoView {
3419 browser,
3420 target,
3421 snapshot_after,
3422 } => {
3423 let target = resolve_required_browser_target(target)?;
3424 run_browser_surface_command(&browser, move |surface_id| {
3425 BrowserControlCommand::ScrollIntoView {
3426 surface_id,
3427 target,
3428 snapshot_after,
3429 }
3430 })
3431 .await?;
3432 }
3433 BrowserCommand::Get { browser, command } => {
3434 let query = match command {
3435 BrowserGetSubcommand::Url => BrowserGetCommand::Url,
3436 BrowserGetSubcommand::Title => BrowserGetCommand::Title,
3437 BrowserGetSubcommand::Text { target } => BrowserGetCommand::Text {
3438 target: resolve_required_browser_target(target)?,
3439 },
3440 BrowserGetSubcommand::Html { target } => BrowserGetCommand::Html {
3441 target: resolve_required_browser_target(target)?,
3442 },
3443 BrowserGetSubcommand::Value { target } => BrowserGetCommand::Value {
3444 target: resolve_required_browser_target(target)?,
3445 },
3446 BrowserGetSubcommand::Attr { target, name } => BrowserGetCommand::Attr {
3447 target: resolve_required_browser_target(target)?,
3448 name,
3449 },
3450 BrowserGetSubcommand::Count { selector } => BrowserGetCommand::Count { selector },
3451 BrowserGetSubcommand::Box { target } => BrowserGetCommand::Box {
3452 target: resolve_required_browser_target(target)?,
3453 },
3454 BrowserGetSubcommand::Styles { target, properties } => BrowserGetCommand::Styles {
3455 target: resolve_required_browser_target(target)?,
3456 properties,
3457 },
3458 };
3459 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Get {
3460 surface_id,
3461 query,
3462 })
3463 .await?;
3464 }
3465 BrowserCommand::Is { browser, command } => {
3466 let query = match command {
3467 BrowserIsSubcommand::Visible { target } => BrowserPredicateCommand::Visible {
3468 target: resolve_required_browser_target(target)?,
3469 },
3470 BrowserIsSubcommand::Enabled { target } => BrowserPredicateCommand::Enabled {
3471 target: resolve_required_browser_target(target)?,
3472 },
3473 BrowserIsSubcommand::Checked { target } => BrowserPredicateCommand::Checked {
3474 target: resolve_required_browser_target(target)?,
3475 },
3476 };
3477 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Is {
3478 surface_id,
3479 query,
3480 })
3481 .await?;
3482 }
3483 BrowserCommand::Screenshot { browser, out, full } => {
3484 run_browser_surface_command(&browser, move |surface_id| {
3485 BrowserControlCommand::Screenshot {
3486 surface_id,
3487 path: out,
3488 full_document: full,
3489 }
3490 })
3491 .await?;
3492 }
3493 BrowserCommand::FocusWebview { browser } => {
3494 run_browser_surface_command(&browser, |surface_id| {
3495 BrowserControlCommand::FocusWebview { surface_id }
3496 })
3497 .await?;
3498 }
3499 BrowserCommand::IsWebviewFocused { browser } => {
3500 run_browser_surface_command(&browser, |surface_id| {
3501 BrowserControlCommand::IsWebviewFocused { surface_id }
3502 })
3503 .await?;
3504 }
3505 BrowserCommand::ClearData {
3506 browser,
3507 origin_filter,
3508 } => {
3509 run_browser_surface_command(&browser, move |surface_id| {
3510 BrowserControlCommand::ClearData {
3511 surface_id,
3512 origin_filter,
3513 reload: true,
3514 }
3515 })
3516 .await?;
3517 }
3518 }
3519
3520 Ok(())
3521}
3522
3523async fn handle_screenshot_cli_command(screenshot: ScreenshotArgs) -> anyhow::Result<()> {
3524 let client = ControlClient::new(resolve_socket_path(screenshot.socket.clone()));
3525 let command = resolve_screenshot_command(&client, &screenshot).await?;
3526 let result = send_screenshot_command(&client, command).await?;
3527 println!("{}", serde_json::to_string_pretty(&result)?);
3528 Ok(())
3529}
3530
3531async fn resolve_screenshot_command(
3532 client: &ControlClient,
3533 screenshot: &ScreenshotArgs,
3534) -> anyhow::Result<ScreenshotCommand> {
3535 let model = query_model(client).await?;
3536 let target = match screenshot.target {
3537 CliScreenshotTarget::Surface => {
3538 let (_, _, surface_id) = resolve_terminal_surface(
3539 client,
3540 &TerminalSurfaceArgs {
3541 socket: screenshot.socket.clone(),
3542 workspace: screenshot.workspace,
3543 pane: screenshot.pane,
3544 surface: screenshot.surface,
3545 },
3546 )
3547 .await?;
3548 ScreenshotTarget::Surface { surface_id }
3549 }
3550 CliScreenshotTarget::Pane => {
3551 let workspace_id = resolve_workspace_id_from_model(&model, screenshot.workspace)?;
3552 let workspace = model
3553 .workspaces
3554 .get(&workspace_id)
3555 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
3556 let pane_id = screenshot
3557 .pane
3558 .or_else(env_pane_id)
3559 .unwrap_or(workspace.active_pane);
3560 workspace.panes.get(&pane_id).ok_or_else(|| {
3561 anyhow!("pane {pane_id} is not present in workspace {workspace_id}")
3562 })?;
3563 ScreenshotTarget::Pane {
3564 workspace_id,
3565 pane_id,
3566 }
3567 }
3568 CliScreenshotTarget::WorkspaceWindow => {
3569 resolve_workspace_window_screenshot_target(&model, screenshot.workspace)?
3570 }
3571 CliScreenshotTarget::WorkspaceCanvas => {
3572 let workspace_id = resolve_workspace_id_from_model(&model, screenshot.workspace)?;
3573 model
3574 .workspaces
3575 .get(&workspace_id)
3576 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
3577 ScreenshotTarget::WorkspaceCanvas { workspace_id }
3578 }
3579 };
3580
3581 Ok(ScreenshotCommand::Capture {
3582 target,
3583 path: screenshot.out.clone(),
3584 })
3585}
3586
3587async fn run_browser_surface_command<F>(
3588 browser: &BrowserSurfaceArgs,
3589 build: F,
3590) -> anyhow::Result<()>
3591where
3592 F: FnOnce(SurfaceId) -> BrowserControlCommand,
3593{
3594 let client = ControlClient::new(resolve_socket_path(browser.socket.clone()));
3595 let (_, _, surface_id) = resolve_browser_surface(&client, browser).await?;
3596 let result = send_browser_command(&client, build(surface_id)).await?;
3597 print_browser_result(&result)
3598}
3599
3600async fn handle_terminal_debug_cli_command(command: TerminalDebugCliCommand) -> anyhow::Result<()> {
3601 match command {
3602 TerminalDebugCliCommand::IsFocused { terminal } => {
3603 run_terminal_surface_command(&terminal, |surface_id| TerminalDebugCommand::IsFocused {
3604 surface_id,
3605 })
3606 .await
3607 }
3608 TerminalDebugCliCommand::ReadText {
3609 terminal,
3610 tail_lines,
3611 } => {
3612 run_terminal_surface_command(&terminal, |surface_id| TerminalDebugCommand::ReadText {
3613 surface_id,
3614 tail_lines,
3615 })
3616 .await
3617 }
3618 TerminalDebugCliCommand::RenderStats { terminal } => {
3619 run_terminal_surface_command(&terminal, |surface_id| {
3620 TerminalDebugCommand::RenderStats { surface_id }
3621 })
3622 .await
3623 }
3624 }
3625}
3626
3627async fn run_terminal_surface_command<F>(
3628 terminal: &TerminalSurfaceArgs,
3629 build: F,
3630) -> anyhow::Result<()>
3631where
3632 F: FnOnce(SurfaceId) -> TerminalDebugCommand,
3633{
3634 let client = ControlClient::new(resolve_socket_path(terminal.socket.clone()));
3635 let (_, _, surface_id) = resolve_terminal_surface(&client, terminal).await?;
3636 let result = send_terminal_debug_command(&client, build(surface_id)).await?;
3637 println!("{}", serde_json::to_string_pretty(&result)?);
3638 Ok(())
3639}
3640
3641async fn send_browser_command(
3642 client: &ControlClient,
3643 browser_command: BrowserControlCommand,
3644) -> anyhow::Result<serde_json::Value> {
3645 let response =
3646 send_control_command(client, ControlCommand::Browser { browser_command }).await?;
3647 match response {
3648 ControlResponse::Browser { result } => Ok(result),
3649 other => bail!("unexpected browser response: {other:?}"),
3650 }
3651}
3652
3653fn print_browser_result(result: &serde_json::Value) -> anyhow::Result<()> {
3654 println!("{}", serde_json::to_string_pretty(result)?);
3655 Ok(())
3656}
3657
3658async fn send_terminal_debug_command(
3659 client: &ControlClient,
3660 command: TerminalDebugCommand,
3661) -> anyhow::Result<serde_json::Value> {
3662 let response = send_control_command(
3663 client,
3664 ControlCommand::TerminalDebug {
3665 debug_command: command,
3666 },
3667 )
3668 .await?;
3669 match response {
3670 ControlResponse::TerminalDebug { result } => Ok(serde_json::to_value(result)?),
3671 other => bail!("unexpected terminal debug response: {other:?}"),
3672 }
3673}
3674
3675async fn send_screenshot_command(
3676 client: &ControlClient,
3677 screenshot_command: ScreenshotCommand,
3678) -> anyhow::Result<serde_json::Value> {
3679 let response =
3680 send_control_command(client, ControlCommand::Screenshot { screenshot_command }).await?;
3681 match response {
3682 ControlResponse::Screenshot { result } => Ok(serde_json::to_value(result)?),
3683 other => bail!("unexpected screenshot response: {other:?}"),
3684 }
3685}
3686
3687async fn resolve_browser_surface(
3688 client: &ControlClient,
3689 browser: &BrowserSurfaceArgs,
3690) -> anyhow::Result<(WorkspaceId, PaneId, SurfaceId)> {
3691 let model = query_model(client).await?;
3692 if let Some(surface_id) = browser.surface.or_else(env_surface_id) {
3693 let (workspace_id, pane_id, kind) = find_surface_location(&model, surface_id)
3694 .ok_or_else(|| anyhow!("surface {surface_id} is not present in the current session"))?;
3695 if kind != PaneKind::Browser {
3696 bail!("surface {surface_id} is not a browser");
3697 }
3698 if let Some(workspace_id_arg) = browser.workspace
3699 && workspace_id_arg != workspace_id
3700 {
3701 bail!(
3702 "surface {surface_id} belongs to workspace {workspace_id}, not {workspace_id_arg}"
3703 );
3704 }
3705 if let Some(pane_id_arg) = browser.pane
3706 && pane_id_arg != pane_id
3707 {
3708 bail!("surface {surface_id} belongs to pane {pane_id}, not {pane_id_arg}");
3709 }
3710 return Ok((workspace_id, pane_id, surface_id));
3711 }
3712
3713 let workspace_id = resolve_workspace_id_from_model(&model, browser.workspace)?;
3714 let workspace = model
3715 .workspaces
3716 .get(&workspace_id)
3717 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
3718 let pane_id = browser
3719 .pane
3720 .or_else(env_pane_id)
3721 .unwrap_or(workspace.active_pane);
3722 let pane = workspace
3723 .panes
3724 .get(&pane_id)
3725 .ok_or_else(|| anyhow!("pane {pane_id} is not present in workspace {workspace_id}"))?;
3726 let surface_id = pane.active_surface;
3727 let surface = pane
3728 .surfaces
3729 .get(&surface_id)
3730 .ok_or_else(|| anyhow!("surface {surface_id} is not present in pane {pane_id}"))?;
3731 if surface.kind != PaneKind::Browser {
3732 bail!(
3733 "active surface {surface_id} in pane {pane_id} is not a browser; pass --surface or activate a browser pane"
3734 );
3735 }
3736 Ok((workspace_id, pane_id, surface_id))
3737}
3738
3739async fn resolve_terminal_surface(
3740 client: &ControlClient,
3741 terminal: &TerminalSurfaceArgs,
3742) -> anyhow::Result<(WorkspaceId, PaneId, SurfaceId)> {
3743 let model = query_model(client).await?;
3744 if let Some(surface_id) = terminal.surface.or_else(env_surface_id) {
3745 let (workspace_id, pane_id, kind) = find_surface_location(&model, surface_id)
3746 .ok_or_else(|| anyhow!("surface {surface_id} is not present in the current session"))?;
3747 if kind != PaneKind::Terminal {
3748 bail!("surface {surface_id} is not a terminal");
3749 }
3750 if let Some(workspace_id_arg) = terminal.workspace
3751 && workspace_id_arg != workspace_id
3752 {
3753 bail!(
3754 "surface {surface_id} belongs to workspace {workspace_id}, not {workspace_id_arg}"
3755 );
3756 }
3757 if let Some(pane_id_arg) = terminal.pane
3758 && pane_id_arg != pane_id
3759 {
3760 bail!("surface {surface_id} belongs to pane {pane_id}, not {pane_id_arg}");
3761 }
3762 return Ok((workspace_id, pane_id, surface_id));
3763 }
3764
3765 let workspace_id = resolve_workspace_id_from_model(&model, terminal.workspace)?;
3766 let workspace = model
3767 .workspaces
3768 .get(&workspace_id)
3769 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
3770 let pane_id = terminal
3771 .pane
3772 .or_else(env_pane_id)
3773 .unwrap_or(workspace.active_pane);
3774 let pane = workspace
3775 .panes
3776 .get(&pane_id)
3777 .ok_or_else(|| anyhow!("pane {pane_id} is not present in workspace {workspace_id}"))?;
3778 let surface_id = pane.active_surface;
3779 let surface = pane
3780 .surfaces
3781 .get(&surface_id)
3782 .ok_or_else(|| anyhow!("surface {surface_id} is not present in pane {pane_id}"))?;
3783 if surface.kind != PaneKind::Terminal {
3784 bail!(
3785 "active surface {surface_id} in pane {pane_id} is not a terminal; pass --surface or activate a terminal pane"
3786 );
3787 }
3788 Ok((workspace_id, pane_id, surface_id))
3789}
3790
3791fn find_surface_location(
3792 model: &AppModel,
3793 surface_id: SurfaceId,
3794) -> Option<(WorkspaceId, PaneId, PaneKind)> {
3795 model
3796 .workspaces
3797 .iter()
3798 .find_map(|(workspace_id, workspace)| {
3799 workspace.panes.iter().find_map(|(pane_id, pane)| {
3800 pane.surfaces
3801 .get(&surface_id)
3802 .map(|surface| (*workspace_id, *pane_id, surface.kind.clone()))
3803 })
3804 })
3805}
3806
3807fn resolve_required_browser_target(target: BrowserTargetArgs) -> anyhow::Result<BrowserTarget> {
3808 resolve_browser_target(target.reference, target.selector, true)
3809 .map(|target| target.expect("required browser target"))
3810}
3811
3812fn resolve_optional_browser_target(
3813 target: BrowserOptionalTargetArgs,
3814) -> anyhow::Result<Option<BrowserTarget>> {
3815 resolve_browser_target(target.reference, target.selector, false)
3816}
3817
3818fn resolve_browser_target(
3819 reference: Option<String>,
3820 selector: Option<String>,
3821 required: bool,
3822) -> anyhow::Result<Option<BrowserTarget>> {
3823 match (reference, selector) {
3824 (Some(reference), None) => Ok(Some(BrowserTarget::Ref { value: reference })),
3825 (None, Some(selector)) => Ok(Some(BrowserTarget::Selector { value: selector })),
3826 (None, None) if !required => Ok(None),
3827 (None, None) => bail!("missing browser target; pass --ref or --selector"),
3828 (Some(_), Some(_)) => bail!("pass only one of --ref or --selector"),
3829 }
3830}
3831
3832fn resolve_wait_condition(
3833 selector: Option<String>,
3834 text: Option<String>,
3835 url_contains: Option<String>,
3836 load_state: Option<CliBrowserLoadState>,
3837 script: Option<String>,
3838 delay_ms: Option<u64>,
3839) -> anyhow::Result<BrowserWaitCondition> {
3840 let mut condition = None;
3841 let mut set = |next| -> anyhow::Result<()> {
3842 if condition.is_some() {
3843 bail!(
3844 "browser wait requires exactly one of --selector, --text, --url-contains, --load-state, --script, or --delay-ms"
3845 );
3846 }
3847 condition = Some(next);
3848 Ok(())
3849 };
3850
3851 if let Some(selector) = selector {
3852 set(BrowserWaitCondition::Selector { selector })?;
3853 }
3854 if let Some(text) = text {
3855 set(BrowserWaitCondition::Text { text })?;
3856 }
3857 if let Some(pattern) = url_contains {
3858 set(BrowserWaitCondition::UrlMatches { pattern })?;
3859 }
3860 if let Some(state) = load_state {
3861 set(BrowserWaitCondition::LoadState {
3862 state: state.into(),
3863 })?;
3864 }
3865 if let Some(script) = script {
3866 set(BrowserWaitCondition::Function { script })?;
3867 }
3868 if let Some(duration_ms) = delay_ms {
3869 set(BrowserWaitCondition::Delay { duration_ms })?;
3870 }
3871
3872 condition.context(
3873 "browser wait requires one of --selector, --text, --url-contains, --load-state, --script, or --delay-ms",
3874 )
3875}
3876
3877#[allow(clippy::too_many_arguments)]
3878async fn emit_agent_hook(
3879 socket: Option<PathBuf>,
3880 workspace: Option<WorkspaceId>,
3881 pane: Option<PaneId>,
3882 surface: Option<SurfaceId>,
3883 agent: Option<String>,
3884 title: Option<String>,
3885 message: Option<String>,
3886 kind: CliSignalKind,
3887) -> anyhow::Result<()> {
3888 let workspace_id = workspace
3889 .or_else(env_workspace_id)
3890 .context("missing workspace id; pass --workspace or run from inside Taskers")?;
3891 let pane_id = pane
3892 .or_else(env_pane_id)
3893 .context("missing pane id; pass --pane or run from inside Taskers")?;
3894 let surface_id = surface.or_else(env_surface_id);
3895 let client = ControlClient::new(resolve_socket_path(socket));
3896
3897 let normalized_agent = agent
3898 .or_else(|| title.as_deref().and_then(infer_agent_kind))
3899 .unwrap_or_else(|| "shell".into());
3900 let normalized_title = title.unwrap_or_else(|| normalized_agent.clone());
3901 let metadata = Some(taskers_domain::SignalPaneMetadata {
3902 title: None,
3903 agent_title: Some(normalized_title.clone()),
3904 cwd: None,
3905 repo_name: None,
3906 git_branch: None,
3907 ports: Vec::new(),
3908 agent_kind: Some(normalized_agent.clone()),
3909 agent_active: Some(matches!(
3910 kind,
3911 CliSignalKind::Started
3912 | CliSignalKind::Progress
3913 | CliSignalKind::WaitingInput
3914 | CliSignalKind::Notification
3915 )),
3916 agent_command: None,
3917 });
3918 let normalized_message = message
3919 .as_deref()
3920 .map(str::trim)
3921 .filter(|value| !value.is_empty())
3922 .map(str::to_owned);
3923 let status_text = normalized_message
3924 .clone()
3925 .unwrap_or_else(|| normalized_title.clone());
3926 let signal_response = send_control_command(
3927 &client,
3928 ControlCommand::EmitSignal {
3929 workspace_id,
3930 pane_id,
3931 surface_id,
3932 event: SignalEvent {
3933 source: format!("agent-hook:{normalized_agent}"),
3934 kind: kind.into(),
3935 message,
3936 metadata,
3937 timestamp: OffsetDateTime::now_utc(),
3938 },
3939 },
3940 )
3941 .await?;
3942
3943 let (resolved_workspace_id, resolved_pane_id, resolved_surface_id) = match surface_id {
3944 Some(surface_id) => {
3945 let (workspace_id, pane_id, surface_id) =
3946 resolve_surface_context(&client, surface_id).await?;
3947 (workspace_id, pane_id, Some(surface_id))
3948 }
3949 None => (workspace_id, pane_id, surface_id),
3950 };
3951
3952 if let Some(log_message) = normalized_message.clone() {
3953 let _ = send_control_command(
3954 &client,
3955 ControlCommand::AgentAppendLog {
3956 workspace_id: resolved_workspace_id,
3957 entry: WorkspaceLogEntry {
3958 source: Some(normalized_agent.clone()),
3959 message: log_message,
3960 created_at: OffsetDateTime::now_utc(),
3961 },
3962 },
3963 )
3964 .await?;
3965 }
3966
3967 match kind {
3968 CliSignalKind::Started | CliSignalKind::Progress => {
3969 let _ = send_control_command(
3970 &client,
3971 ControlCommand::AgentSetStatus {
3972 workspace_id: resolved_workspace_id,
3973 text: status_text,
3974 },
3975 )
3976 .await?;
3977 }
3978 CliSignalKind::WaitingInput | CliSignalKind::Notification => {
3979 let _ = send_control_command(
3980 &client,
3981 ControlCommand::AgentSetStatus {
3982 workspace_id: resolved_workspace_id,
3983 text: status_text.clone(),
3984 },
3985 )
3986 .await?;
3987 }
3988 CliSignalKind::Completed | CliSignalKind::Error => {
3989 if matches!(kind, CliSignalKind::Completed) {
3990 let _ = send_control_command(
3991 &client,
3992 ControlCommand::AgentClearStatus {
3993 workspace_id: resolved_workspace_id,
3994 },
3995 )
3996 .await?;
3997 let _ = send_control_command(
3998 &client,
3999 ControlCommand::AgentClearProgress {
4000 workspace_id: resolved_workspace_id,
4001 },
4002 )
4003 .await?;
4004 } else {
4005 let _ = send_control_command(
4006 &client,
4007 ControlCommand::AgentSetStatus {
4008 workspace_id: resolved_workspace_id,
4009 text: status_text,
4010 },
4011 )
4012 .await?;
4013 let _ = send_control_command(
4014 &client,
4015 ControlCommand::AgentClearProgress {
4016 workspace_id: resolved_workspace_id,
4017 },
4018 )
4019 .await?;
4020 }
4021 }
4022 CliSignalKind::Metadata => {}
4023 }
4024
4025 if matches!(
4026 kind,
4027 CliSignalKind::WaitingInput | CliSignalKind::Notification | CliSignalKind::Error
4028 ) {
4029 let flash_surface_id = match resolved_surface_id.or_else(env_surface_id) {
4030 Some(surface_id) => Some(surface_id),
4031 None => {
4032 let model = query_model(&client).await?;
4033 Some(active_surface_for_pane(
4034 &model,
4035 resolved_workspace_id,
4036 resolved_pane_id,
4037 )?)
4038 }
4039 };
4040 if let Some(surface_id) = flash_surface_id {
4041 let _ = send_control_command(
4042 &client,
4043 ControlCommand::AgentTriggerFlash {
4044 workspace_id: resolved_workspace_id,
4045 pane_id: resolved_pane_id,
4046 surface_id,
4047 },
4048 )
4049 .await?;
4050 }
4051 }
4052
4053 println!("{}", serde_json::to_string_pretty(&signal_response)?);
4054 Ok(())
4055}
4056
4057fn infer_agent_kind(value: &str) -> Option<String> {
4058 let normalized = value.trim().to_ascii_lowercase();
4059 match normalized.as_str() {
4060 "codex" => Some("codex".into()),
4061 "claude" | "claude code" | "claude-code" => Some("claude".into()),
4062 "opencode" => Some("opencode".into()),
4063 "aider" => Some("aider".into()),
4064 _ => None,
4065 }
4066}
4067
4068#[cfg(test)]
4069mod tests {
4070 use std::{
4071 path::PathBuf,
4072 sync::Mutex,
4073 time::{SystemTime, UNIX_EPOCH},
4074 };
4075
4076 use clap::Parser;
4077 use taskers_control::{
4078 BrowserTarget, BrowserWaitCondition, ControlClient, ControlCommand, InMemoryController,
4079 ScreenshotCommand, ScreenshotTarget, bind_socket, serve,
4080 };
4081 use taskers_domain::{AppModel, BrowserProfileMode, PaneKind, SplitAxis, WorkspaceWindowId};
4082 use tokio::sync::oneshot;
4083
4084 use super::{
4085 Cli, CliBrowserLoadState, CliScreenshotTarget, CliSignalKind, CompletionQueryArgs,
4086 CompletionShell, ScreenshotArgs, completion_query_candidates, emit_agent_hook,
4087 ensure_implicit_notify_target_context, env_pane_id, env_surface_id, env_workspace_id,
4088 infer_agent_kind, query_model, render_completion, resolve_browser_target,
4089 resolve_screenshot_command, resolve_wait_condition,
4090 resolve_workspace_window_screenshot_target, send_screenshot_command,
4091 };
4092
4093 static ENV_LOCK: Mutex<()> = Mutex::new(());
4094
4095 fn unique_temp_dir(prefix: &str) -> PathBuf {
4096 let unique = SystemTime::now()
4097 .duration_since(UNIX_EPOCH)
4098 .expect("time")
4099 .as_nanos();
4100 std::env::temp_dir().join(format!("{prefix}-{unique}"))
4101 }
4102
4103 #[test]
4104 fn infers_known_agent_names() {
4105 assert_eq!(infer_agent_kind("Codex"), Some("codex".into()));
4106 assert_eq!(infer_agent_kind("Claude Code"), Some("claude".into()));
4107 assert_eq!(infer_agent_kind("opencode"), Some("opencode".into()));
4108 assert_eq!(infer_agent_kind("unknown"), None);
4109 }
4110
4111 #[test]
4112 fn parses_completion_subcommand() {
4113 let cli = Cli::try_parse_from(["taskersctl", "completion", "fish"])
4114 .expect("completion subcommand should parse");
4115 let debug = format!("{cli:?}");
4116 assert!(debug.contains("Completion"));
4117 assert!(debug.contains("Fish"));
4118 }
4119
4120 #[test]
4121 fn generated_completion_scripts_include_public_commands_only() {
4122 for shell in [
4123 CompletionShell::Bash,
4124 CompletionShell::Fish,
4125 CompletionShell::Zsh,
4126 ] {
4127 let script = render_completion(shell);
4128 assert!(
4129 script.contains("browser"),
4130 "expected browser command in {shell:?} completion"
4131 );
4132 assert!(
4133 script.contains("workspace"),
4134 "expected workspace command in {shell:?} completion"
4135 );
4136 assert!(
4137 script.contains("--socket"),
4138 "expected socket flag in {shell:?} completion"
4139 );
4140 assert!(
4141 !script.contains(" session "),
4142 "hidden session command leaked into {shell:?} completion"
4143 );
4144 }
4145 }
4146
4147 #[tokio::test]
4148 async fn completion_query_returns_static_flag_values() {
4149 let values = completion_query_candidates(&CompletionQueryArgs {
4150 path: Some("screenshot".into()),
4151 flag: Some("--target".into()),
4152 ..CompletionQueryArgs::default()
4153 })
4154 .await;
4155
4156 assert_eq!(
4157 values,
4158 vec![
4159 "surface".to_string(),
4160 "pane".to_string(),
4161 "workspace_window".to_string(),
4162 "workspace_canvas".to_string()
4163 ]
4164 );
4165 }
4166
4167 #[tokio::test]
4168 async fn completion_query_returns_static_positional_values() {
4169 let values = completion_query_candidates(&CompletionQueryArgs {
4170 path: Some("completion".into()),
4171 positional: Some(0),
4172 ..CompletionQueryArgs::default()
4173 })
4174 .await;
4175
4176 assert_eq!(
4177 values,
4178 vec!["bash".to_string(), "fish".to_string(), "zsh".to_string()]
4179 );
4180 }
4181
4182 #[tokio::test]
4183 async fn completion_query_returns_dynamic_taskers_ids() {
4184 let tempdir = unique_temp_dir("taskers-cli-completion-query");
4185 std::fs::create_dir_all(&tempdir).expect("tempdir");
4186 let socket_path = tempdir.join("taskers.sock");
4187 let listener = bind_socket(&socket_path).expect("listener");
4188 let controller = InMemoryController::new(AppModel::new("Main"));
4189 let snapshot = controller.snapshot();
4190 let workspace = snapshot.model.active_workspace().expect("workspace");
4191 let workspace_id = workspace.id;
4192 let active_pane_id = workspace.active_pane;
4193 let initial_surface_id = workspace
4194 .panes
4195 .get(&active_pane_id)
4196 .expect("pane")
4197 .active_surface;
4198
4199 controller
4200 .handle(ControlCommand::SplitPane {
4201 workspace_id,
4202 pane_id: Some(active_pane_id),
4203 axis: SplitAxis::Horizontal,
4204 })
4205 .expect("split pane");
4206 let second_pane_id = controller
4207 .snapshot()
4208 .model
4209 .workspaces
4210 .get(&workspace_id)
4211 .and_then(|workspace| {
4212 workspace
4213 .panes
4214 .keys()
4215 .copied()
4216 .find(|pane_id| *pane_id != active_pane_id)
4217 })
4218 .expect("second pane");
4219
4220 controller
4221 .handle(ControlCommand::CreateSurface {
4222 workspace_id,
4223 pane_id: active_pane_id,
4224 kind: PaneKind::Browser,
4225 browser_profile_mode: Some(BrowserProfileMode::PersistentDefault),
4226 })
4227 .expect("create surface");
4228 let browser_surface_id = controller
4229 .snapshot()
4230 .model
4231 .workspaces
4232 .get(&workspace_id)
4233 .and_then(|workspace| workspace.panes.get(&active_pane_id))
4234 .and_then(|pane| {
4235 pane.surfaces
4236 .keys()
4237 .copied()
4238 .find(|surface_id| *surface_id != initial_surface_id)
4239 })
4240 .expect("browser surface");
4241
4242 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
4243 let server = tokio::spawn(async move {
4244 serve(listener, controller, async move {
4245 let _ = shutdown_rx.await;
4246 })
4247 .await
4248 });
4249
4250 let workspace_values = completion_query_candidates(&CompletionQueryArgs {
4251 path: Some("browser click".into()),
4252 flag: Some("--workspace".into()),
4253 socket: Some(socket_path.clone()),
4254 ..CompletionQueryArgs::default()
4255 })
4256 .await;
4257 assert!(workspace_values.contains(&workspace_id.to_string()));
4258
4259 let pane_values = completion_query_candidates(&CompletionQueryArgs {
4260 path: Some("browser click".into()),
4261 flag: Some("--pane".into()),
4262 socket: Some(socket_path.clone()),
4263 workspace: Some(workspace_id),
4264 ..CompletionQueryArgs::default()
4265 })
4266 .await;
4267 assert!(pane_values.contains(&active_pane_id.to_string()));
4268 assert!(pane_values.contains(&second_pane_id.to_string()));
4269
4270 let surface_values = completion_query_candidates(&CompletionQueryArgs {
4271 path: Some("browser click".into()),
4272 flag: Some("--surface".into()),
4273 socket: Some(socket_path.clone()),
4274 workspace: Some(workspace_id),
4275 pane: Some(active_pane_id),
4276 ..CompletionQueryArgs::default()
4277 })
4278 .await;
4279 assert!(surface_values.contains(&initial_surface_id.to_string()));
4280 assert!(surface_values.contains(&browser_surface_id.to_string()));
4281
4282 shutdown_tx.send(()).expect("shutdown");
4283 server.await.expect("server task").expect("serve cleanly");
4284 std::fs::remove_dir_all(&tempdir).expect("cleanup tempdir");
4285 }
4286
4287 #[test]
4288 fn codex_notify_helper_requires_embedded_surface_context() {
4289 let asset = include_str!(concat!(
4290 env!("CARGO_MANIFEST_DIR"),
4291 "/assets/taskers-codex-notify.sh"
4292 ));
4293
4294 for expected in [
4295 "TASKERS_WORKSPACE_ID",
4296 "TASKERS_PANE_ID",
4297 "TASKERS_SURFACE_ID",
4298 "TASKERS_TTY_NAME",
4299 "tty 2>/dev/null",
4300 "agent-hook stop",
4301 "--workspace \"$TASKERS_WORKSPACE_ID\"",
4302 "--pane \"$TASKERS_PANE_ID\"",
4303 "--surface \"$TASKERS_SURFACE_ID\"",
4304 ] {
4305 assert!(
4306 asset.contains(expected),
4307 "expected helper asset to contain {expected:?}"
4308 );
4309 }
4310 }
4311
4312 #[test]
4313 fn reads_runtime_context_ids_from_env() {
4314 let _guard = ENV_LOCK.lock().expect("env lock");
4315 unsafe {
4316 std::env::set_var(
4317 "TASKERS_WORKSPACE_ID",
4318 "019cede5-2843-7da1-a281-dd6b5d1cfbe6",
4319 );
4320 std::env::set_var("TASKERS_PANE_ID", "019cede5-2843-7da1-a281-dd4f2de73c9c");
4321 std::env::set_var("TASKERS_SURFACE_ID", "019cede5-2843-7da1-a281-dd2119ae9b83");
4322 }
4323
4324 assert!(env_workspace_id().is_some());
4325 assert!(env_pane_id().is_some());
4326 assert!(env_surface_id().is_some());
4327
4328 unsafe {
4329 std::env::remove_var("TASKERS_WORKSPACE_ID");
4330 std::env::remove_var("TASKERS_PANE_ID");
4331 std::env::remove_var("TASKERS_SURFACE_ID");
4332 }
4333 }
4334
4335 #[test]
4336 fn implicit_notify_requires_embedded_taskers_context() {
4337 let _guard = ENV_LOCK.lock().expect("env lock");
4338 unsafe {
4339 std::env::remove_var("TASKERS_WORKSPACE_ID");
4340 std::env::remove_var("TASKERS_PANE_ID");
4341 std::env::remove_var("TASKERS_SURFACE_ID");
4342 }
4343
4344 assert!(ensure_implicit_notify_target_context(None, None, None).is_err());
4345 assert!(ensure_implicit_notify_target_context(env_workspace_id(), None, None).is_err());
4346 }
4347
4348 #[test]
4349 fn implicit_notify_accepts_embedded_context_or_explicit_target() {
4350 let _guard = ENV_LOCK.lock().expect("env lock");
4351 unsafe {
4352 std::env::set_var(
4353 "TASKERS_WORKSPACE_ID",
4354 "019cede5-2843-7da1-a281-dd6b5d1cfbe6",
4355 );
4356 std::env::set_var("TASKERS_PANE_ID", "019cede5-2843-7da1-a281-dd4f2de73c9c");
4357 std::env::set_var("TASKERS_SURFACE_ID", "019cede5-2843-7da1-a281-dd2119ae9b83");
4358 std::env::remove_var("TASKERS_TTY_NAME");
4359 }
4360
4361 assert!(ensure_implicit_notify_target_context(None, None, None).is_ok());
4362
4363 unsafe {
4364 std::env::remove_var("TASKERS_WORKSPACE_ID");
4365 std::env::remove_var("TASKERS_PANE_ID");
4366 std::env::remove_var("TASKERS_SURFACE_ID");
4367 }
4368
4369 let workspace = "019cede5-2843-7da1-a281-dd6b5d1cfbe6"
4370 .parse()
4371 .expect("workspace id");
4372 assert!(ensure_implicit_notify_target_context(Some(workspace), None, None).is_ok());
4373 }
4374
4375 #[test]
4376 fn runtime_context_ids_are_ignored_when_tty_mismatches() {
4377 let _guard = ENV_LOCK.lock().expect("env lock");
4378 unsafe {
4379 std::env::set_var(
4380 "TASKERS_WORKSPACE_ID",
4381 "019cede5-2843-7da1-a281-dd6b5d1cfbe6",
4382 );
4383 std::env::set_var("TASKERS_PANE_ID", "019cede5-2843-7da1-a281-dd4f2de73c9c");
4384 std::env::set_var("TASKERS_SURFACE_ID", "019cede5-2843-7da1-a281-dd2119ae9b83");
4385 std::env::set_var("TASKERS_TTY_NAME", "/dev/pts/taskers-mismatch");
4386 }
4387
4388 assert!(env_workspace_id().is_none());
4389 assert!(env_pane_id().is_none());
4390 assert!(env_surface_id().is_none());
4391
4392 unsafe {
4393 std::env::remove_var("TASKERS_WORKSPACE_ID");
4394 std::env::remove_var("TASKERS_PANE_ID");
4395 std::env::remove_var("TASKERS_SURFACE_ID");
4396 std::env::remove_var("TASKERS_TTY_NAME");
4397 }
4398 }
4399
4400 #[tokio::test]
4401 async fn agent_hook_status_and_logs_follow_surface_workspace_after_move() {
4402 let tempdir = unique_temp_dir("taskers-cli-agent-hook");
4403 std::fs::create_dir_all(&tempdir).expect("tempdir");
4404 let socket_path = tempdir.join("taskers.sock");
4405 let listener = bind_socket(&socket_path).expect("listener");
4406 let controller = InMemoryController::new(AppModel::new("Main"));
4407 let snapshot = controller.snapshot();
4408 let source_workspace = snapshot.model.active_workspace().expect("workspace");
4409 let source_workspace_id = source_workspace.id;
4410 let source_pane_id = source_workspace.active_pane;
4411
4412 controller
4413 .handle(ControlCommand::CreateSurface {
4414 workspace_id: source_workspace_id,
4415 pane_id: source_pane_id,
4416 kind: PaneKind::Browser,
4417 browser_profile_mode: Some(BrowserProfileMode::PersistentDefault),
4418 })
4419 .expect("create surface");
4420 let moved_surface_id = controller
4421 .snapshot()
4422 .model
4423 .workspaces
4424 .get(&source_workspace_id)
4425 .and_then(|workspace| workspace.panes.get(&source_pane_id))
4426 .map(|pane| pane.active_surface)
4427 .expect("moved surface");
4428
4429 controller
4430 .handle(ControlCommand::CreateWorkspace {
4431 label: "Docs".into(),
4432 })
4433 .expect("create target workspace");
4434 let target_workspace_id = controller
4435 .snapshot()
4436 .model
4437 .active_workspace_id()
4438 .expect("target workspace");
4439
4440 controller
4441 .handle(ControlCommand::MoveSurfaceToWorkspace {
4442 source_workspace_id,
4443 source_pane_id,
4444 surface_id: moved_surface_id,
4445 target_workspace_id,
4446 })
4447 .expect("move surface");
4448
4449 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
4450 let server = tokio::spawn(serve(listener, controller.clone(), async move {
4451 let _ = shutdown_rx.await;
4452 }));
4453
4454 emit_agent_hook(
4455 Some(socket_path.clone()),
4456 Some(source_workspace_id),
4457 Some(source_pane_id),
4458 Some(moved_surface_id),
4459 Some("codex".into()),
4460 Some("Codex".into()),
4461 Some("Turn complete".into()),
4462 CliSignalKind::Notification,
4463 )
4464 .await
4465 .expect("emit agent hook");
4466
4467 let snapshot = controller.snapshot();
4468 let source_workspace_after = snapshot
4469 .model
4470 .workspaces
4471 .get(&source_workspace_id)
4472 .expect("source workspace");
4473 let target_workspace_after = snapshot
4474 .model
4475 .workspaces
4476 .get(&target_workspace_id)
4477 .expect("target workspace");
4478
4479 assert_eq!(source_workspace_after.status_text, None);
4480 assert!(
4481 source_workspace_after.log_entries.is_empty(),
4482 "expected source workspace log to stay empty"
4483 );
4484 assert_eq!(
4485 target_workspace_after.status_text.as_deref(),
4486 Some("Turn complete")
4487 );
4488 assert_eq!(target_workspace_after.log_entries.len(), 1);
4489 assert_eq!(
4490 target_workspace_after.log_entries[0].message,
4491 "Turn complete"
4492 );
4493
4494 let target_surface = target_workspace_after
4495 .panes
4496 .values()
4497 .flat_map(|pane| pane.surfaces.values())
4498 .find(|surface| surface.id == moved_surface_id)
4499 .expect("target surface");
4500 assert_eq!(
4501 target_surface.metadata.latest_agent_message.as_deref(),
4502 Some("Turn complete")
4503 );
4504 assert!(
4505 target_workspace_after
4506 .surface_flash_tokens
4507 .contains_key(&moved_surface_id),
4508 "expected flash token on moved target surface"
4509 );
4510
4511 shutdown_tx.send(()).expect("shutdown");
4512 server.await.expect("server task").expect("serve cleanly");
4513 std::fs::remove_dir_all(&tempdir).expect("cleanup tempdir");
4514 }
4515
4516 #[tokio::test]
4517 async fn screenshot_workspace_window_resolves_to_selected_workspace() {
4518 let tempdir = unique_temp_dir("taskers-cli-screenshot-window");
4519 std::fs::create_dir_all(&tempdir).expect("tempdir");
4520 let socket_path = tempdir.join("taskers.sock");
4521 let listener = bind_socket(&socket_path).expect("listener");
4522 let controller = InMemoryController::new(AppModel::new("Main"));
4523 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
4524 let server = tokio::spawn(async move {
4525 serve(listener, controller, async move {
4526 let _ = shutdown_rx.await;
4527 })
4528 .await
4529 });
4530
4531 let client = ControlClient::new(socket_path.clone());
4532 let workspace_id = query_model(&client)
4533 .await
4534 .expect("model")
4535 .active_workspace_id()
4536 .expect("workspace");
4537
4538 let command = resolve_screenshot_command(
4539 &client,
4540 &ScreenshotArgs {
4541 socket: Some(socket_path),
4542 target: CliScreenshotTarget::WorkspaceWindow,
4543 workspace: Some(workspace_id),
4544 pane: None,
4545 surface: None,
4546 out: Some(tempdir.join("window.png").display().to_string()),
4547 },
4548 )
4549 .await
4550 .expect("resolve screenshot");
4551
4552 match command {
4553 ScreenshotCommand::Capture {
4554 target:
4555 ScreenshotTarget::WorkspaceWindow {
4556 workspace_id: resolved_workspace_id,
4557 },
4558 ..
4559 } => assert_eq!(resolved_workspace_id, workspace_id),
4560 other => panic!("unexpected screenshot command: {other:?}"),
4561 }
4562
4563 shutdown_tx.send(()).expect("shutdown");
4564 server.await.expect("server task").expect("serve cleanly");
4565 std::fs::remove_dir_all(&tempdir).expect("cleanup tempdir");
4566 }
4567
4568 #[tokio::test]
4569 async fn screenshot_surface_rejects_non_terminal_surface() {
4570 let tempdir = unique_temp_dir("taskers-cli-screenshot-surface");
4571 std::fs::create_dir_all(&tempdir).expect("tempdir");
4572 let socket_path = tempdir.join("taskers.sock");
4573 let listener = bind_socket(&socket_path).expect("listener");
4574 let controller = InMemoryController::new(AppModel::new("Main"));
4575 let snapshot = controller.snapshot();
4576 let workspace = snapshot.model.active_workspace().expect("workspace");
4577
4578 controller
4579 .handle(ControlCommand::CreateSurface {
4580 workspace_id: workspace.id,
4581 pane_id: workspace.active_pane,
4582 kind: PaneKind::Browser,
4583 browser_profile_mode: Some(BrowserProfileMode::PersistentDefault),
4584 })
4585 .expect("create browser surface");
4586 let browser_surface_id = controller
4587 .snapshot()
4588 .model
4589 .workspaces
4590 .get(&workspace.id)
4591 .and_then(|workspace| workspace.panes.get(&workspace.active_pane))
4592 .map(|pane| pane.active_surface)
4593 .expect("browser surface");
4594
4595 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
4596 let server = tokio::spawn(async move {
4597 serve(listener, controller, async move {
4598 let _ = shutdown_rx.await;
4599 })
4600 .await
4601 });
4602
4603 let client = ControlClient::new(socket_path.clone());
4604 let error = resolve_screenshot_command(
4605 &client,
4606 &ScreenshotArgs {
4607 socket: Some(socket_path),
4608 target: CliScreenshotTarget::Surface,
4609 workspace: Some(workspace.id),
4610 pane: Some(workspace.active_pane),
4611 surface: Some(browser_surface_id),
4612 out: None,
4613 },
4614 )
4615 .await
4616 .expect_err("browser surface should not resolve as a terminal screenshot target");
4617
4618 assert!(
4619 error.to_string().contains("not a terminal"),
4620 "unexpected error: {error}"
4621 );
4622
4623 shutdown_tx.send(()).expect("shutdown");
4624 server.await.expect("server task").expect("serve cleanly");
4625 std::fs::remove_dir_all(&tempdir).expect("cleanup tempdir");
4626 }
4627
4628 #[test]
4629 fn screenshot_workspace_window_errors_when_workspace_has_no_active_window() {
4630 let mut model = AppModel::new("Main");
4631 let workspace_id = model.active_workspace_id().expect("workspace");
4632 let workspace = model.workspaces.get_mut(&workspace_id).expect("workspace");
4633 workspace.active_window = WorkspaceWindowId::new();
4634 let error = resolve_workspace_window_screenshot_target(&model, Some(workspace_id))
4635 .expect_err("workspace without active window should fail");
4636
4637 assert!(
4638 error.to_string().contains("no active workspace window"),
4639 "unexpected error: {error}"
4640 );
4641 }
4642
4643 #[tokio::test]
4644 async fn screenshot_bridge_unavailable_does_not_create_output() {
4645 let tempdir = unique_temp_dir("taskers-cli-screenshot-unavailable");
4646 std::fs::create_dir_all(&tempdir).expect("tempdir");
4647 let socket_path = tempdir.join("missing.sock");
4648 let output_path = tempdir.join("missing.png");
4649 let client = ControlClient::new(socket_path);
4650
4651 let error = send_screenshot_command(
4652 &client,
4653 ScreenshotCommand::Capture {
4654 target: ScreenshotTarget::WorkspaceCanvas {
4655 workspace_id: taskers_domain::WorkspaceId::new(),
4656 },
4657 path: Some(output_path.display().to_string()),
4658 },
4659 )
4660 .await
4661 .expect_err("missing host bridge should fail");
4662
4663 assert!(
4664 !output_path.exists(),
4665 "unexpected screenshot artifact at {}",
4666 output_path.display()
4667 );
4668 assert!(
4669 error.to_string().contains("No such file")
4670 || error.to_string().contains("os error")
4671 || error.to_string().contains("connect"),
4672 "unexpected error: {error}"
4673 );
4674
4675 std::fs::remove_dir_all(&tempdir).expect("cleanup tempdir");
4676 }
4677
4678 #[test]
4679 fn browser_targets_require_exactly_one_selector_or_ref() {
4680 let target = resolve_browser_target(Some("@e1".into()), None, true).expect("target");
4681 assert_eq!(
4682 target,
4683 Some(BrowserTarget::Ref {
4684 value: "@e1".into()
4685 })
4686 );
4687 assert!(resolve_browser_target(None, None, true).is_err());
4688 assert!(resolve_browser_target(Some("@e1".into()), Some("a".into()), true).is_err());
4689 assert_eq!(
4690 resolve_browser_target(None, None, false).expect("optional target"),
4691 None
4692 );
4693 }
4694
4695 #[test]
4696 fn browser_wait_conditions_require_one_clause() {
4697 let wait = resolve_wait_condition(None, Some("hello".into()), None, None, None, None)
4698 .expect("wait");
4699 assert_eq!(
4700 wait,
4701 BrowserWaitCondition::Text {
4702 text: "hello".into()
4703 }
4704 );
4705
4706 let wait = resolve_wait_condition(
4707 None,
4708 None,
4709 None,
4710 Some(CliBrowserLoadState::Committed),
4711 None,
4712 None,
4713 )
4714 .expect("load state");
4715 assert_eq!(
4716 wait,
4717 BrowserWaitCondition::LoadState {
4718 state: taskers_control::BrowserLoadState::Committed
4719 }
4720 );
4721
4722 assert!(
4723 resolve_wait_condition(
4724 Some("body".into()),
4725 Some("hello".into()),
4726 None,
4727 None,
4728 None,
4729 None,
4730 )
4731 .is_err()
4732 );
4733 assert!(resolve_wait_condition(None, None, None, None, None, None).is_err());
4734 }
4735}