Skip to main content

taskers_cli/
lib.rs

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}