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