cydonia 0.1.9

Desktop workspace for the ACP agents you run, keeping what they produce as files on your disk
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
//! Root view: the window's grid, the state the chrome owns, and the frame
//! the sidebar and the chat column are hung in.

use crate::{
    model::{
        session::ChatSession,
        settings::Settings,
        state::State,
        update,
        workspace::{Reloaded, Showing, Workspace},
    },
    view::{
        board,
        component::{
            composer::{Composer, ComposerEvent},
            menu::Menu,
            meter,
            ribbon::Ribbon,
        },
        confirm, create, info,
        leaf::{Leaf, Pane},
        settings::{self, Section, SettingsWindow},
        sidebar::{Renaming, Row},
        table,
    },
};
use anyhow::Result;
use artifact::layout::Member;
use bezel::{
    gpui::{
        self, AnyElement, App, Axis, Bounds, Context, DragMoveEvent, Empty, Entity, FocusHandle,
        Hsla, KeyBinding, PathPromptOptions, Render, TitlebarOptions, UniformListScrollHandle,
        Window, WindowBounds, WindowHandle, WindowOptions, actions, div, point, prelude::*, px,
        size,
    },
    motion::{Fade, Painter},
    theme::{Material, TextStyle, Theme, Typeset, appearance},
    ui::{
        floating::Floating,
        icons,
        input::TextField,
        menu::Cursor,
        stats::Stats,
        widgets::{ButtonStyle, Buttons, Content, SplitDrag},
    },
};

actions!(
    cydonia,
    [
        NewSession,
        NewSessionNext,
        NewBoard,
        NewArticle,
        NewTable,
        OpenProject,
        CloseProject,
        OpenSettings,
        ToggleSidebar,
        ToggleTerminal,
        ToggleChanges,
        OpenFiles,
        OpenReview,
        CommitName,
        DismissName,
        NextEntry,
        PrevEntry,
        NextPane,
        PrevPane,
        ClosePane,
        ZoomPane,
        CopySelection
    ]
);

/// File › New Session With: a session on the agent it names. By name, because
/// that is what the menu was built from — an index would open the wrong agent
/// the moment one was installed ahead of it and the bar not yet rebuilt.
///
/// No JSON: the menu is the only thing that dispatches it, and nobody writes
/// an agent's name into a keymap.
#[derive(Clone, Debug, PartialEq, gpui::Action)]
#[action(namespace = cydonia, no_json)]
pub struct NewSessionWith {
    pub agent: String,
}

/// Claimed on the rename field so `enter` files the name and `escape` drops it.
const RENAME_CONTEXT: &str = "CydoniaSessionName";

const SIDEBAR_WIDTH: f32 = 200.;
const SIDEBAR_WIDTH_MIN: f32 = 180.;
const SIDEBAR_WIDTH_MAX: f32 = 420.;

/// The sidebar's gutter: a row's outer margin, and the padding inside it.
pub(crate) const SIDEBAR_GUTTER: f32 = 8.;

/// How thick each column's material sits. Nothing paints beneath them, so these
/// are absolute and independent: the sidebar is chrome and holds no long-form
/// text, the panel is the column whose text has to win against the desktop.
const SIDEBAR_MATERIAL: Material = Material::Thick;
const CONTENT_MATERIAL: Material = Material::UltraThick;

/// The header strip's height, measured off `../desktop`: between Cursor's 34
/// and Notion's 36, and tall enough to hold the 14px traffic lights macOS 26
/// draws without crowding them.
pub(crate) const HEADER_HEIGHT: f32 = 36.;

/// The pill at rest, and the agent mark beside it. Half of it is the stadium's
/// radius.
pub(crate) fn composer_height() -> f32 {
    composer_disc() + 2. * COMPOSER_INSET
}

/// The room the pill keeps above and below its content.
pub(crate) const COMPOSER_INSET: f32 = 4.;

/// Keep the send target comfortable at small text sizes, and grow with the
/// line box when the text-size setting needs more room.
pub(crate) fn composer_disc() -> f32 {
    TextStyle::Body.painted_line_height().max(24.)
}

/// How far the floating composer stands off the column's bottom edge.
pub(crate) const COMPOSER_BOTTOM: f32 = 20.;

/// How wide the composer's column runs before it stops growing, and the air it
/// keeps either side of itself inside that.
pub(crate) const COMPOSER_COLUMN: f32 = 720.;
pub(crate) const COMPOSER_MARGIN: f32 = 24.;

/// What the composer itself is at its widest — the column, less its own air.
///
/// The `/` picker is capped at this. Its rows carry a sentence each and the
/// card sizes to the longest of them, so on a wide window it opened as far as
/// the window allowed: a menu reaching past the box it came out of stops
/// reading as that box's menu.
pub(crate) fn composer_width() -> f32 {
    COMPOSER_COLUMN - 2. * COMPOSER_MARGIN
}

/// The sidebar's fill. Opaque, it takes the chrome tone: the light palette's
/// `surface` is the grey the content plane's white sits inside, and falling
/// back to the panel would leave the two columns one flat sheet.
pub(crate) fn sidebar_bg(theme: &Theme) -> Hsla {
    material(theme, SIDEBAR_MATERIAL).unwrap_or(theme.surface)
}

/// The content column's fill.
pub(crate) fn content_bg(theme: &Theme) -> Hsla {
    material(theme, CONTENT_MATERIAL).unwrap_or(theme.bg)
}

/// A column's own tint at one thickness on the material ladder, or nothing
/// where the window shows no desktop to sit over. The ladder's tone is a
/// neutral scrim and carries no appearance — tinting it is what makes dark
/// glass dark.
fn material(theme: &Theme, thickness: Material) -> Option<Hsla> {
    theme.vibrancy.then(|| Hsla {
        a: thickness.opacity(),
        ..theme.vibrancy_tint()
    })
}

/// macOS traffic light diameter — AppKit owns the buttons and reports their
/// frame, so nothing here can derive it. Measured on macOS 26.
const TRAFFIC_LIGHT_SIZE: f32 = 14.;

/// Where the traffic lights go, for `TitlebarOptions::traffic_light_position`:
/// AppKit's own inset across, which is where every other window on the desktop
/// shows them, and down by half the band the header reserves for them. macOS
/// sizes the button container to `height + 2y`.
pub const TRAFFIC_LIGHT_X: f32 = 12.;
pub const TRAFFIC_LIGHT_Y: f32 = (HEADER_HEIGHT - TRAFFIC_LIGHT_SIZE) / 2.;

/// Between the lights' centres, as AppKit lays them out. Measured on macOS 26.
const TRAFFIC_LIGHT_SPACING: f32 = 23.;

/// The gap the header keeps at the window's edges, and between the lights and
/// the first control it puts past them.
pub(crate) const HEADER_INSET: f32 = 16.;

/// Where the toolbar's own controls start: clear of the three lights AppKit
/// puts down from [`TRAFFIC_LIGHT_X`], plus the gutter that clears them and the
/// strip's own inset, so the first control stands off the lights by the same
/// measure it keeps from every other edge.
pub(crate) const TOOLBAR_INSET: f32 = if cfg!(target_os = "macos") {
    TRAFFIC_LIGHT_X + 2. * TRAFFIC_LIGHT_SPACING + TRAFFIC_LIGHT_SIZE + 6. + HEADER_INSET
} else {
    HEADER_INSET
};

/// The chords the window keeps whatever the reader says — the commands it also
/// answers to are bound from [`crate::view::keymap`], which is where they can
/// be moved.
pub fn bindings() -> Vec<KeyBinding> {
    vec![
        // What a browser binds its tabs to, and the only chord these answer to:
        // the menu bar cannot draw it — gpui has no macOS equivalent for `tab`,
        // so AppKit is handed the word where the API takes one character and
        // shows ⌃T — and a chord it *can* draw is claimed by AppKit before the
        // window is ever offered it.
        //
        // Scoped to the root rather than left contextless, so that a surface
        // with a row of its own can take the chord for its own row: a binding
        // with no predicate ranks at the depth of the whole stack, which puts
        // it *above* every scoped one rather than below — see
        // `Keymap::binding_enabled`. The `cmd-c` fallback below is the same
        // trick for the same reason.
        KeyBinding::new("ctrl-tab", NextEntry, Some("Cydonia")),
        KeyBinding::new("ctrl-shift-tab", PrevEntry, Some("Cydonia")),
        // The panes of a layout, on the chords beside the ones that step
        // through entries.
        KeyBinding::new("ctrl-alt-tab", NextPane, Some("Cydonia")),
        KeyBinding::new("ctrl-alt-shift-tab", PrevPane, Some("Cydonia")),
        KeyBinding::new("ctrl-alt-w", ClosePane, Some("Cydonia")),
        KeyBinding::new("ctrl-alt-z", ZoomPane, Some("Cydonia")),
        // Scope the fallback to the root so focused text surfaces take priority.
        KeyBinding::new("cmd-c", CopySelection, Some("Cydonia")),
        KeyBinding::new("enter", CommitName, Some(RENAME_CONTEXT)),
        KeyBinding::new("escape", DismissName, Some(RENAME_CONTEXT)),
    ]
}

/// Open the workspace window. Called at launch, and again when the Dock
/// reopens an app whose window ⌘W closed.
pub fn open(settings: Settings, state: State, cx: &mut App) -> Result<WindowHandle<Cydonia>> {
    let bounds = Bounds::centered(None, size(px(1100.), px(760.)), cx);
    cx.open_window(
        WindowOptions {
            window_bounds: Some(WindowBounds::Windowed(bounds)),
            // No strip of its own: the traffic lights sit in the nav, so the
            // window owes no titlebar above it.
            titlebar: Some(TitlebarOptions {
                appears_transparent: true,
                traffic_light_position: Some(point(px(TRAFFIC_LIGHT_X), px(TRAFFIC_LIGHT_Y))),
                ..Default::default()
            }),
            // Glass needs a blurred window background to blur into.
            window_background: Theme::of(cx).window_background_appearance(),
            window_min_size: Some(size(px(600.), px(320.))),
            app_id: Some("cydonia".into()),
            ..Default::default()
        },
        |window, cx| {
            appearance::observe_window(window, cx).detach();
            cx.new(|cx| {
                let mut root = Cydonia::new(settings, state, window, cx);
                root.restore_panel_layout();
                cx.on_release(|root: &mut Cydonia, cx| root.save_panel_layout(cx))
                    .detach();
                root
            })
        },
    )
}

/// One step from `at` through `len` entries, wrapping — a list of none has
/// nowhere to land.
fn stepped(at: Option<usize>, len: usize, step: isize) -> Option<usize> {
    if len == 0 {
        return None;
    }
    let at = at.unwrap_or(0) as isize;
    Some((at + step).rem_euclid(len as isize) as usize)
}

/// The root view. It owns no app state — only the window's own chrome: how
/// wide the sidebar is, which menu is open, and what a dialog is asking about.
///
/// What is being shown, and everything the showing of it needs, is the
/// [`Leaf`]'s. One to a window today.
pub struct Cydonia {
    pub(crate) workspace: Entity<Workspace>,
    /// The panes on screen, and which of them has the focus.
    ///
    /// One unless a layout is open — see [`Leaf`]. The order is the order the
    /// arrangement lays them out, so stepping through them is stepping across
    /// the window.
    pub(crate) leaves: Vec<Leaf>,
    pub(crate) focused: usize,
    pub(crate) sidebar_open: bool,
    pub(crate) sidebar_width: f32,
    /// Visibility and shell per session; hiding a panel keeps its process alive.
    pub(crate) terminals:
        std::collections::HashMap<u64, (bool, Entity<super::component::terminal::TerminalPanel>)>,
    pub(crate) changes_open: bool,
    /// How wide the right-hand panel was dragged, and `None` for one nobody
    /// has dragged — which is given a share of the window instead. See
    /// [`super::detail::panel_width`].
    pub(crate) changes_width: Option<f32>,
    pub(crate) terminal_height: f32,
    pub(crate) changes: Option<Entity<super::component::panel::Panel>>,
    pub(crate) right_panels: std::collections::HashMap<u64, Entity<super::component::panel::Panel>>,
    settings_window: Option<WindowHandle<SettingsWindow>>,
    /// The delete waiting to be agreed to, and the name to ask about. Held
    /// with its label rather than looked up when the dialog draws: what is
    /// being asked about must not change wording under the question.
    pub(crate) confirming: Option<confirm::Confirming>,
    /// The layouts whose members are folded away, by layout id.
    ///
    /// Collapsed rather than expanded, so a layout is open until someone folds
    /// it: an entry is listed under the layout holding it and nowhere else, and
    /// a fold remembered across launches would start the window with entries
    /// hidden behind a row nobody chose to close.
    ///
    /// Runtime only, for the same reason.
    pub(crate) collapsed_layouts: std::collections::HashSet<String>,
    /// Where a pane dropped on a pane's edge would land: the pane under the
    /// pointer, and which of its edges. Written by whichever pane the pointer
    /// is inside and read by the one that draws the mark, the way a card's
    /// landing is — see [`board::Landing`].
    pub(crate) pane_landing: Option<(Member, artifact::layout::Side)>,
    /// The board identity panel, while it is open — see [`header::BoardInfo`].
    pub(crate) info: Option<info::BoardInfo>,
    /// The board that has been asked for and not yet made — see
    /// [`create::Making`].
    pub(crate) making: Option<create::Making>,
    /// Whether the press now being handled landed on the name of the board
    /// whose panel is open — read by [`Cydonia::toggle_info`] and nothing else,
    /// the way [`Cydonia::menu_pressed`] is read by `toggle_menu`.
    pub(crate) info_pressed: bool,
    pub(crate) menu: Option<Menu>,
    pub(crate) sidebar_hovered: Option<Menu>,
    /// Which of the open menu's rows is live. Held here rather than in the
    /// card, which is rebuilt every frame: the pointer moves the cursor, and
    /// a cursor made afresh each paint would light nothing.
    pub(crate) menu_cursor: Cursor,
    /// Whether the press now being handled landed on the open menu's own
    /// trigger — read by [`Cydonia::toggle_menu`] and nothing else.
    pub(crate) menu_pressed: bool,
    /// What the name field is attached to, and the field itself.
    pub(crate) renaming: Option<Renaming>,
    pub(crate) name_field: Entity<TextField>,
    meter: Entity<Stats>,
    meter_at: Floating,
    /// The rail's scroll. A step taken from the keyboard has to bring its
    /// landing into view; the list does not scroll itself.
    pub(crate) rail: UniformListScrollHandle,
    /// Where the focus rests when no field holds it — a board, a table and a
    /// transcript have none — so the bindings below always have a path here.
    focus: FocusHandle,
}

impl Cydonia {
    /// The pane with the focus — what a command without a pane of its own acts
    /// on, and what the sidebar lights.
    ///
    /// Never empty: a window always draws at least one pane, and closing the
    /// last one is closing the layout, not the pane.
    pub(crate) fn leaf(&self) -> &Leaf {
        let at = self.focused.min(self.leaves.len().saturating_sub(1));
        &self.leaves[at]
    }

    /// The entities one pane needs, with the composer wired to this window.
    ///
    /// `on` is the pane's entry, where it has one: an event from a composer in
    /// a pane that is not the focused one moves the focus there first, so what
    /// was typed is sent to the session it was typed under.
    fn pane_parts(
        on: Option<Member>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) -> (Entity<Composer>, Entity<TextField>, Entity<TextField>) {
        let composer = cx.new(Composer::new);
        cx.subscribe_in(
            &composer,
            window,
            move |this, _, event: &ComposerEvent, window, cx| {
                if let Some(on) = on.clone() {
                    this.focus_pane(&on, window, cx);
                }
                match event {
                    ComposerEvent::Submit(text, attachments) => {
                        this.submit(text.clone(), attachments.clone(), cx)
                    }
                    ComposerEvent::Draft(id, draft) => {
                        this.workspace.update(cx, |workspace, cx| {
                            workspace.set_draft(*id, draft.clone(), cx)
                        });
                    }
                    ComposerEvent::Cancel => this.cancel_turn(cx),
                    ComposerEvent::Reconnect => {
                        this.workspace.update(cx, |workspace, cx| {
                            if let Some(id) = workspace.active_id() {
                                workspace.select_session(id, cx);
                            }
                        });
                    }
                    ComposerEvent::Terminal => this.show_terminal(window, cx),
                    ComposerEvent::Changes => this.show_changes(window, cx),
                    ComposerEvent::Files => this.show_files(window, cx),
                    ComposerEvent::Switch(id, value) => this.switch(id, value, cx),
                }
            },
        )
        .detach();
        (composer, board::field(cx), table::field(cx))
    }

    /// Reconcile the panes on screen with the open layout.
    ///
    /// A pane already on an entry is kept, so switching layouts does not throw
    /// away a composer with a draft in it. Panes are ordered as the
    /// arrangement lays them out — stepping through them steps across the
    /// window.
    pub(crate) fn sync_leaves(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let members = self.arrangement(cx).map(|layout| layout.entries());
        let Some(members) = members else {
            // No layout: one pane, on whatever the project was left on.
            self.leaves.truncate(1);
            self.leaf_mut().entry = None;
            self.focused = 0;
            return;
        };
        let focused = self.leaf().entry.clone();
        let mut kept: Vec<Leaf> = Vec::with_capacity(members.len());
        for entry in &members {
            match self
                .leaves
                .iter()
                .position(|leaf| leaf.entry.as_ref() == Some(entry))
            {
                Some(at) => kept.push(self.leaves.remove(at)),
                None => {
                    let (composer, card_field, cell_field) =
                        Self::pane_parts(Some(entry.clone()), window, cx);
                    let mut leaf = Leaf::new(composer, card_field, cell_field, Ribbon::new(cx));
                    leaf.entry = Some(entry.clone());
                    kept.push(leaf);
                }
            }
        }
        self.leaves = kept;
        self.focused = focused
            .and_then(|on| {
                self.leaves
                    .iter()
                    .position(|leaf| leaf.entry.as_ref() == Some(&on))
            })
            .unwrap_or(0);
    }

    /// Move the focus to the pane on this entry, and put the project's
    /// selection on what that pane shows.
    ///
    /// The selection is what every command without a pane of its own reads —
    /// see [`Workspace::active_board`] and the rest. Syncing it here is what
    /// makes "the pane you are in" the thing they act on.
    pub(crate) fn focus_pane(
        &mut self,
        entry: &Member,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let Some(at) = self
            .leaves
            .iter()
            .position(|leaf| leaf.entry.as_ref() == Some(entry))
        else {
            return;
        };
        if self.focused == at {
            return;
        }
        self.focused = at;
        let Some((project, showing)) = self.workspace.read(cx).showing_of(entry) else {
            return;
        };
        self.leaf_mut().pane = match showing {
            Showing::Session(_) => Pane::Chat,
            Showing::Board(_) => Pane::Board,
            Showing::Article(_) => Pane::Article,
            Showing::Table(_) => Pane::Table,
        };
        self.workspace.update(cx, |workspace, cx| {
            workspace.select_showing(project, showing, cx);
        });
        self.sync_composer(cx);
        let _ = window;
        cx.notify();
    }

    /// The pane showing this entry, falling back to the focused one where the
    /// window has no layout open and the caller has no entry to name.
    ///
    /// Drawing reads a pane's state through this rather than through
    /// [`Self::leaf`]: every pane drawn against the focused leaf shares one
    /// scroll, one editor and one landing between them, so moving the focus
    /// moves what the other panes are showing.
    pub(crate) fn leaf_of(&self, on: Option<&Member>) -> &Leaf {
        on.and_then(|on| {
            self.leaves
                .iter()
                .find(|leaf| leaf.entry.as_ref() == Some(on))
        })
        .unwrap_or_else(|| self.leaf())
    }

    pub(crate) fn leaf_mut(&mut self) -> &mut Leaf {
        let at = self.focused.min(self.leaves.len().saturating_sub(1));
        &mut self.leaves[at]
    }

    pub fn new(
        settings: Settings,
        state: State,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) -> Self {
        let (composer, card_field, cell_field) = Self::pane_parts(None, window, cx);
        let name_field = cx.new(|cx| {
            TextField::new(cx)
                .with_frame(false)
                .with_key_context(RENAME_CONTEXT)
                .with_placeholder("name this session…")
        });
        let workspace = cx.new(|cx| Workspace::new(settings, state, cx));
        // The model is the only thing that says a session appeared or a turn
        // ended; the composer's placeholder, commands and busy state are all
        // read back from it rather than pushed by whoever caused the change.
        cx.observe_in(&workspace, window, |this, _, window, cx| {
            let previous = this.leaf().composer.read(cx).session();
            this.sync_composer(cx);
            let current = this.leaf().composer.read(cx).session();
            if current.is_some() && current != previous {
                window.focus(&this.composer_focus_handle(cx), cx);
            }
        })
        .detach();
        // The notice at the foot of the sidebar is the updater's, and the
        // updater moves on its own clock — and from the other window, where the
        // Developer switch that previews it lives.
        if let Some(updater) = update::of(cx) {
            cx.observe(&updater, |_, _, cx| cx.notify()).detach();
        }
        // A re-read replaced what a pane is showing — see
        // [`Workspace::reload_project`]. The card is held by id, so it comes
        // through unless what it names is gone; the cell is still addressed by
        // where it sits, so filing it now would file it into whatever slid
        // under the index, and it is dropped. The caret follows the document,
        // which is a new editor entity.
        cx.subscribe_in(&workspace, window, |this, _, _: &Reloaded, window, cx| {
            this.drop_stale_edit(cx);
            this.rest_ribbon(cx);
            this.leaf_mut().cell = None;
            this.leaf()
                .cell_field
                .update(cx, |field, cx| field.clear(cx));
            this.follow_article(window, cx);
            cx.notify();
        })
        .detach();

        let mut this = Self {
            meter: cx.new(Stats::new),
            meter_at: Floating::new(Painter::of(cx)),
            workspace,
            leaves: vec![Leaf::new(composer, card_field, cell_field, Ribbon::new(cx))],
            focused: 0,
            sidebar_open: true,
            sidebar_width: SIDEBAR_WIDTH,
            terminals: Default::default(),
            changes_open: false,
            changes_width: None,
            terminal_height: 240.,
            changes: None,
            right_panels: Default::default(),
            settings_window: None,
            collapsed_layouts: Default::default(),
            pane_landing: None,
            confirming: None,
            info: None,
            making: None,
            info_pressed: false,
            menu: None,
            sidebar_hovered: None,
            menu_cursor: Cursor::default(),
            menu_pressed: false,
            renaming: None,
            name_field,
            rail: UniformListScrollHandle::new(),
            focus: cx.focus_handle(),
        };
        // Whatever held the focus has left the tree — the composer with the
        // chat pane, an editor with its article — and an unrendered element
        // dispatches nothing, so the window takes its focus back.
        cx.on_focus_lost(window, |this, window, cx| window.focus(&this.focus, cx))
            .detach();
        // The backstop under the watch. Coming back to the window is where a
        // dropped event costs the most and the one moment we can be sure of
        // catching, so every project is re-read on the way in — see
        // [`Workspace::reload_projects`].
        cx.observe_window_activation(window, |this, window, cx| {
            if window.is_window_active() {
                this.workspace
                    .update(cx, |workspace, cx| workspace.reload_projects(cx));
            }
        })
        .detach();
        // The window comes back on the entry it was left on — the whole point
        // of [`state::Entry`], and the pane the entry is read in is half of it.
        this.land(cx);
        this.sync_composer(cx);
        // Where the caret starts. The composer is drawn only over a chat it can
        // send to, and focus on an element no frame draws is focus nowhere.
        let composer = this
            .workspace
            .read(cx)
            .active_session()
            .is_some_and(ChatSession::resumable)
            .then(|| this.composer_focus_handle(cx));
        window.focus(composer.as_ref().unwrap_or(&this.focus), cx);
        this
    }

    /// Open a session on whichever agent the last one ran on, or on the first
    /// one configured.
    ///
    /// With none configured there is nothing to open a session *on*, and a
    /// chat pane switched to over no session is a blank one. A fresh install
    /// names no agent — see [`crate::model::settings::Settings::default`] — so
    /// this is where most people meet the feature: it sends them to the
    /// section that installs one, the same place the composer's own
    /// `Install an agent…` goes.
    pub(crate) fn new_session_action(
        &mut self,
        _: &NewSession,
        _: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let asked = self.workspace.read(cx).preferred_agent();
        // Nothing to open one on: the pane says so and offers the install,
        // which is the same notice a session whose agent has gone stands
        // under. The window jumping to Settings on its own answered a question
        // it had not been asked yet.
        self.leaf_mut().asked_session = asked.is_none();
        self.show_pane(Pane::Chat, cx);
        if let Some(entry) = asked {
            self.workspace
                .update(cx, |workspace, cx| workspace.new_session(entry, None, cx));
        }
    }

    /// Open a session on the agent the menu named, if it is still installed.
    pub(crate) fn new_session_with_action(
        &mut self,
        action: &NewSessionWith,
        _: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let at = self
            .workspace
            .read(cx)
            .settings
            .agents
            .iter()
            .position(|agent| agent.name == action.agent);
        if let Some(at) = at {
            self.pick_agent(at, cx);
        }
    }

    /// Open a session on the agent after the one ⌘N would pick, wrapping — with
    /// two installed, that is always the other one.
    ///
    /// With none installed it is ⌘N, which is what says so.
    pub(crate) fn new_session_next_action(
        &mut self,
        _: &NewSessionNext,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let workspace = self.workspace.read(cx);
        let agents = &workspace.settings.agents;
        if agents.is_empty() {
            return self.new_session_action(&NewSession, window, cx);
        }
        let at = workspace
            .preferred_agent()
            .and_then(|preferred| agents.iter().position(|agent| agent.name == preferred.name))
            .map_or(0, |ix| (ix + 1) % agents.len());
        self.pick_agent(at, cx);
    }

    /// Copy what the transcript has selected. Bound app-wide and reached only
    /// where nothing nearer to the focus claimed the chord.
    ///
    /// Only while the transcript is the pane in front. The selection belongs to
    /// the active session whichever pane is showing, so without this a ⌘C over
    /// a board copies a run out of a chat nobody is looking at — which reads as
    /// the chord doing nothing, right up until it is pasted.
    fn copy_selection(&mut self, _: &CopySelection, _: &mut Window, cx: &mut Context<Self>) {
        if self.showing(cx) != Some(Pane::Chat) {
            return;
        }
        self.workspace
            .update(cx, |workspace, cx| workspace.copy_selection(cx));
    }

    pub(crate) fn next_entry(
        &mut self,
        _: &NextEntry,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        self.cycle_entry(1, window, cx);
    }

    pub(crate) fn prev_entry(
        &mut self,
        _: &PrevEntry,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        self.cycle_entry(-1, window, cx);
    }

    /// Step to the next entry the project has open, wrapping at the ends — one
    /// ring over every kind, in the order the sidebar lists them, so a board
    /// standing alone still has the article above it for a neighbour.
    fn cycle_entry(&mut self, step: isize, window: &mut Window, cx: &mut Context<Self>) {
        // Nothing on screen is nothing to step from: the launch view is not an
        // entry, and its neighbour is not another one.
        let Some(pane) = self.showing(cx) else {
            return;
        };
        let workspace = self.workspace.read(cx);
        let Some(project) = workspace.active else {
            return;
        };
        let Some(open) = workspace.projects.get(project) else {
            return;
        };
        let showing = match pane {
            Pane::Chat => open.active.map(|id| Row::Session { project, id }),
            Pane::Board => open.board.map(|ix| Row::Board { project, ix }),
            Pane::Article => open.article.map(|ix| Row::Article { project, ix }),
            Pane::Table => open.table.map(|ix| Row::Table { project, ix }),
        };
        // The divider is a line, not a landing.
        let ring: Vec<Row> = self
            .entries(project, cx)
            .into_iter()
            .filter(|row| !matches!(row, Row::Archive(_)))
            .collect();
        let at = showing.and_then(|row| ring.iter().position(|entry| *entry == row));
        let Some(landing) = stepped(at, ring.len(), step).map(|ix| ring[ix]) else {
            return;
        };
        self.open_row(landing, window, cx);
        self.reveal(landing, cx);
    }

    /// Leaving a project is the moment a half-written card has to be filed:
    /// the spot it points at belongs to the board being navigated away from.
    pub(crate) fn select_project(&mut self, ix: usize, cx: &mut Context<Self>) {
        self.commit(cx);
        self.workspace
            .update(cx, |workspace, cx| workspace.select_project(ix, cx));
        self.land(cx);
    }

    /// Put the pane on what the project coming forward was last showing.
    ///
    /// Without this the pane is whatever the last project was read in, and
    /// [`Self::showing`] falls back through the four in a fixed order — so a
    /// project with a board open from earlier in the session lands on the
    /// board however recently the article beside it was read.
    fn land(&mut self, cx: &mut Context<Self>) {
        if let Some(kind) = self.workspace.read(cx).landing() {
            self.leaf_mut().pane = Pane::of(kind);
        }
    }

    pub(crate) fn close_project(&mut self, ix: usize, cx: &mut Context<Self>) {
        self.commit(cx);
        self.workspace
            .update(cx, |workspace, cx| workspace.close_project(ix, cx));
    }

    pub(crate) fn select_session(&mut self, id: u64, cx: &mut Context<Self>) {
        self.show_pane(Pane::Chat, cx);
        self.workspace
            .update(cx, |workspace, cx| workspace.select_session(id, cx));
    }

    pub(crate) fn open_settings_action(
        &mut self,
        _: &OpenSettings,
        _: &mut Window,
        cx: &mut Context<Self>,
    ) {
        self.open_settings(Section::General, cx);
    }

    pub(crate) fn toggle_sidebar(&mut self, cx: &mut Context<Self>) {
        self.sidebar_open = !self.sidebar_open;
        cx.notify();
    }

    pub(crate) fn toggle_sidebar_action(
        &mut self,
        _: &ToggleSidebar,
        _: &mut Window,
        cx: &mut Context<Self>,
    ) {
        self.toggle_sidebar(cx);
    }

    /// The menu's Close Project. The sidebar names a project by the row it was
    /// pressed on; the menu bar has only the one in front.
    pub(crate) fn close_project_action(
        &mut self,
        _: &CloseProject,
        _: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let Some(ix) = self.workspace.read(cx).active else {
            return;
        };
        self.close_project(ix, cx);
    }

    pub(crate) fn open_settings(&mut self, section: Section, cx: &mut Context<Self>) {
        let workspace = self.workspace.clone();
        self.settings_window = settings::open(workspace, self.settings_window, section, cx);
    }

    pub(crate) fn open_project_action(
        &mut self,
        _: &OpenProject,
        _: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let picked = cx.prompt_for_paths(PathPromptOptions {
            files: false,
            directories: true,
            multiple: false,
            prompt: None,
        });
        cx.spawn(async move |this, cx| {
            let Ok(Ok(Some(paths))) = picked.await else {
                return;
            };
            let Some(path) = paths.into_iter().next() else {
                return;
            };
            let _ = this.update(cx, |this, cx| {
                this.workspace
                    .update(cx, |workspace, cx| workspace.open_project(path, cx));
            });
        })
        .detach();
    }

    /// Which pane is on screen, as against [`Self::pane`], which is the one
    /// asked for. They part when what it points at is gone — deleted, switched
    /// off, or in a project that has none open — and whatever the project does
    /// have stands in, so a launch lands on the entry it was left on rather
    /// than on an empty conversation. The sidebar reads this, not the request:
    /// a row lit for a pane nobody can see is the second selection the eye
    /// finds.
    ///
    /// `None` is the launch view. A pane exists only where something is open in
    /// it, so with nothing open there is no pane to name — least of all the
    /// chat, which under the shipped defaults is itself switched off.
    pub(crate) fn showing(&self, cx: &App) -> Option<Pane> {
        if self.has_pane(self.leaf().pane, cx) {
            return Some(self.leaf().pane);
        }
        [Pane::Chat, Pane::Board, Pane::Article, Pane::Table]
            .into_iter()
            .find(|&pane| self.has_pane(pane, cx))
    }

    /// Whether the active project has anything open in `pane` — what makes it
    /// a pane there is to show, as against one asked for.
    pub(crate) fn has_pane(&self, pane: Pane, cx: &App) -> bool {
        let workspace = self.workspace.read(cx);
        match pane {
            // A pane with no session in it, where one was asked for and there
            // is no agent to open it on — [`Self::asked_session`]. Conditioned
            // on the agent as well as on the flag, so an install is all it
            // takes to put the pane back to what it is for.
            Pane::Chat => {
                workspace.active_session().is_some()
                    || (self.leaf().asked_session && workspace.preferred_agent().is_none())
            }
            Pane::Board => workspace.active_board().is_some(),
            Pane::Article => workspace.active_article().is_some(),
            Pane::Table => workspace.active_table().is_some(),
        }
    }

    /// Nothing is open, so there is nowhere to send a prompt — the only thing
    /// on offer is a folder.
    pub(crate) fn no_project(&self, cx: &mut Context<Self>) -> AnyElement {
        let theme = Theme::of(cx).clone();
        let painter = Painter::of(cx);
        theme
            .empty_state(
                icons::files::Folder,
                "No project open",
                "An agent runs in a directory. Pick one to start.",
            )
            .flex_1()
            .child(
                theme
                    .button(
                        "Open folder…",
                        ButtonStyle::Prominent,
                        Some(Fade::new(painter, "open-project-empty")),
                    )
                    .id("open-project-empty")
                    .on_click(cx.listener(|this, _, window, cx| {
                        this.open_project_action(&OpenProject, window, cx);
                    })),
            )
            .into_any_element()
    }
}

impl Render for Cydonia {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        self.sync_leaves(window, cx);
        self.sync_changes(cx);
        let theme = Theme::of(cx).clone();
        let arranged = self.workspace.read(cx).active_layout().is_some();
        div()
            .key_context("Cydonia")
            .size_full()
            .relative()
            .flex()
            .flex_row()
            .font_family(theme.font_sans.clone())
            .text_color(theme.text)
            .text_style(TextStyle::Body)
            .on_action(cx.listener(|this, _: &NextPane, window, cx| this.step_pane(1, window, cx)))
            .on_action(cx.listener(|this, _: &PrevPane, window, cx| this.step_pane(-1, window, cx)))
            .on_action(
                cx.listener(|this, _: &ClosePane, window, cx| this.close_focused_pane(window, cx)),
            )
            .on_action(cx.listener(|this, _: &ZoomPane, _, cx| this.zoom_focused_pane(cx)))
            // The right-hand panel and what opens into it are not offered
            // beside a layout: it divides the room they would stand in, and
            // macOS greys an item nothing is left to handle.
            .when(!arranged, |root| {
                root.on_action(cx.listener(Self::toggle_changes))
                    .on_action(cx.listener(Self::open_session_file))
                    .on_action(
                        cx.listener(|this, _: &OpenReview, window, cx| {
                            this.show_changes(window, cx)
                        }),
                    )
                    .on_action(
                        cx.listener(|this, _: &OpenFiles, window, cx| {
                            this.toggle_files(window, cx)
                        }),
                    )
            })
            .on_action(cx.listener(Self::copy_selection))
            .on_action(cx.listener(Self::commit_cell_action))
            .on_action(cx.listener(Self::dismiss_cell))
            .on_action(cx.listener(Self::commit_name))
            .on_action(cx.listener(Self::commit_info))
            .on_action(cx.listener(Self::dismiss_info))
            .on_action(cx.listener(Self::make_board))
            .on_action(cx.listener(Self::dismiss_new_board))
            .on_action(cx.listener(Self::dismiss_name))
            // Everything the menu bar names, and only under the conditions
            // that keep its items honest.
            .map(|root| self.commands(root, cx))
            .on_drag_move(
                cx.listener(|this, event: &DragMoveEvent<SplitDrag>, _, cx| {
                    this.sidebar_width = f32::from(event.event.position.x)
                        .clamp(SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX);
                    cx.notify();
                }),
            )
            // An action reaches the handlers above only through the focused
            // element's ancestors. Sized at nothing, so the pane that does hold
            // a field keeps its focus through a click anywhere else.
            .child(div().track_focus(&self.focus))
            .when(self.sidebar_open, |root| root.child(self.sidebar(cx)))
            .child(self.detail(window, cx))
            // Rides on the seam between the sidebar and the detail column
            // rather than sitting in flow, so neither gives up a column.
            .when(self.sidebar_open, |root| {
                root.child(
                    crate::view::component::divider::divider(&theme, Axis::Horizontal)
                        .id("sidebar-split")
                        .absolute()
                        .top_0()
                        .left(px(
                            self.sidebar_width - crate::view::component::divider::HIT / 2.
                        ))
                        .on_drag(SplitDrag, |_, _, _, cx| cx.new(|_| Empty)),
                )
            })
            .children(
                self.workspace
                    .read(cx)
                    .meter
                    .then(|| meter::panel("app-meter", &self.meter_at, &self.meter, window)),
            )
            // Over every column and every floating control: nothing behind it
            // is answerable while it is asking.
            .children(self.confirm_delete(cx))
            .children(self.new_board_dialog(cx))
    }
}