Skip to main content

forge_server/surface/
composer.rs

1//! `slash_commands()`, `subagents()`, `subagent_cards()`,
2//! `forge_commands()`, `file_index()`, `respect_gitignore()`: what the
3//! composer's autocomplete triggers read, and the two sub-agent facts a
4//! record draws its section from.
5//!
6//! Three of them are facts about a session - what the CLI advertised, and what
7//! its conversation dispatched - so the core holds them and these verbs read
8//! through it. The other three have no session in them at all: forge's own
9//! command table and the file walk are data a view reads, and the walk's
10//! ignore preference is the user's own, which the core reads on the walk's
11//! behalf.
12//!
13//! The emoji set is not here. Which shortcodes exist and which a query
14//! selects is the typeahead's own business, so it lives with the typeahead
15//! rather than being handed to it.
16//!
17//! The tests here read the two session facts through a fixture that
18//! writes the core's fields directly, so they pin the reads and not the
19//! keeping: whether the init frame and `commands_changed` retain anything
20//! at all is pinned by `forge-workspace`'s session-task tests.
21
22use forge_primitives::{AvailableAgent, AvailableCommand, SessionSlot};
23
24use crate::commands::ForgeCommand;
25use crate::file_index::FileIndex;
26use crate::surface::ViewSurface;
27
28impl ViewSurface {
29    /// The slash commands the CLI last advertised for `slot`, named as
30    /// they are typed. Empty for a seat whose session has not connected,
31    /// and for one whose CLI advertised none.
32    ///
33    /// The core retains them from the init frame each turn and from
34    /// `commands_changed`, so a view that arrived after the turn started
35    /// reads them here rather than waiting a whole turn for the next
36    /// frame.
37    ///
38    /// The wire carries the names bare, so the leading slash is added
39    /// here: a caller renders `name` as it comes back.
40    ///
41    /// Forge's own commands are not in this list. A view rendering the `/`
42    /// dropdown reads those from [`Self::forge_commands`], and a name in
43    /// both is drawn from the catalogue rather than the CLI's copy.
44    pub fn slash_commands(&self, slot: &SessionSlot) -> Vec<AvailableCommand> {
45        self.workspace
46            .available_commands_for(slot)
47            .into_iter()
48            .map(|command| AvailableCommand {
49                name: forge_workspace::translate::commands::slash_name(&command.name),
50                ..command
51            })
52            .collect()
53    }
54
55    /// forge's own commands, which a view offers beside the ones the CLI
56    /// advertises: a `/` dropdown is built from this table and
57    /// [`Self::slash_commands`] together, so the two views cannot show
58    /// different lists.
59    ///
60    /// A name here shadows the CLI's row for the same name
61    /// (`crate::commands::is_forge_command`), so one row is drawn, with the
62    /// catalogue's words.
63    pub fn forge_commands() -> &'static [ForgeCommand] {
64        crate::commands::FORGE_COMMANDS
65    }
66
67    /// The subagents the CLI last advertised for `slot`, retained by the
68    /// same frames and empty in the same two cases as
69    /// [`Self::slash_commands`].
70    pub fn subagents(&self, slot: &SessionSlot) -> Vec<AvailableAgent> {
71        self.workspace.available_agents_for(slot)
72    }
73
74    /// The sessions `slot` can jump back to with `/resume`, newest first,
75    /// which is what the seat's own `claude --resume` can find from the
76    /// directory it runs in. See [`forge_workspace::Workspace::resumable_sessions`]
77    /// for the set a lead and a worker each get.
78    pub async fn resumable_sessions(
79        &self,
80        slot: &SessionSlot,
81    ) -> Vec<forge_primitives::SDKSessionInfo> {
82        self.workspace.resumable_sessions(slot).await
83    }
84
85    /// The sub-agent instances the session at `slot` has folded, as the list
86    /// it pushes when the list moves.
87    ///
88    /// A record that folded the conversation's messages itself would answer
89    /// with whatever window of them it holds, which is not the conversation
90    /// the cards describe.
91    pub fn subagent_cards(
92        &self,
93        slot: &SessionSlot,
94    ) -> Vec<forge_primitives::runtime::SubagentCard> {
95        self.workspace.subagent_cards_for(slot)
96    }
97
98    /// The seat's file index, as the seat's own loop last walked it, or
99    /// `None` until the first walk lands.
100    ///
101    /// **Held rather than walked here.** The walk is a whole tree, and the
102    /// seat's loop is the one that takes it - throttled, and announced as
103    /// `FileIndexChanged` when it moves - so this answers the store the loop
104    /// writes rather than paying for a walk of its own.
105    pub fn file_index(&self, slot: &SessionSlot) -> Option<std::sync::Arc<FileIndex>> {
106        self.workspace.file_index(slot).map(|held| held.index)
107    }
108
109    /// The record's file index for `slot`: the store, or the walk the seat's
110    /// loop would take, under the same gate - so the snapshot's fallback and
111    /// the first pass are one walk rather than two of the same tree.
112    pub async fn file_index_for_record(&self, slot: &SessionSlot) -> std::sync::Arc<FileIndex> {
113        if let Some(index) = self.file_index(slot) {
114            return index;
115        }
116        self.workspace.walk_files_for_record(slot).await
117    }
118
119    /// One working tree's files, walked on demand. `root` is the session's
120    /// own scan cwd, which a git worker's worktree overrides.
121    ///
122    /// **A page's own walk.** A page that holds no seat - nothing subscribes
123    /// on its behalf, so no seat's loop runs for it - has no store to read,
124    /// and walks where it stands.
125    pub fn walk_file_index(&self, root: &std::path::Path) -> FileIndex {
126        FileIndex::scan(root, self.respect_gitignore())
127    }
128
129    /// The user's own `respectGitignore`, the preference
130    /// [`Self::walk_file_index`] runs with. Read on each call from the CLI's
131    /// per-user preferences document, so a flip reaches the next walk.
132    pub fn respect_gitignore(&self) -> bool {
133        let preferences = self.workspace.user_preferences();
134        crate::file_index::respect_gitignore(preferences.as_ref())
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use forge_primitives::SessionSlot;
141
142    use crate::surface::ViewSurface;
143
144    fn slot() -> SessionSlot {
145        SessionSlot::lead("TestOrg", "forge")
146    }
147
148    /// A seat whose CLI has advertised nothing reads as empty: a view
149    /// opening on a session that has not connected renders an empty
150    /// list, not an error and not another seat's.
151    #[test]
152    fn slash_commands_reads_the_seat_it_was_asked_about() {
153        let (workspace, _dir) = crate::surface::testing::workspace();
154        let surface = ViewSurface::new(std::sync::Arc::clone(&workspace));
155
156        assert!(surface.slash_commands(&slot()).is_empty(), "nothing advertised reads as empty");
157
158        workspace.seed_test_advertised_catalogues(
159            &slot(),
160            vec![forge_primitives::AvailableCommand::new("help", "Open help")],
161            Vec::new(),
162        );
163
164        let commands = surface.slash_commands(&slot());
165        assert_eq!(commands.len(), 1, "the seat's own list reaches the view");
166        assert_eq!(
167            commands[0].name, "/help",
168            "and named as it is typed, which is not how the wire carries it",
169        );
170        assert_eq!(commands[0].description, "Open help", "the rest of the entry comes through");
171        assert!(
172            surface.slash_commands(&SessionSlot::lead("TestOrg", "nothing")).is_empty(),
173            "and a seat with no declaration reads as empty rather than sharing one",
174        );
175    }
176
177    /// The agent catalogue, the same way.
178    #[test]
179    fn subagents_reads_the_seat_it_was_asked_about() {
180        let (workspace, _dir) = crate::surface::testing::workspace();
181        let surface = ViewSurface::new(std::sync::Arc::clone(&workspace));
182
183        assert!(surface.subagents(&slot()).is_empty(), "nothing advertised reads as empty");
184
185        workspace.seed_test_advertised_catalogues(
186            &slot(),
187            Vec::new(),
188            vec![forge_primitives::AvailableAgent::new("reviewer", "Review code")],
189        );
190
191        let agents = surface.subagents(&slot());
192        assert_eq!(agents.len(), 1, "the seat's own catalogue reaches the view");
193        assert_eq!(agents[0].name, "reviewer");
194    }
195
196    /// The index is the seat's own: a slot nothing has walked reads as
197    /// `None` rather than paying for a walk of the tree here, and what the
198    /// seat's loop stores is what this answers.
199    ///
200    /// The walk itself is the workspace's (`a_walk_happens_only_once_the_
201    /// window_has_passed` and its siblings, beside the seat's loop).
202    #[tokio::test]
203    async fn the_file_index_answers_the_seat_that_was_walked() {
204        let (workspace, _dir) = crate::surface::testing::workspace();
205        let surface = ViewSurface::new(std::sync::Arc::clone(&workspace));
206        let seat = slot();
207        workspace.register_domain_session(seat.clone(), None);
208
209        assert!(surface.file_index(&seat).is_none(), "a slot nothing walked reads as none");
210
211        let mut index = forge_workspace::file_index::FileIndex::default();
212        index.entries.insert(
213            "src/main.rs".to_owned(),
214            forge_workspace::file_index::FileCandidate {
215                rel_path: "src/main.rs".to_owned(),
216                rel_path_lower: "src/main.rs".to_owned(),
217                basename_lower: "main.rs".to_owned(),
218                depth: 1,
219            },
220        );
221        workspace.store_file_index(&seat, std::sync::Arc::new(index));
222
223        let held = surface.file_index(&seat).expect("the walk the seat holds comes back");
224        assert!(held.entries.contains_key("src/main.rs"), "with the files the walk found");
225        let other = SessionSlot::worker("TestOrg", "forge", "worker");
226        assert!(surface.file_index(&other).is_none(), "and it is the seat's own, not another's");
227    }
228}