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}