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 })
2500 .await?;
2501 println!("{}", serde_json::to_string_pretty(&response)?);
2502 }
2503 PaneCommand::Split {
2504 socket,
2505 workspace,
2506 pane,
2507 axis,
2508 kind,
2509 url,
2510 ephemeral,
2511 } => {
2512 if url.is_some() && kind != CliPaneKind::Browser {
2513 bail!("--url requires --kind browser");
2514 }
2515 if ephemeral && kind != CliPaneKind::Browser {
2516 bail!("--ephemeral requires --kind browser");
2517 }
2518
2519 let client = ControlClient::new(resolve_socket_path(socket));
2520 if kind == CliPaneKind::Terminal {
2521 let response = client
2522 .send(ControlCommand::SplitPane {
2523 workspace_id: workspace,
2524 pane_id: pane,
2525 axis: axis.into(),
2526 })
2527 .await?;
2528 println!("{}", serde_json::to_string_pretty(&response)?);
2529 } else {
2530 let response = send_control_command(
2531 &client,
2532 ControlCommand::SplitPane {
2533 workspace_id: workspace,
2534 pane_id: pane,
2535 axis: axis.into(),
2536 },
2537 )
2538 .await?;
2539 let pane_id = match response {
2540 ControlResponse::PaneSplit { pane_id } => pane_id,
2541 other => bail!("unexpected split response: {other:?}"),
2542 };
2543 let placeholder_surface_id =
2544 active_surface_for_pane(&query_model(&client).await?, workspace, pane_id)?;
2545 let surface_id = create_surface(
2546 &client,
2547 workspace,
2548 pane_id,
2549 kind.into(),
2550 Some(browser_profile_mode(ephemeral)),
2551 url.clone(),
2552 )
2553 .await?;
2554 send_control_command(
2555 &client,
2556 ControlCommand::CloseSurface {
2557 workspace_id: workspace,
2558 pane_id,
2559 surface_id: placeholder_surface_id,
2560 },
2561 )
2562 .await?;
2563 println!(
2564 "{}",
2565 serde_json::to_string_pretty(&serde_json::json!({
2566 "status": "browser_surface_opened",
2567 "workspace_id": workspace,
2568 "pane_id": pane_id,
2569 "surface_id": surface_id,
2570 "replaced_surface_id": placeholder_surface_id,
2571 "url": url,
2572 }))?
2573 );
2574 }
2575 }
2576 PaneCommand::Focus {
2577 socket,
2578 workspace,
2579 pane,
2580 } => {
2581 let client = ControlClient::new(resolve_socket_path(socket));
2582 let response = client
2583 .send(ControlCommand::FocusPane {
2584 workspace_id: workspace,
2585 pane_id: pane,
2586 })
2587 .await?;
2588 println!("{}", serde_json::to_string_pretty(&response)?);
2589 }
2590 PaneCommand::FocusDirection {
2591 socket,
2592 workspace,
2593 direction,
2594 } => {
2595 let client = ControlClient::new(resolve_socket_path(socket));
2596 let response = client
2597 .send(ControlCommand::FocusPaneDirection {
2598 workspace_id: workspace,
2599 direction: direction.into(),
2600 })
2601 .await?;
2602 println!("{}", serde_json::to_string_pretty(&response)?);
2603 }
2604 PaneCommand::ResizeWindow {
2605 socket,
2606 workspace,
2607 direction,
2608 amount,
2609 } => {
2610 let client = ControlClient::new(resolve_socket_path(socket));
2611 let response = client
2612 .send(ControlCommand::ResizeActiveWindow {
2613 workspace_id: workspace,
2614 direction: direction.into(),
2615 amount,
2616 })
2617 .await?;
2618 println!("{}", serde_json::to_string_pretty(&response)?);
2619 }
2620 PaneCommand::ResizeSplit {
2621 socket,
2622 workspace,
2623 direction,
2624 amount,
2625 } => {
2626 let client = ControlClient::new(resolve_socket_path(socket));
2627 let response = client
2628 .send(ControlCommand::ResizeActivePaneSplit {
2629 workspace_id: workspace,
2630 direction: direction.into(),
2631 amount,
2632 })
2633 .await?;
2634 println!("{}", serde_json::to_string_pretty(&response)?);
2635 }
2636 PaneCommand::Close {
2637 socket,
2638 workspace,
2639 pane,
2640 } => {
2641 let client = ControlClient::new(resolve_socket_path(socket));
2642 let response = client
2643 .send(ControlCommand::ClosePane {
2644 workspace_id: workspace,
2645 pane_id: pane,
2646 })
2647 .await?;
2648 println!("{}", serde_json::to_string_pretty(&response)?);
2649 }
2650 PaneCommand::Update {
2651 socket,
2652 pane,
2653 title,
2654 cwd,
2655 repo,
2656 branch,
2657 agent,
2658 } => {
2659 let client = ControlClient::new(resolve_socket_path(socket));
2660 let response = client
2661 .send(ControlCommand::UpdatePaneMetadata {
2662 pane_id: pane,
2663 patch: PaneMetadataPatch {
2664 title,
2665 cwd,
2666 url: None,
2667 browser_profile_mode: None,
2668 repo_name: repo,
2669 git_branch: branch,
2670 ports: None,
2671 agent_kind: agent,
2672 },
2673 })
2674 .await?;
2675 println!("{}", serde_json::to_string_pretty(&response)?);
2676 }
2677 },
2678 Command::Surface { command } => match command {
2679 SurfaceCommand::New {
2680 socket,
2681 workspace,
2682 pane,
2683 kind,
2684 url,
2685 ephemeral,
2686 } => {
2687 if url.is_some() && kind != CliPaneKind::Browser {
2688 bail!("--url requires --kind browser");
2689 }
2690 if ephemeral && kind != CliPaneKind::Browser {
2691 bail!("--ephemeral requires --kind browser");
2692 }
2693
2694 let client = ControlClient::new(resolve_socket_path(socket));
2695 if kind == CliPaneKind::Terminal {
2696 let response = client
2697 .send(ControlCommand::CreateSurface {
2698 workspace_id: workspace,
2699 pane_id: pane,
2700 kind: PaneKind::Terminal,
2701 browser_profile_mode: None,
2702 })
2703 .await?;
2704 println!("{}", serde_json::to_string_pretty(&response)?);
2705 } else {
2706 let surface_id = create_surface(
2707 &client,
2708 workspace,
2709 pane,
2710 kind.into(),
2711 Some(browser_profile_mode(ephemeral)),
2712 url.clone(),
2713 )
2714 .await?;
2715 println!(
2716 "{}",
2717 serde_json::to_string_pretty(&serde_json::json!({
2718 "status": "surface_created",
2719 "workspace_id": workspace,
2720 "pane_id": pane,
2721 "surface_id": surface_id,
2722 "kind": "browser",
2723 "url": url,
2724 "profile_mode": browser_profile_mode(ephemeral),
2725 }))?
2726 );
2727 }
2728 }
2729 SurfaceCommand::Focus {
2730 socket,
2731 workspace,
2732 pane,
2733 surface,
2734 } => {
2735 let client = ControlClient::new(resolve_socket_path(socket));
2736 let response = client
2737 .send(ControlCommand::FocusSurface {
2738 workspace_id: workspace,
2739 pane_id: pane,
2740 surface_id: surface,
2741 })
2742 .await?;
2743 println!("{}", serde_json::to_string_pretty(&response)?);
2744 }
2745 SurfaceCommand::Complete {
2746 socket,
2747 workspace,
2748 pane,
2749 surface,
2750 } => {
2751 let client = ControlClient::new(resolve_socket_path(socket));
2752 let response = client
2753 .send(ControlCommand::MarkSurfaceCompleted {
2754 workspace_id: workspace,
2755 pane_id: pane,
2756 surface_id: surface,
2757 })
2758 .await?;
2759 println!("{}", serde_json::to_string_pretty(&response)?);
2760 }
2761 SurfaceCommand::AgentStart {
2762 socket,
2763 workspace,
2764 pane,
2765 surface,
2766 agent,
2767 } => {
2768 let client = ControlClient::new(resolve_socket_path(socket));
2769 let response = client
2770 .send(ControlCommand::StartSurfaceAgentSession {
2771 workspace_id: workspace,
2772 pane_id: pane,
2773 surface_id: surface,
2774 agent_kind: agent,
2775 })
2776 .await?;
2777 println!("{}", serde_json::to_string_pretty(&response)?);
2778 }
2779 SurfaceCommand::AgentStop {
2780 socket,
2781 workspace,
2782 pane,
2783 surface,
2784 exit_status,
2785 } => {
2786 let client = ControlClient::new(resolve_socket_path(socket));
2787 let response = client
2788 .send(ControlCommand::StopSurfaceAgentSession {
2789 workspace_id: workspace,
2790 pane_id: pane,
2791 surface_id: surface,
2792 exit_status,
2793 })
2794 .await?;
2795 println!("{}", serde_json::to_string_pretty(&response)?);
2796 }
2797 SurfaceCommand::DismissAlert {
2798 socket,
2799 workspace,
2800 pane,
2801 surface,
2802 } => {
2803 let client = ControlClient::new(resolve_socket_path(socket));
2804 let response = client
2805 .send(ControlCommand::DismissSurfaceAlert {
2806 workspace_id: workspace,
2807 pane_id: pane,
2808 surface_id: surface,
2809 })
2810 .await?;
2811 println!("{}", serde_json::to_string_pretty(&response)?);
2812 }
2813 SurfaceCommand::Close {
2814 socket,
2815 workspace,
2816 pane,
2817 surface,
2818 } => {
2819 let client = ControlClient::new(resolve_socket_path(socket));
2820 let response = client
2821 .send(ControlCommand::CloseSurface {
2822 workspace_id: workspace,
2823 pane_id: pane,
2824 surface_id: surface,
2825 })
2826 .await?;
2827 println!("{}", serde_json::to_string_pretty(&response)?);
2828 }
2829 },
2830 }
2831
2832 Ok(())
2833}
2834
2835fn env_workspace_id() -> Option<WorkspaceId> {
2836 if !taskers_env_context_matches_current_tty() {
2837 return None;
2838 }
2839 env::var("TASKERS_WORKSPACE_ID")
2840 .ok()
2841 .and_then(|value| value.parse().ok())
2842}
2843
2844fn env_pane_id() -> Option<PaneId> {
2845 if !taskers_env_context_matches_current_tty() {
2846 return None;
2847 }
2848 env::var("TASKERS_PANE_ID")
2849 .ok()
2850 .and_then(|value| value.parse().ok())
2851}
2852
2853fn env_surface_id() -> Option<SurfaceId> {
2854 if !taskers_env_context_matches_current_tty() {
2855 return None;
2856 }
2857 env::var("TASKERS_SURFACE_ID")
2858 .ok()
2859 .and_then(|value| value.parse().ok())
2860}
2861
2862fn env_tty_name() -> Option<String> {
2863 env::var("TASKERS_TTY_NAME")
2864 .ok()
2865 .map(|value| value.trim().to_string())
2866 .filter(|value| !value.is_empty())
2867}
2868
2869fn current_process_tty_name() -> Option<String> {
2870 let output = ProcessCommand::new("ps")
2871 .args(["-o", "tty=", "-p", &std::process::id().to_string()])
2872 .output()
2873 .ok()?;
2874 if !output.status.success() {
2875 return None;
2876 }
2877
2878 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
2879 if raw.is_empty() || raw == "?" {
2880 return None;
2881 }
2882 if raw.starts_with('/') {
2883 Some(raw)
2884 } else {
2885 Some(format!("/dev/{raw}"))
2886 }
2887}
2888
2889fn taskers_env_context_matches_current_tty() -> bool {
2890 match env_tty_name() {
2891 Some(expected) => current_process_tty_name().is_some_and(|current| current == expected),
2892 None => true,
2893 }
2894}
2895
2896fn has_implicit_notify_target_context() -> bool {
2897 env_workspace_id().is_some() && env_pane_id().is_some() && env_surface_id().is_some()
2898}
2899
2900fn ensure_implicit_notify_target_context(
2901 workspace: Option<WorkspaceId>,
2902 pane: Option<PaneId>,
2903 surface: Option<SurfaceId>,
2904) -> anyhow::Result<()> {
2905 if workspace.is_some()
2906 || pane.is_some()
2907 || surface.is_some()
2908 || has_implicit_notify_target_context()
2909 {
2910 return Ok(());
2911 }
2912
2913 bail!(
2914 "notify requires embedded Taskers pane context; pass --workspace/--pane/--surface when running outside Taskers"
2915 )
2916}
2917
2918fn resolve_socket_path(socket: Option<PathBuf>) -> PathBuf {
2919 socket
2920 .or_else(|| env::var_os("TASKERS_SOCKET").map(PathBuf::from))
2921 .unwrap_or_else(default_socket_path)
2922}
2923
2924fn resolve_terminal_socket_path(socket: Option<PathBuf>) -> PathBuf {
2925 socket
2926 .or_else(|| env::var_os("TASKERS_TERMINAL_SOCKET").map(PathBuf::from))
2927 .unwrap_or_else(default_terminal_socket_path)
2928}
2929
2930async fn send_control_command(
2931 client: &ControlClient,
2932 command: ControlCommand,
2933) -> anyhow::Result<ControlResponse> {
2934 let response = client.send(command).await?;
2935 response.response.map_err(|error| anyhow!(error))
2936}
2937
2938async fn query_model(client: &ControlClient) -> anyhow::Result<AppModel> {
2939 let response = send_control_command(
2940 client,
2941 ControlCommand::QueryStatus {
2942 query: ControlQuery::All,
2943 },
2944 )
2945 .await?;
2946 match response {
2947 ControlResponse::Status { session } => Ok(session.model),
2948 other => bail!("unexpected query response: {other:?}"),
2949 }
2950}
2951
2952async fn resolve_surface_context(
2953 client: &ControlClient,
2954 surface_id: SurfaceId,
2955) -> anyhow::Result<(WorkspaceId, PaneId, SurfaceId)> {
2956 let response = send_control_command(
2957 client,
2958 ControlCommand::QueryStatus {
2959 query: ControlQuery::Identify {
2960 workspace_id: None,
2961 pane_id: None,
2962 surface_id: Some(surface_id),
2963 },
2964 },
2965 )
2966 .await?;
2967
2968 let ControlResponse::Identify { result } = response else {
2969 bail!("unexpected identify response: {response:?}");
2970 };
2971 let caller = result
2972 .caller
2973 .ok_or_else(|| anyhow!("missing identify caller context for surface {surface_id}"))?;
2974 Ok((caller.workspace_id, caller.pane_id, caller.surface_id))
2975}
2976
2977fn active_surface_for_pane(
2978 model: &AppModel,
2979 workspace_id: WorkspaceId,
2980 pane_id: PaneId,
2981) -> anyhow::Result<SurfaceId> {
2982 model
2983 .workspaces
2984 .get(&workspace_id)
2985 .and_then(|workspace| workspace.panes.get(&pane_id))
2986 .map(|pane| pane.active_surface)
2987 .ok_or_else(|| anyhow!("pane {pane_id} is not present in workspace {workspace_id}"))
2988}
2989
2990fn resolve_workspace_id_from_model(
2991 model: &AppModel,
2992 workspace: Option<WorkspaceId>,
2993) -> anyhow::Result<WorkspaceId> {
2994 workspace
2995 .or_else(env_workspace_id)
2996 .or_else(|| model.active_workspace_id())
2997 .context("missing workspace id; pass --workspace or run from inside Taskers")
2998}
2999
3000fn resolve_workspace_window_screenshot_target(
3001 model: &AppModel,
3002 workspace: Option<WorkspaceId>,
3003) -> anyhow::Result<ScreenshotTarget> {
3004 let workspace_id = resolve_workspace_id_from_model(model, workspace)?;
3005 let workspace = model
3006 .workspaces
3007 .get(&workspace_id)
3008 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
3009 if !workspace.windows.contains_key(&workspace.active_window) {
3010 bail!("workspace {workspace_id} has no active workspace window");
3011 }
3012 Ok(ScreenshotTarget::WorkspaceWindow { workspace_id })
3013}
3014
3015fn resolve_agent_target(
3016 model: &AppModel,
3017 workspace: Option<WorkspaceId>,
3018 pane: Option<PaneId>,
3019 surface: Option<SurfaceId>,
3020 scope: CliAgentTargetScope,
3021) -> anyhow::Result<AgentTarget> {
3022 let workspace_id = resolve_workspace_id_from_model(model, workspace)?;
3023 let workspace_record = model
3024 .workspaces
3025 .get(&workspace_id)
3026 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
3027
3028 let resolved_pane = pane
3029 .or_else(env_pane_id)
3030 .unwrap_or(workspace_record.active_pane);
3031 let pane_record = workspace_record.panes.get(&resolved_pane).ok_or_else(|| {
3032 anyhow!("pane {resolved_pane} is not present in workspace {workspace_id}")
3033 })?;
3034 let resolved_surface = surface
3035 .or_else(env_surface_id)
3036 .unwrap_or(pane_record.active_surface);
3037
3038 match scope {
3039 CliAgentTargetScope::Workspace => Ok(AgentTarget::Workspace { workspace_id }),
3040 CliAgentTargetScope::Pane => Ok(AgentTarget::Pane {
3041 workspace_id,
3042 pane_id: resolved_pane,
3043 }),
3044 CliAgentTargetScope::Surface => Ok(AgentTarget::Surface {
3045 workspace_id,
3046 pane_id: resolved_pane,
3047 surface_id: resolved_surface,
3048 }),
3049 }
3050}
3051
3052async fn create_surface(
3053 client: &ControlClient,
3054 workspace_id: WorkspaceId,
3055 pane_id: PaneId,
3056 kind: PaneKind,
3057 browser_profile_mode: Option<BrowserProfileMode>,
3058 url: Option<String>,
3059) -> anyhow::Result<SurfaceId> {
3060 let response = send_control_command(
3061 client,
3062 ControlCommand::CreateSurface {
3063 workspace_id,
3064 pane_id,
3065 kind,
3066 browser_profile_mode,
3067 },
3068 )
3069 .await?;
3070 let surface_id = match response {
3071 ControlResponse::SurfaceCreated { surface_id } => surface_id,
3072 other => bail!("unexpected create surface response: {other:?}"),
3073 };
3074
3075 if let Some(url) = url {
3076 send_control_command(
3077 client,
3078 ControlCommand::UpdateSurfaceMetadata {
3079 surface_id,
3080 patch: PaneMetadataPatch {
3081 title: None,
3082 cwd: None,
3083 url: Some(url),
3084 browser_profile_mode: None,
3085 repo_name: None,
3086 git_branch: None,
3087 ports: None,
3088 agent_kind: None,
3089 },
3090 },
3091 )
3092 .await?;
3093 }
3094
3095 Ok(surface_id)
3096}
3097
3098async fn handle_browser_cli_command(command: BrowserCommand) -> anyhow::Result<()> {
3099 match command {
3100 BrowserCommand::Open {
3101 socket,
3102 workspace,
3103 pane,
3104 url,
3105 ephemeral,
3106 } => {
3107 let client = ControlClient::new(resolve_socket_path(socket));
3108 let model = query_model(&client).await?;
3109 let workspace_id = resolve_workspace_id_from_model(&model, workspace)?;
3110 let target_pane = pane.or_else(env_pane_id).or_else(|| {
3111 model
3112 .workspaces
3113 .get(&workspace_id)
3114 .map(|workspace| workspace.active_pane)
3115 });
3116 let response = send_control_command(
3117 &client,
3118 ControlCommand::SplitPane {
3119 workspace_id,
3120 pane_id: target_pane,
3121 axis: SplitAxis::Horizontal,
3122 },
3123 )
3124 .await?;
3125 let pane_id = match response {
3126 ControlResponse::PaneSplit { pane_id } => pane_id,
3127 other => bail!("unexpected browser open response: {other:?}"),
3128 };
3129 let placeholder_surface_id =
3130 active_surface_for_pane(&query_model(&client).await?, workspace_id, pane_id)?;
3131 let surface_id = create_surface(
3132 &client,
3133 workspace_id,
3134 pane_id,
3135 PaneKind::Browser,
3136 Some(browser_profile_mode(ephemeral)),
3137 url.clone(),
3138 )
3139 .await?;
3140 send_control_command(
3141 &client,
3142 ControlCommand::CloseSurface {
3143 workspace_id,
3144 pane_id,
3145 surface_id: placeholder_surface_id,
3146 },
3147 )
3148 .await?;
3149 println!(
3150 "{}",
3151 serde_json::to_string_pretty(&serde_json::json!({
3152 "status": "browser_opened",
3153 "workspace_id": workspace_id,
3154 "pane_id": pane_id,
3155 "surface_id": surface_id,
3156 "url": url,
3157 "profile_mode": browser_profile_mode(ephemeral),
3158 }))?
3159 );
3160 }
3161 BrowserCommand::Navigate { browser, url } => {
3162 let client = ControlClient::new(resolve_socket_path(browser.socket.clone()));
3163 let (_, _, surface_id) = resolve_browser_surface(&client, &browser).await?;
3164 let result =
3165 send_browser_command(&client, BrowserControlCommand::Navigate { surface_id, url })
3166 .await?;
3167 print_browser_result(&result)?;
3168 }
3169 BrowserCommand::Back { browser } => {
3170 run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Back {
3171 surface_id,
3172 })
3173 .await?;
3174 }
3175 BrowserCommand::Forward { browser } => {
3176 run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Forward {
3177 surface_id,
3178 })
3179 .await?;
3180 }
3181 BrowserCommand::Reload { browser } => {
3182 run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Reload {
3183 surface_id,
3184 })
3185 .await?;
3186 }
3187 BrowserCommand::Snapshot { browser } => {
3188 run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Snapshot {
3189 surface_id,
3190 })
3191 .await?;
3192 }
3193 BrowserCommand::Eval { browser, script } => {
3194 run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Eval {
3195 surface_id,
3196 script,
3197 })
3198 .await?;
3199 }
3200 BrowserCommand::Wait {
3201 browser,
3202 selector,
3203 text,
3204 url_contains,
3205 load_state,
3206 script,
3207 delay_ms,
3208 timeout_ms,
3209 poll_interval_ms,
3210 } => {
3211 let condition =
3212 resolve_wait_condition(selector, text, url_contains, load_state, script, delay_ms)?;
3213 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Wait {
3214 surface_id,
3215 condition,
3216 timeout_ms,
3217 poll_interval_ms,
3218 })
3219 .await?;
3220 }
3221 BrowserCommand::Click {
3222 browser,
3223 target,
3224 snapshot_after,
3225 } => {
3226 let target = resolve_required_browser_target(target)?;
3227 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Click {
3228 surface_id,
3229 target,
3230 snapshot_after,
3231 })
3232 .await?;
3233 }
3234 BrowserCommand::Dblclick {
3235 browser,
3236 target,
3237 snapshot_after,
3238 } => {
3239 let target = resolve_required_browser_target(target)?;
3240 run_browser_surface_command(&browser, move |surface_id| {
3241 BrowserControlCommand::Dblclick {
3242 surface_id,
3243 target,
3244 snapshot_after,
3245 }
3246 })
3247 .await?;
3248 }
3249 BrowserCommand::Type {
3250 browser,
3251 target,
3252 text,
3253 snapshot_after,
3254 } => {
3255 let target = resolve_required_browser_target(target)?;
3256 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Type {
3257 surface_id,
3258 target,
3259 text,
3260 snapshot_after,
3261 })
3262 .await?;
3263 }
3264 BrowserCommand::Fill {
3265 browser,
3266 target,
3267 text,
3268 snapshot_after,
3269 } => {
3270 let target = resolve_required_browser_target(target)?;
3271 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Fill {
3272 surface_id,
3273 target,
3274 text,
3275 snapshot_after,
3276 })
3277 .await?;
3278 }
3279 BrowserCommand::Press {
3280 browser,
3281 target,
3282 key,
3283 snapshot_after,
3284 } => {
3285 let target = resolve_optional_browser_target(target)?;
3286 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Press {
3287 surface_id,
3288 target,
3289 key,
3290 snapshot_after,
3291 })
3292 .await?;
3293 }
3294 BrowserCommand::Keydown {
3295 browser,
3296 target,
3297 key,
3298 snapshot_after,
3299 } => {
3300 let target = resolve_optional_browser_target(target)?;
3301 run_browser_surface_command(&browser, move |surface_id| {
3302 BrowserControlCommand::Keydown {
3303 surface_id,
3304 target,
3305 key,
3306 snapshot_after,
3307 }
3308 })
3309 .await?;
3310 }
3311 BrowserCommand::Keyup {
3312 browser,
3313 target,
3314 key,
3315 snapshot_after,
3316 } => {
3317 let target = resolve_optional_browser_target(target)?;
3318 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Keyup {
3319 surface_id,
3320 target,
3321 key,
3322 snapshot_after,
3323 })
3324 .await?;
3325 }
3326 BrowserCommand::Hover {
3327 browser,
3328 target,
3329 snapshot_after,
3330 } => {
3331 let target = resolve_required_browser_target(target)?;
3332 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Hover {
3333 surface_id,
3334 target,
3335 snapshot_after,
3336 })
3337 .await?;
3338 }
3339 BrowserCommand::Focus {
3340 browser,
3341 target,
3342 snapshot_after,
3343 } => {
3344 let target = resolve_required_browser_target(target)?;
3345 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Focus {
3346 surface_id,
3347 target,
3348 snapshot_after,
3349 })
3350 .await?;
3351 }
3352 BrowserCommand::Check {
3353 browser,
3354 target,
3355 snapshot_after,
3356 } => {
3357 let target = resolve_required_browser_target(target)?;
3358 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Check {
3359 surface_id,
3360 target,
3361 snapshot_after,
3362 })
3363 .await?;
3364 }
3365 BrowserCommand::Uncheck {
3366 browser,
3367 target,
3368 snapshot_after,
3369 } => {
3370 let target = resolve_required_browser_target(target)?;
3371 run_browser_surface_command(&browser, move |surface_id| {
3372 BrowserControlCommand::Uncheck {
3373 surface_id,
3374 target,
3375 snapshot_after,
3376 }
3377 })
3378 .await?;
3379 }
3380 BrowserCommand::Select {
3381 browser,
3382 target,
3383 values,
3384 snapshot_after,
3385 } => {
3386 let target = resolve_required_browser_target(target)?;
3387 run_browser_surface_command(&browser, move |surface_id| {
3388 BrowserControlCommand::Select {
3389 surface_id,
3390 target,
3391 values,
3392 snapshot_after,
3393 }
3394 })
3395 .await?;
3396 }
3397 BrowserCommand::Scroll {
3398 browser,
3399 target,
3400 dx,
3401 dy,
3402 snapshot_after,
3403 } => {
3404 let target = resolve_optional_browser_target(target)?;
3405 run_browser_surface_command(&browser, move |surface_id| {
3406 BrowserControlCommand::Scroll {
3407 surface_id,
3408 target,
3409 dx,
3410 dy,
3411 snapshot_after,
3412 }
3413 })
3414 .await?;
3415 }
3416 BrowserCommand::ScrollIntoView {
3417 browser,
3418 target,
3419 snapshot_after,
3420 } => {
3421 let target = resolve_required_browser_target(target)?;
3422 run_browser_surface_command(&browser, move |surface_id| {
3423 BrowserControlCommand::ScrollIntoView {
3424 surface_id,
3425 target,
3426 snapshot_after,
3427 }
3428 })
3429 .await?;
3430 }
3431 BrowserCommand::Get { browser, command } => {
3432 let query = match command {
3433 BrowserGetSubcommand::Url => BrowserGetCommand::Url,
3434 BrowserGetSubcommand::Title => BrowserGetCommand::Title,
3435 BrowserGetSubcommand::Text { target } => BrowserGetCommand::Text {
3436 target: resolve_required_browser_target(target)?,
3437 },
3438 BrowserGetSubcommand::Html { target } => BrowserGetCommand::Html {
3439 target: resolve_required_browser_target(target)?,
3440 },
3441 BrowserGetSubcommand::Value { target } => BrowserGetCommand::Value {
3442 target: resolve_required_browser_target(target)?,
3443 },
3444 BrowserGetSubcommand::Attr { target, name } => BrowserGetCommand::Attr {
3445 target: resolve_required_browser_target(target)?,
3446 name,
3447 },
3448 BrowserGetSubcommand::Count { selector } => BrowserGetCommand::Count { selector },
3449 BrowserGetSubcommand::Box { target } => BrowserGetCommand::Box {
3450 target: resolve_required_browser_target(target)?,
3451 },
3452 BrowserGetSubcommand::Styles { target, properties } => BrowserGetCommand::Styles {
3453 target: resolve_required_browser_target(target)?,
3454 properties,
3455 },
3456 };
3457 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Get {
3458 surface_id,
3459 query,
3460 })
3461 .await?;
3462 }
3463 BrowserCommand::Is { browser, command } => {
3464 let query = match command {
3465 BrowserIsSubcommand::Visible { target } => BrowserPredicateCommand::Visible {
3466 target: resolve_required_browser_target(target)?,
3467 },
3468 BrowserIsSubcommand::Enabled { target } => BrowserPredicateCommand::Enabled {
3469 target: resolve_required_browser_target(target)?,
3470 },
3471 BrowserIsSubcommand::Checked { target } => BrowserPredicateCommand::Checked {
3472 target: resolve_required_browser_target(target)?,
3473 },
3474 };
3475 run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Is {
3476 surface_id,
3477 query,
3478 })
3479 .await?;
3480 }
3481 BrowserCommand::Screenshot { browser, out, full } => {
3482 run_browser_surface_command(&browser, move |surface_id| {
3483 BrowserControlCommand::Screenshot {
3484 surface_id,
3485 path: out,
3486 full_document: full,
3487 }
3488 })
3489 .await?;
3490 }
3491 BrowserCommand::FocusWebview { browser } => {
3492 run_browser_surface_command(&browser, |surface_id| {
3493 BrowserControlCommand::FocusWebview { surface_id }
3494 })
3495 .await?;
3496 }
3497 BrowserCommand::IsWebviewFocused { browser } => {
3498 run_browser_surface_command(&browser, |surface_id| {
3499 BrowserControlCommand::IsWebviewFocused { surface_id }
3500 })
3501 .await?;
3502 }
3503 BrowserCommand::ClearData {
3504 browser,
3505 origin_filter,
3506 } => {
3507 run_browser_surface_command(&browser, move |surface_id| {
3508 BrowserControlCommand::ClearData {
3509 surface_id,
3510 origin_filter,
3511 reload: true,
3512 }
3513 })
3514 .await?;
3515 }
3516 }
3517
3518 Ok(())
3519}
3520
3521async fn handle_screenshot_cli_command(screenshot: ScreenshotArgs) -> anyhow::Result<()> {
3522 let client = ControlClient::new(resolve_socket_path(screenshot.socket.clone()));
3523 let command = resolve_screenshot_command(&client, &screenshot).await?;
3524 let result = send_screenshot_command(&client, command).await?;
3525 println!("{}", serde_json::to_string_pretty(&result)?);
3526 Ok(())
3527}
3528
3529async fn resolve_screenshot_command(
3530 client: &ControlClient,
3531 screenshot: &ScreenshotArgs,
3532) -> anyhow::Result<ScreenshotCommand> {
3533 let model = query_model(client).await?;
3534 let target = match screenshot.target {
3535 CliScreenshotTarget::Surface => {
3536 let (_, _, surface_id) = resolve_terminal_surface(
3537 client,
3538 &TerminalSurfaceArgs {
3539 socket: screenshot.socket.clone(),
3540 workspace: screenshot.workspace,
3541 pane: screenshot.pane,
3542 surface: screenshot.surface,
3543 },
3544 )
3545 .await?;
3546 ScreenshotTarget::Surface { surface_id }
3547 }
3548 CliScreenshotTarget::Pane => {
3549 let workspace_id = resolve_workspace_id_from_model(&model, screenshot.workspace)?;
3550 let workspace = model
3551 .workspaces
3552 .get(&workspace_id)
3553 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
3554 let pane_id = screenshot
3555 .pane
3556 .or_else(env_pane_id)
3557 .unwrap_or(workspace.active_pane);
3558 workspace.panes.get(&pane_id).ok_or_else(|| {
3559 anyhow!("pane {pane_id} is not present in workspace {workspace_id}")
3560 })?;
3561 ScreenshotTarget::Pane {
3562 workspace_id,
3563 pane_id,
3564 }
3565 }
3566 CliScreenshotTarget::WorkspaceWindow => {
3567 resolve_workspace_window_screenshot_target(&model, screenshot.workspace)?
3568 }
3569 CliScreenshotTarget::WorkspaceCanvas => {
3570 let workspace_id = resolve_workspace_id_from_model(&model, screenshot.workspace)?;
3571 model
3572 .workspaces
3573 .get(&workspace_id)
3574 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
3575 ScreenshotTarget::WorkspaceCanvas { workspace_id }
3576 }
3577 };
3578
3579 Ok(ScreenshotCommand::Capture {
3580 target,
3581 path: screenshot.out.clone(),
3582 })
3583}
3584
3585async fn run_browser_surface_command<F>(
3586 browser: &BrowserSurfaceArgs,
3587 build: F,
3588) -> anyhow::Result<()>
3589where
3590 F: FnOnce(SurfaceId) -> BrowserControlCommand,
3591{
3592 let client = ControlClient::new(resolve_socket_path(browser.socket.clone()));
3593 let (_, _, surface_id) = resolve_browser_surface(&client, browser).await?;
3594 let result = send_browser_command(&client, build(surface_id)).await?;
3595 print_browser_result(&result)
3596}
3597
3598async fn handle_terminal_debug_cli_command(command: TerminalDebugCliCommand) -> anyhow::Result<()> {
3599 match command {
3600 TerminalDebugCliCommand::IsFocused { terminal } => {
3601 run_terminal_surface_command(&terminal, |surface_id| TerminalDebugCommand::IsFocused {
3602 surface_id,
3603 })
3604 .await
3605 }
3606 TerminalDebugCliCommand::ReadText {
3607 terminal,
3608 tail_lines,
3609 } => {
3610 run_terminal_surface_command(&terminal, |surface_id| TerminalDebugCommand::ReadText {
3611 surface_id,
3612 tail_lines,
3613 })
3614 .await
3615 }
3616 TerminalDebugCliCommand::RenderStats { terminal } => {
3617 run_terminal_surface_command(&terminal, |surface_id| {
3618 TerminalDebugCommand::RenderStats { surface_id }
3619 })
3620 .await
3621 }
3622 }
3623}
3624
3625async fn run_terminal_surface_command<F>(
3626 terminal: &TerminalSurfaceArgs,
3627 build: F,
3628) -> anyhow::Result<()>
3629where
3630 F: FnOnce(SurfaceId) -> TerminalDebugCommand,
3631{
3632 let client = ControlClient::new(resolve_socket_path(terminal.socket.clone()));
3633 let (_, _, surface_id) = resolve_terminal_surface(&client, terminal).await?;
3634 let result = send_terminal_debug_command(&client, build(surface_id)).await?;
3635 println!("{}", serde_json::to_string_pretty(&result)?);
3636 Ok(())
3637}
3638
3639async fn send_browser_command(
3640 client: &ControlClient,
3641 browser_command: BrowserControlCommand,
3642) -> anyhow::Result<serde_json::Value> {
3643 let response =
3644 send_control_command(client, ControlCommand::Browser { browser_command }).await?;
3645 match response {
3646 ControlResponse::Browser { result } => Ok(result),
3647 other => bail!("unexpected browser response: {other:?}"),
3648 }
3649}
3650
3651fn print_browser_result(result: &serde_json::Value) -> anyhow::Result<()> {
3652 println!("{}", serde_json::to_string_pretty(result)?);
3653 Ok(())
3654}
3655
3656async fn send_terminal_debug_command(
3657 client: &ControlClient,
3658 command: TerminalDebugCommand,
3659) -> anyhow::Result<serde_json::Value> {
3660 let response = send_control_command(
3661 client,
3662 ControlCommand::TerminalDebug {
3663 debug_command: command,
3664 },
3665 )
3666 .await?;
3667 match response {
3668 ControlResponse::TerminalDebug { result } => Ok(serde_json::to_value(result)?),
3669 other => bail!("unexpected terminal debug response: {other:?}"),
3670 }
3671}
3672
3673async fn send_screenshot_command(
3674 client: &ControlClient,
3675 screenshot_command: ScreenshotCommand,
3676) -> anyhow::Result<serde_json::Value> {
3677 let response =
3678 send_control_command(client, ControlCommand::Screenshot { screenshot_command }).await?;
3679 match response {
3680 ControlResponse::Screenshot { result } => Ok(serde_json::to_value(result)?),
3681 other => bail!("unexpected screenshot response: {other:?}"),
3682 }
3683}
3684
3685async fn resolve_browser_surface(
3686 client: &ControlClient,
3687 browser: &BrowserSurfaceArgs,
3688) -> anyhow::Result<(WorkspaceId, PaneId, SurfaceId)> {
3689 let model = query_model(client).await?;
3690 if let Some(surface_id) = browser.surface.or_else(env_surface_id) {
3691 let (workspace_id, pane_id, kind) = find_surface_location(&model, surface_id)
3692 .ok_or_else(|| anyhow!("surface {surface_id} is not present in the current session"))?;
3693 if kind != PaneKind::Browser {
3694 bail!("surface {surface_id} is not a browser");
3695 }
3696 if let Some(workspace_id_arg) = browser.workspace
3697 && workspace_id_arg != workspace_id
3698 {
3699 bail!(
3700 "surface {surface_id} belongs to workspace {workspace_id}, not {workspace_id_arg}"
3701 );
3702 }
3703 if let Some(pane_id_arg) = browser.pane
3704 && pane_id_arg != pane_id
3705 {
3706 bail!("surface {surface_id} belongs to pane {pane_id}, not {pane_id_arg}");
3707 }
3708 return Ok((workspace_id, pane_id, surface_id));
3709 }
3710
3711 let workspace_id = resolve_workspace_id_from_model(&model, browser.workspace)?;
3712 let workspace = model
3713 .workspaces
3714 .get(&workspace_id)
3715 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
3716 let pane_id = browser
3717 .pane
3718 .or_else(env_pane_id)
3719 .unwrap_or(workspace.active_pane);
3720 let pane = workspace
3721 .panes
3722 .get(&pane_id)
3723 .ok_or_else(|| anyhow!("pane {pane_id} is not present in workspace {workspace_id}"))?;
3724 let surface_id = pane.active_surface;
3725 let surface = pane
3726 .surfaces
3727 .get(&surface_id)
3728 .ok_or_else(|| anyhow!("surface {surface_id} is not present in pane {pane_id}"))?;
3729 if surface.kind != PaneKind::Browser {
3730 bail!(
3731 "active surface {surface_id} in pane {pane_id} is not a browser; pass --surface or activate a browser pane"
3732 );
3733 }
3734 Ok((workspace_id, pane_id, surface_id))
3735}
3736
3737async fn resolve_terminal_surface(
3738 client: &ControlClient,
3739 terminal: &TerminalSurfaceArgs,
3740) -> anyhow::Result<(WorkspaceId, PaneId, SurfaceId)> {
3741 let model = query_model(client).await?;
3742 if let Some(surface_id) = terminal.surface.or_else(env_surface_id) {
3743 let (workspace_id, pane_id, kind) = find_surface_location(&model, surface_id)
3744 .ok_or_else(|| anyhow!("surface {surface_id} is not present in the current session"))?;
3745 if kind != PaneKind::Terminal {
3746 bail!("surface {surface_id} is not a terminal");
3747 }
3748 if let Some(workspace_id_arg) = terminal.workspace
3749 && workspace_id_arg != workspace_id
3750 {
3751 bail!(
3752 "surface {surface_id} belongs to workspace {workspace_id}, not {workspace_id_arg}"
3753 );
3754 }
3755 if let Some(pane_id_arg) = terminal.pane
3756 && pane_id_arg != pane_id
3757 {
3758 bail!("surface {surface_id} belongs to pane {pane_id}, not {pane_id_arg}");
3759 }
3760 return Ok((workspace_id, pane_id, surface_id));
3761 }
3762
3763 let workspace_id = resolve_workspace_id_from_model(&model, terminal.workspace)?;
3764 let workspace = model
3765 .workspaces
3766 .get(&workspace_id)
3767 .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?;
3768 let pane_id = terminal
3769 .pane
3770 .or_else(env_pane_id)
3771 .unwrap_or(workspace.active_pane);
3772 let pane = workspace
3773 .panes
3774 .get(&pane_id)
3775 .ok_or_else(|| anyhow!("pane {pane_id} is not present in workspace {workspace_id}"))?;
3776 let surface_id = pane.active_surface;
3777 let surface = pane
3778 .surfaces
3779 .get(&surface_id)
3780 .ok_or_else(|| anyhow!("surface {surface_id} is not present in pane {pane_id}"))?;
3781 if surface.kind != PaneKind::Terminal {
3782 bail!(
3783 "active surface {surface_id} in pane {pane_id} is not a terminal; pass --surface or activate a terminal pane"
3784 );
3785 }
3786 Ok((workspace_id, pane_id, surface_id))
3787}
3788
3789fn find_surface_location(
3790 model: &AppModel,
3791 surface_id: SurfaceId,
3792) -> Option<(WorkspaceId, PaneId, PaneKind)> {
3793 model
3794 .workspaces
3795 .iter()
3796 .find_map(|(workspace_id, workspace)| {
3797 workspace.panes.iter().find_map(|(pane_id, pane)| {
3798 pane.surfaces
3799 .get(&surface_id)
3800 .map(|surface| (*workspace_id, *pane_id, surface.kind.clone()))
3801 })
3802 })
3803}
3804
3805fn resolve_required_browser_target(target: BrowserTargetArgs) -> anyhow::Result<BrowserTarget> {
3806 resolve_browser_target(target.reference, target.selector, true)
3807 .map(|target| target.expect("required browser target"))
3808}
3809
3810fn resolve_optional_browser_target(
3811 target: BrowserOptionalTargetArgs,
3812) -> anyhow::Result<Option<BrowserTarget>> {
3813 resolve_browser_target(target.reference, target.selector, false)
3814}
3815
3816fn resolve_browser_target(
3817 reference: Option<String>,
3818 selector: Option<String>,
3819 required: bool,
3820) -> anyhow::Result<Option<BrowserTarget>> {
3821 match (reference, selector) {
3822 (Some(reference), None) => Ok(Some(BrowserTarget::Ref { value: reference })),
3823 (None, Some(selector)) => Ok(Some(BrowserTarget::Selector { value: selector })),
3824 (None, None) if !required => Ok(None),
3825 (None, None) => bail!("missing browser target; pass --ref or --selector"),
3826 (Some(_), Some(_)) => bail!("pass only one of --ref or --selector"),
3827 }
3828}
3829
3830fn resolve_wait_condition(
3831 selector: Option<String>,
3832 text: Option<String>,
3833 url_contains: Option<String>,
3834 load_state: Option<CliBrowserLoadState>,
3835 script: Option<String>,
3836 delay_ms: Option<u64>,
3837) -> anyhow::Result<BrowserWaitCondition> {
3838 let mut condition = None;
3839 let mut set = |next| -> anyhow::Result<()> {
3840 if condition.is_some() {
3841 bail!(
3842 "browser wait requires exactly one of --selector, --text, --url-contains, --load-state, --script, or --delay-ms"
3843 );
3844 }
3845 condition = Some(next);
3846 Ok(())
3847 };
3848
3849 if let Some(selector) = selector {
3850 set(BrowserWaitCondition::Selector { selector })?;
3851 }
3852 if let Some(text) = text {
3853 set(BrowserWaitCondition::Text { text })?;
3854 }
3855 if let Some(pattern) = url_contains {
3856 set(BrowserWaitCondition::UrlMatches { pattern })?;
3857 }
3858 if let Some(state) = load_state {
3859 set(BrowserWaitCondition::LoadState {
3860 state: state.into(),
3861 })?;
3862 }
3863 if let Some(script) = script {
3864 set(BrowserWaitCondition::Function { script })?;
3865 }
3866 if let Some(duration_ms) = delay_ms {
3867 set(BrowserWaitCondition::Delay { duration_ms })?;
3868 }
3869
3870 condition.context(
3871 "browser wait requires one of --selector, --text, --url-contains, --load-state, --script, or --delay-ms",
3872 )
3873}
3874
3875#[allow(clippy::too_many_arguments)]
3876async fn emit_agent_hook(
3877 socket: Option<PathBuf>,
3878 workspace: Option<WorkspaceId>,
3879 pane: Option<PaneId>,
3880 surface: Option<SurfaceId>,
3881 agent: Option<String>,
3882 title: Option<String>,
3883 message: Option<String>,
3884 kind: CliSignalKind,
3885) -> anyhow::Result<()> {
3886 let workspace_id = workspace
3887 .or_else(env_workspace_id)
3888 .context("missing workspace id; pass --workspace or run from inside Taskers")?;
3889 let pane_id = pane
3890 .or_else(env_pane_id)
3891 .context("missing pane id; pass --pane or run from inside Taskers")?;
3892 let surface_id = surface.or_else(env_surface_id);
3893 let client = ControlClient::new(resolve_socket_path(socket));
3894
3895 let normalized_agent = agent
3896 .or_else(|| title.as_deref().and_then(infer_agent_kind))
3897 .unwrap_or_else(|| "shell".into());
3898 let normalized_title = title.unwrap_or_else(|| normalized_agent.clone());
3899 let metadata = Some(taskers_domain::SignalPaneMetadata {
3900 title: None,
3901 agent_title: Some(normalized_title.clone()),
3902 cwd: None,
3903 repo_name: None,
3904 git_branch: None,
3905 ports: Vec::new(),
3906 agent_kind: Some(normalized_agent.clone()),
3907 agent_active: Some(matches!(
3908 kind,
3909 CliSignalKind::Started
3910 | CliSignalKind::Progress
3911 | CliSignalKind::WaitingInput
3912 | CliSignalKind::Notification
3913 )),
3914 agent_command: None,
3915 });
3916 let normalized_message = message
3917 .as_deref()
3918 .map(str::trim)
3919 .filter(|value| !value.is_empty())
3920 .map(str::to_owned);
3921 let status_text = normalized_message
3922 .clone()
3923 .unwrap_or_else(|| normalized_title.clone());
3924 let signal_response = send_control_command(
3925 &client,
3926 ControlCommand::EmitSignal {
3927 workspace_id,
3928 pane_id,
3929 surface_id,
3930 event: SignalEvent {
3931 source: format!("agent-hook:{normalized_agent}"),
3932 kind: kind.into(),
3933 message,
3934 metadata,
3935 timestamp: OffsetDateTime::now_utc(),
3936 },
3937 },
3938 )
3939 .await?;
3940
3941 let (resolved_workspace_id, resolved_pane_id, resolved_surface_id) = match surface_id {
3942 Some(surface_id) => {
3943 let (workspace_id, pane_id, surface_id) =
3944 resolve_surface_context(&client, surface_id).await?;
3945 (workspace_id, pane_id, Some(surface_id))
3946 }
3947 None => (workspace_id, pane_id, surface_id),
3948 };
3949
3950 if let Some(log_message) = normalized_message.clone() {
3951 let _ = send_control_command(
3952 &client,
3953 ControlCommand::AgentAppendLog {
3954 workspace_id: resolved_workspace_id,
3955 entry: WorkspaceLogEntry {
3956 source: Some(normalized_agent.clone()),
3957 message: log_message,
3958 created_at: OffsetDateTime::now_utc(),
3959 },
3960 },
3961 )
3962 .await?;
3963 }
3964
3965 match kind {
3966 CliSignalKind::Started | CliSignalKind::Progress => {
3967 let _ = send_control_command(
3968 &client,
3969 ControlCommand::AgentSetStatus {
3970 workspace_id: resolved_workspace_id,
3971 text: status_text,
3972 },
3973 )
3974 .await?;
3975 }
3976 CliSignalKind::WaitingInput | CliSignalKind::Notification => {
3977 let _ = send_control_command(
3978 &client,
3979 ControlCommand::AgentSetStatus {
3980 workspace_id: resolved_workspace_id,
3981 text: status_text.clone(),
3982 },
3983 )
3984 .await?;
3985 }
3986 CliSignalKind::Completed | CliSignalKind::Error => {
3987 if matches!(kind, CliSignalKind::Completed) {
3988 let _ = send_control_command(
3989 &client,
3990 ControlCommand::AgentClearStatus {
3991 workspace_id: resolved_workspace_id,
3992 },
3993 )
3994 .await?;
3995 let _ = send_control_command(
3996 &client,
3997 ControlCommand::AgentClearProgress {
3998 workspace_id: resolved_workspace_id,
3999 },
4000 )
4001 .await?;
4002 } else {
4003 let _ = send_control_command(
4004 &client,
4005 ControlCommand::AgentSetStatus {
4006 workspace_id: resolved_workspace_id,
4007 text: status_text,
4008 },
4009 )
4010 .await?;
4011 let _ = send_control_command(
4012 &client,
4013 ControlCommand::AgentClearProgress {
4014 workspace_id: resolved_workspace_id,
4015 },
4016 )
4017 .await?;
4018 }
4019 }
4020 CliSignalKind::Metadata => {}
4021 }
4022
4023 if matches!(
4024 kind,
4025 CliSignalKind::WaitingInput | CliSignalKind::Notification | CliSignalKind::Error
4026 ) {
4027 let flash_surface_id = match resolved_surface_id.or_else(env_surface_id) {
4028 Some(surface_id) => Some(surface_id),
4029 None => {
4030 let model = query_model(&client).await?;
4031 Some(active_surface_for_pane(
4032 &model,
4033 resolved_workspace_id,
4034 resolved_pane_id,
4035 )?)
4036 }
4037 };
4038 if let Some(surface_id) = flash_surface_id {
4039 let _ = send_control_command(
4040 &client,
4041 ControlCommand::AgentTriggerFlash {
4042 workspace_id: resolved_workspace_id,
4043 pane_id: resolved_pane_id,
4044 surface_id,
4045 },
4046 )
4047 .await?;
4048 }
4049 }
4050
4051 println!("{}", serde_json::to_string_pretty(&signal_response)?);
4052 Ok(())
4053}
4054
4055fn infer_agent_kind(value: &str) -> Option<String> {
4056 let normalized = value.trim().to_ascii_lowercase();
4057 match normalized.as_str() {
4058 "codex" => Some("codex".into()),
4059 "claude" | "claude code" | "claude-code" => Some("claude".into()),
4060 "opencode" => Some("opencode".into()),
4061 "aider" => Some("aider".into()),
4062 _ => None,
4063 }
4064}
4065
4066#[cfg(test)]
4067mod tests {
4068 use std::{
4069 path::PathBuf,
4070 sync::Mutex,
4071 time::{SystemTime, UNIX_EPOCH},
4072 };
4073
4074 use clap::Parser;
4075 use taskers_control::{
4076 BrowserTarget, BrowserWaitCondition, ControlClient, ControlCommand, InMemoryController,
4077 ScreenshotCommand, ScreenshotTarget, bind_socket, serve,
4078 };
4079 use taskers_domain::{AppModel, BrowserProfileMode, PaneKind, SplitAxis, WorkspaceWindowId};
4080 use tokio::sync::oneshot;
4081
4082 use super::{
4083 Cli, CliBrowserLoadState, CliScreenshotTarget, CliSignalKind, CompletionQueryArgs,
4084 CompletionShell, ScreenshotArgs, completion_query_candidates, emit_agent_hook,
4085 ensure_implicit_notify_target_context, env_pane_id, env_surface_id, env_workspace_id,
4086 infer_agent_kind, query_model, render_completion, resolve_browser_target,
4087 resolve_screenshot_command, resolve_wait_condition,
4088 resolve_workspace_window_screenshot_target, send_screenshot_command,
4089 };
4090
4091 static ENV_LOCK: Mutex<()> = Mutex::new(());
4092
4093 fn unique_temp_dir(prefix: &str) -> PathBuf {
4094 let unique = SystemTime::now()
4095 .duration_since(UNIX_EPOCH)
4096 .expect("time")
4097 .as_nanos();
4098 std::env::temp_dir().join(format!("{prefix}-{unique}"))
4099 }
4100
4101 #[test]
4102 fn infers_known_agent_names() {
4103 assert_eq!(infer_agent_kind("Codex"), Some("codex".into()));
4104 assert_eq!(infer_agent_kind("Claude Code"), Some("claude".into()));
4105 assert_eq!(infer_agent_kind("opencode"), Some("opencode".into()));
4106 assert_eq!(infer_agent_kind("unknown"), None);
4107 }
4108
4109 #[test]
4110 fn parses_completion_subcommand() {
4111 let cli = Cli::try_parse_from(["taskersctl", "completion", "fish"])
4112 .expect("completion subcommand should parse");
4113 let debug = format!("{cli:?}");
4114 assert!(debug.contains("Completion"));
4115 assert!(debug.contains("Fish"));
4116 }
4117
4118 #[test]
4119 fn generated_completion_scripts_include_public_commands_only() {
4120 for shell in [
4121 CompletionShell::Bash,
4122 CompletionShell::Fish,
4123 CompletionShell::Zsh,
4124 ] {
4125 let script = render_completion(shell);
4126 assert!(
4127 script.contains("browser"),
4128 "expected browser command in {shell:?} completion"
4129 );
4130 assert!(
4131 script.contains("workspace"),
4132 "expected workspace command in {shell:?} completion"
4133 );
4134 assert!(
4135 script.contains("--socket"),
4136 "expected socket flag in {shell:?} completion"
4137 );
4138 assert!(
4139 !script.contains(" session "),
4140 "hidden session command leaked into {shell:?} completion"
4141 );
4142 }
4143 }
4144
4145 #[tokio::test]
4146 async fn completion_query_returns_static_flag_values() {
4147 let values = completion_query_candidates(&CompletionQueryArgs {
4148 path: Some("screenshot".into()),
4149 flag: Some("--target".into()),
4150 ..CompletionQueryArgs::default()
4151 })
4152 .await;
4153
4154 assert_eq!(
4155 values,
4156 vec![
4157 "surface".to_string(),
4158 "pane".to_string(),
4159 "workspace_window".to_string(),
4160 "workspace_canvas".to_string()
4161 ]
4162 );
4163 }
4164
4165 #[tokio::test]
4166 async fn completion_query_returns_static_positional_values() {
4167 let values = completion_query_candidates(&CompletionQueryArgs {
4168 path: Some("completion".into()),
4169 positional: Some(0),
4170 ..CompletionQueryArgs::default()
4171 })
4172 .await;
4173
4174 assert_eq!(
4175 values,
4176 vec!["bash".to_string(), "fish".to_string(), "zsh".to_string()]
4177 );
4178 }
4179
4180 #[tokio::test]
4181 async fn completion_query_returns_dynamic_taskers_ids() {
4182 let tempdir = unique_temp_dir("taskers-cli-completion-query");
4183 std::fs::create_dir_all(&tempdir).expect("tempdir");
4184 let socket_path = tempdir.join("taskers.sock");
4185 let listener = bind_socket(&socket_path).expect("listener");
4186 let controller = InMemoryController::new(AppModel::new("Main"));
4187 let snapshot = controller.snapshot();
4188 let workspace = snapshot.model.active_workspace().expect("workspace");
4189 let workspace_id = workspace.id;
4190 let active_pane_id = workspace.active_pane;
4191 let initial_surface_id = workspace
4192 .panes
4193 .get(&active_pane_id)
4194 .expect("pane")
4195 .active_surface;
4196
4197 controller
4198 .handle(ControlCommand::SplitPane {
4199 workspace_id,
4200 pane_id: Some(active_pane_id),
4201 axis: SplitAxis::Horizontal,
4202 })
4203 .expect("split pane");
4204 let second_pane_id = controller
4205 .snapshot()
4206 .model
4207 .workspaces
4208 .get(&workspace_id)
4209 .and_then(|workspace| {
4210 workspace
4211 .panes
4212 .keys()
4213 .copied()
4214 .find(|pane_id| *pane_id != active_pane_id)
4215 })
4216 .expect("second pane");
4217
4218 controller
4219 .handle(ControlCommand::CreateSurface {
4220 workspace_id,
4221 pane_id: active_pane_id,
4222 kind: PaneKind::Browser,
4223 browser_profile_mode: Some(BrowserProfileMode::PersistentDefault),
4224 })
4225 .expect("create surface");
4226 let browser_surface_id = controller
4227 .snapshot()
4228 .model
4229 .workspaces
4230 .get(&workspace_id)
4231 .and_then(|workspace| workspace.panes.get(&active_pane_id))
4232 .and_then(|pane| {
4233 pane.surfaces
4234 .keys()
4235 .copied()
4236 .find(|surface_id| *surface_id != initial_surface_id)
4237 })
4238 .expect("browser surface");
4239
4240 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
4241 let server = tokio::spawn(async move {
4242 serve(listener, controller, async move {
4243 let _ = shutdown_rx.await;
4244 })
4245 .await
4246 });
4247
4248 let workspace_values = completion_query_candidates(&CompletionQueryArgs {
4249 path: Some("browser click".into()),
4250 flag: Some("--workspace".into()),
4251 socket: Some(socket_path.clone()),
4252 ..CompletionQueryArgs::default()
4253 })
4254 .await;
4255 assert!(workspace_values.contains(&workspace_id.to_string()));
4256
4257 let pane_values = completion_query_candidates(&CompletionQueryArgs {
4258 path: Some("browser click".into()),
4259 flag: Some("--pane".into()),
4260 socket: Some(socket_path.clone()),
4261 workspace: Some(workspace_id),
4262 ..CompletionQueryArgs::default()
4263 })
4264 .await;
4265 assert!(pane_values.contains(&active_pane_id.to_string()));
4266 assert!(pane_values.contains(&second_pane_id.to_string()));
4267
4268 let surface_values = completion_query_candidates(&CompletionQueryArgs {
4269 path: Some("browser click".into()),
4270 flag: Some("--surface".into()),
4271 socket: Some(socket_path.clone()),
4272 workspace: Some(workspace_id),
4273 pane: Some(active_pane_id),
4274 ..CompletionQueryArgs::default()
4275 })
4276 .await;
4277 assert!(surface_values.contains(&initial_surface_id.to_string()));
4278 assert!(surface_values.contains(&browser_surface_id.to_string()));
4279
4280 shutdown_tx.send(()).expect("shutdown");
4281 server.await.expect("server task").expect("serve cleanly");
4282 std::fs::remove_dir_all(&tempdir).expect("cleanup tempdir");
4283 }
4284
4285 #[test]
4286 fn codex_notify_helper_requires_embedded_surface_context() {
4287 let asset = include_str!(concat!(
4288 env!("CARGO_MANIFEST_DIR"),
4289 "/assets/taskers-codex-notify.sh"
4290 ));
4291
4292 for expected in [
4293 "TASKERS_WORKSPACE_ID",
4294 "TASKERS_PANE_ID",
4295 "TASKERS_SURFACE_ID",
4296 "TASKERS_TTY_NAME",
4297 "tty 2>/dev/null",
4298 "agent-hook stop",
4299 "--workspace \"$TASKERS_WORKSPACE_ID\"",
4300 "--pane \"$TASKERS_PANE_ID\"",
4301 "--surface \"$TASKERS_SURFACE_ID\"",
4302 ] {
4303 assert!(
4304 asset.contains(expected),
4305 "expected helper asset to contain {expected:?}"
4306 );
4307 }
4308 }
4309
4310 #[test]
4311 fn reads_runtime_context_ids_from_env() {
4312 let _guard = ENV_LOCK.lock().expect("env lock");
4313 unsafe {
4314 std::env::set_var(
4315 "TASKERS_WORKSPACE_ID",
4316 "019cede5-2843-7da1-a281-dd6b5d1cfbe6",
4317 );
4318 std::env::set_var("TASKERS_PANE_ID", "019cede5-2843-7da1-a281-dd4f2de73c9c");
4319 std::env::set_var("TASKERS_SURFACE_ID", "019cede5-2843-7da1-a281-dd2119ae9b83");
4320 }
4321
4322 assert!(env_workspace_id().is_some());
4323 assert!(env_pane_id().is_some());
4324 assert!(env_surface_id().is_some());
4325
4326 unsafe {
4327 std::env::remove_var("TASKERS_WORKSPACE_ID");
4328 std::env::remove_var("TASKERS_PANE_ID");
4329 std::env::remove_var("TASKERS_SURFACE_ID");
4330 }
4331 }
4332
4333 #[test]
4334 fn implicit_notify_requires_embedded_taskers_context() {
4335 let _guard = ENV_LOCK.lock().expect("env lock");
4336 unsafe {
4337 std::env::remove_var("TASKERS_WORKSPACE_ID");
4338 std::env::remove_var("TASKERS_PANE_ID");
4339 std::env::remove_var("TASKERS_SURFACE_ID");
4340 }
4341
4342 assert!(ensure_implicit_notify_target_context(None, None, None).is_err());
4343 assert!(ensure_implicit_notify_target_context(env_workspace_id(), None, None).is_err());
4344 }
4345
4346 #[test]
4347 fn implicit_notify_accepts_embedded_context_or_explicit_target() {
4348 let _guard = ENV_LOCK.lock().expect("env lock");
4349 unsafe {
4350 std::env::set_var(
4351 "TASKERS_WORKSPACE_ID",
4352 "019cede5-2843-7da1-a281-dd6b5d1cfbe6",
4353 );
4354 std::env::set_var("TASKERS_PANE_ID", "019cede5-2843-7da1-a281-dd4f2de73c9c");
4355 std::env::set_var("TASKERS_SURFACE_ID", "019cede5-2843-7da1-a281-dd2119ae9b83");
4356 std::env::remove_var("TASKERS_TTY_NAME");
4357 }
4358
4359 assert!(ensure_implicit_notify_target_context(None, None, None).is_ok());
4360
4361 unsafe {
4362 std::env::remove_var("TASKERS_WORKSPACE_ID");
4363 std::env::remove_var("TASKERS_PANE_ID");
4364 std::env::remove_var("TASKERS_SURFACE_ID");
4365 }
4366
4367 let workspace = "019cede5-2843-7da1-a281-dd6b5d1cfbe6"
4368 .parse()
4369 .expect("workspace id");
4370 assert!(ensure_implicit_notify_target_context(Some(workspace), None, None).is_ok());
4371 }
4372
4373 #[test]
4374 fn runtime_context_ids_are_ignored_when_tty_mismatches() {
4375 let _guard = ENV_LOCK.lock().expect("env lock");
4376 unsafe {
4377 std::env::set_var(
4378 "TASKERS_WORKSPACE_ID",
4379 "019cede5-2843-7da1-a281-dd6b5d1cfbe6",
4380 );
4381 std::env::set_var("TASKERS_PANE_ID", "019cede5-2843-7da1-a281-dd4f2de73c9c");
4382 std::env::set_var("TASKERS_SURFACE_ID", "019cede5-2843-7da1-a281-dd2119ae9b83");
4383 std::env::set_var("TASKERS_TTY_NAME", "/dev/pts/taskers-mismatch");
4384 }
4385
4386 assert!(env_workspace_id().is_none());
4387 assert!(env_pane_id().is_none());
4388 assert!(env_surface_id().is_none());
4389
4390 unsafe {
4391 std::env::remove_var("TASKERS_WORKSPACE_ID");
4392 std::env::remove_var("TASKERS_PANE_ID");
4393 std::env::remove_var("TASKERS_SURFACE_ID");
4394 std::env::remove_var("TASKERS_TTY_NAME");
4395 }
4396 }
4397
4398 #[tokio::test]
4399 async fn agent_hook_status_and_logs_follow_surface_workspace_after_move() {
4400 let tempdir = unique_temp_dir("taskers-cli-agent-hook");
4401 std::fs::create_dir_all(&tempdir).expect("tempdir");
4402 let socket_path = tempdir.join("taskers.sock");
4403 let listener = bind_socket(&socket_path).expect("listener");
4404 let controller = InMemoryController::new(AppModel::new("Main"));
4405 let snapshot = controller.snapshot();
4406 let source_workspace = snapshot.model.active_workspace().expect("workspace");
4407 let source_workspace_id = source_workspace.id;
4408 let source_pane_id = source_workspace.active_pane;
4409
4410 controller
4411 .handle(ControlCommand::CreateSurface {
4412 workspace_id: source_workspace_id,
4413 pane_id: source_pane_id,
4414 kind: PaneKind::Browser,
4415 browser_profile_mode: Some(BrowserProfileMode::PersistentDefault),
4416 })
4417 .expect("create surface");
4418 let moved_surface_id = controller
4419 .snapshot()
4420 .model
4421 .workspaces
4422 .get(&source_workspace_id)
4423 .and_then(|workspace| workspace.panes.get(&source_pane_id))
4424 .map(|pane| pane.active_surface)
4425 .expect("moved surface");
4426
4427 controller
4428 .handle(ControlCommand::CreateWorkspace {
4429 label: "Docs".into(),
4430 })
4431 .expect("create target workspace");
4432 let target_workspace_id = controller
4433 .snapshot()
4434 .model
4435 .active_workspace_id()
4436 .expect("target workspace");
4437
4438 controller
4439 .handle(ControlCommand::MoveSurfaceToWorkspace {
4440 source_workspace_id,
4441 source_pane_id,
4442 surface_id: moved_surface_id,
4443 target_workspace_id,
4444 })
4445 .expect("move surface");
4446
4447 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
4448 let server = tokio::spawn(serve(listener, controller.clone(), async move {
4449 let _ = shutdown_rx.await;
4450 }));
4451
4452 emit_agent_hook(
4453 Some(socket_path.clone()),
4454 Some(source_workspace_id),
4455 Some(source_pane_id),
4456 Some(moved_surface_id),
4457 Some("codex".into()),
4458 Some("Codex".into()),
4459 Some("Turn complete".into()),
4460 CliSignalKind::Notification,
4461 )
4462 .await
4463 .expect("emit agent hook");
4464
4465 let snapshot = controller.snapshot();
4466 let source_workspace_after = snapshot
4467 .model
4468 .workspaces
4469 .get(&source_workspace_id)
4470 .expect("source workspace");
4471 let target_workspace_after = snapshot
4472 .model
4473 .workspaces
4474 .get(&target_workspace_id)
4475 .expect("target workspace");
4476
4477 assert_eq!(source_workspace_after.status_text, None);
4478 assert!(
4479 source_workspace_after.log_entries.is_empty(),
4480 "expected source workspace log to stay empty"
4481 );
4482 assert_eq!(
4483 target_workspace_after.status_text.as_deref(),
4484 Some("Turn complete")
4485 );
4486 assert_eq!(target_workspace_after.log_entries.len(), 1);
4487 assert_eq!(
4488 target_workspace_after.log_entries[0].message,
4489 "Turn complete"
4490 );
4491
4492 let target_surface = target_workspace_after
4493 .panes
4494 .values()
4495 .flat_map(|pane| pane.surfaces.values())
4496 .find(|surface| surface.id == moved_surface_id)
4497 .expect("target surface");
4498 assert_eq!(
4499 target_surface.metadata.latest_agent_message.as_deref(),
4500 Some("Turn complete")
4501 );
4502 assert!(
4503 target_workspace_after
4504 .surface_flash_tokens
4505 .contains_key(&moved_surface_id),
4506 "expected flash token on moved target surface"
4507 );
4508
4509 shutdown_tx.send(()).expect("shutdown");
4510 server.await.expect("server task").expect("serve cleanly");
4511 std::fs::remove_dir_all(&tempdir).expect("cleanup tempdir");
4512 }
4513
4514 #[tokio::test]
4515 async fn screenshot_workspace_window_resolves_to_selected_workspace() {
4516 let tempdir = unique_temp_dir("taskers-cli-screenshot-window");
4517 std::fs::create_dir_all(&tempdir).expect("tempdir");
4518 let socket_path = tempdir.join("taskers.sock");
4519 let listener = bind_socket(&socket_path).expect("listener");
4520 let controller = InMemoryController::new(AppModel::new("Main"));
4521 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
4522 let server = tokio::spawn(async move {
4523 serve(listener, controller, async move {
4524 let _ = shutdown_rx.await;
4525 })
4526 .await
4527 });
4528
4529 let client = ControlClient::new(socket_path.clone());
4530 let workspace_id = query_model(&client)
4531 .await
4532 .expect("model")
4533 .active_workspace_id()
4534 .expect("workspace");
4535
4536 let command = resolve_screenshot_command(
4537 &client,
4538 &ScreenshotArgs {
4539 socket: Some(socket_path),
4540 target: CliScreenshotTarget::WorkspaceWindow,
4541 workspace: Some(workspace_id),
4542 pane: None,
4543 surface: None,
4544 out: Some(tempdir.join("window.png").display().to_string()),
4545 },
4546 )
4547 .await
4548 .expect("resolve screenshot");
4549
4550 match command {
4551 ScreenshotCommand::Capture {
4552 target:
4553 ScreenshotTarget::WorkspaceWindow {
4554 workspace_id: resolved_workspace_id,
4555 },
4556 ..
4557 } => assert_eq!(resolved_workspace_id, workspace_id),
4558 other => panic!("unexpected screenshot command: {other:?}"),
4559 }
4560
4561 shutdown_tx.send(()).expect("shutdown");
4562 server.await.expect("server task").expect("serve cleanly");
4563 std::fs::remove_dir_all(&tempdir).expect("cleanup tempdir");
4564 }
4565
4566 #[tokio::test]
4567 async fn screenshot_surface_rejects_non_terminal_surface() {
4568 let tempdir = unique_temp_dir("taskers-cli-screenshot-surface");
4569 std::fs::create_dir_all(&tempdir).expect("tempdir");
4570 let socket_path = tempdir.join("taskers.sock");
4571 let listener = bind_socket(&socket_path).expect("listener");
4572 let controller = InMemoryController::new(AppModel::new("Main"));
4573 let snapshot = controller.snapshot();
4574 let workspace = snapshot.model.active_workspace().expect("workspace");
4575
4576 controller
4577 .handle(ControlCommand::CreateSurface {
4578 workspace_id: workspace.id,
4579 pane_id: workspace.active_pane,
4580 kind: PaneKind::Browser,
4581 browser_profile_mode: Some(BrowserProfileMode::PersistentDefault),
4582 })
4583 .expect("create browser surface");
4584 let browser_surface_id = controller
4585 .snapshot()
4586 .model
4587 .workspaces
4588 .get(&workspace.id)
4589 .and_then(|workspace| workspace.panes.get(&workspace.active_pane))
4590 .map(|pane| pane.active_surface)
4591 .expect("browser surface");
4592
4593 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
4594 let server = tokio::spawn(async move {
4595 serve(listener, controller, async move {
4596 let _ = shutdown_rx.await;
4597 })
4598 .await
4599 });
4600
4601 let client = ControlClient::new(socket_path.clone());
4602 let error = resolve_screenshot_command(
4603 &client,
4604 &ScreenshotArgs {
4605 socket: Some(socket_path),
4606 target: CliScreenshotTarget::Surface,
4607 workspace: Some(workspace.id),
4608 pane: Some(workspace.active_pane),
4609 surface: Some(browser_surface_id),
4610 out: None,
4611 },
4612 )
4613 .await
4614 .expect_err("browser surface should not resolve as a terminal screenshot target");
4615
4616 assert!(
4617 error.to_string().contains("not a terminal"),
4618 "unexpected error: {error}"
4619 );
4620
4621 shutdown_tx.send(()).expect("shutdown");
4622 server.await.expect("server task").expect("serve cleanly");
4623 std::fs::remove_dir_all(&tempdir).expect("cleanup tempdir");
4624 }
4625
4626 #[test]
4627 fn screenshot_workspace_window_errors_when_workspace_has_no_active_window() {
4628 let mut model = AppModel::new("Main");
4629 let workspace_id = model.active_workspace_id().expect("workspace");
4630 let workspace = model.workspaces.get_mut(&workspace_id).expect("workspace");
4631 workspace.active_window = WorkspaceWindowId::new();
4632 let error = resolve_workspace_window_screenshot_target(&model, Some(workspace_id))
4633 .expect_err("workspace without active window should fail");
4634
4635 assert!(
4636 error.to_string().contains("no active workspace window"),
4637 "unexpected error: {error}"
4638 );
4639 }
4640
4641 #[tokio::test]
4642 async fn screenshot_bridge_unavailable_does_not_create_output() {
4643 let tempdir = unique_temp_dir("taskers-cli-screenshot-unavailable");
4644 std::fs::create_dir_all(&tempdir).expect("tempdir");
4645 let socket_path = tempdir.join("missing.sock");
4646 let output_path = tempdir.join("missing.png");
4647 let client = ControlClient::new(socket_path);
4648
4649 let error = send_screenshot_command(
4650 &client,
4651 ScreenshotCommand::Capture {
4652 target: ScreenshotTarget::WorkspaceCanvas {
4653 workspace_id: taskers_domain::WorkspaceId::new(),
4654 },
4655 path: Some(output_path.display().to_string()),
4656 },
4657 )
4658 .await
4659 .expect_err("missing host bridge should fail");
4660
4661 assert!(
4662 !output_path.exists(),
4663 "unexpected screenshot artifact at {}",
4664 output_path.display()
4665 );
4666 assert!(
4667 error.to_string().contains("No such file")
4668 || error.to_string().contains("os error")
4669 || error.to_string().contains("connect"),
4670 "unexpected error: {error}"
4671 );
4672
4673 std::fs::remove_dir_all(&tempdir).expect("cleanup tempdir");
4674 }
4675
4676 #[test]
4677 fn browser_targets_require_exactly_one_selector_or_ref() {
4678 let target = resolve_browser_target(Some("@e1".into()), None, true).expect("target");
4679 assert_eq!(
4680 target,
4681 Some(BrowserTarget::Ref {
4682 value: "@e1".into()
4683 })
4684 );
4685 assert!(resolve_browser_target(None, None, true).is_err());
4686 assert!(resolve_browser_target(Some("@e1".into()), Some("a".into()), true).is_err());
4687 assert_eq!(
4688 resolve_browser_target(None, None, false).expect("optional target"),
4689 None
4690 );
4691 }
4692
4693 #[test]
4694 fn browser_wait_conditions_require_one_clause() {
4695 let wait = resolve_wait_condition(None, Some("hello".into()), None, None, None, None)
4696 .expect("wait");
4697 assert_eq!(
4698 wait,
4699 BrowserWaitCondition::Text {
4700 text: "hello".into()
4701 }
4702 );
4703
4704 let wait = resolve_wait_condition(
4705 None,
4706 None,
4707 None,
4708 Some(CliBrowserLoadState::Committed),
4709 None,
4710 None,
4711 )
4712 .expect("load state");
4713 assert_eq!(
4714 wait,
4715 BrowserWaitCondition::LoadState {
4716 state: taskers_control::BrowserLoadState::Committed
4717 }
4718 );
4719
4720 assert!(
4721 resolve_wait_condition(
4722 Some("body".into()),
4723 Some("hello".into()),
4724 None,
4725 None,
4726 None,
4727 None,
4728 )
4729 .is_err()
4730 );
4731 assert!(resolve_wait_condition(None, None, None, None, None, None).is_err());
4732 }
4733}