ucp_codegraph/programmatic/
session.rs1use std::collections::HashSet;
2
3use anyhow::Result;
4use ucm_core::BlockId;
5
6use crate::{
7 export_codegraph_context_with_config, render_codegraph_context_prompt, CodeGraphContextExport,
8 CodeGraphContextFrontierAction, CodeGraphContextSession, CodeGraphContextSummary,
9 CodeGraphContextUpdate, CodeGraphDetailLevel, CodeGraphExportConfig, CodeGraphRenderConfig,
10 CodeGraphSelectionOriginKind, CodeGraphTraversalConfig,
11};
12
13use super::{
14 query,
15 types::{
16 CodeGraphExpandMode, CodeGraphFindQuery, CodeGraphNodeSummary,
17 CodeGraphRecommendedActionsResult, CodeGraphSelectionExplanation, CodeGraphSessionDiff,
18 },
19 CodeGraphNavigator,
20};
21
22#[derive(Debug, Clone)]
23pub struct CodeGraphNavigatorSession {
24 graph: CodeGraphNavigator,
25 context: CodeGraphContextSession,
26}
27
28impl CodeGraphNavigatorSession {
29 pub fn new(graph: CodeGraphNavigator) -> Self {
30 Self {
31 graph,
32 context: CodeGraphContextSession::new(),
33 }
34 }
35
36 pub fn context(&self) -> &CodeGraphContextSession {
37 &self.context
38 }
39
40 pub fn selected_block_ids(&self) -> Vec<BlockId> {
41 let mut ids = self.context.selected.keys().copied().collect::<Vec<_>>();
42 ids.sort_by_key(|value| value.to_string());
43 ids
44 }
45
46 pub fn summary(&self) -> CodeGraphContextSummary {
47 self.context.summary(self.graph.document())
48 }
49
50 pub fn fork(&self) -> Self {
51 self.clone()
52 }
53
54 pub fn seed_overview(&mut self, max_depth: Option<usize>) -> CodeGraphContextUpdate {
55 self.context
56 .seed_overview_with_depth(self.graph.document(), max_depth)
57 }
58
59 pub fn focus(&mut self, selector: Option<&str>) -> Result<CodeGraphContextUpdate> {
60 let block_id = selector
61 .map(|value| self.graph.resolve_required(value))
62 .transpose()?;
63 Ok(self.context.set_focus(self.graph.document(), block_id))
64 }
65
66 pub fn select(
67 &mut self,
68 selector: &str,
69 detail_level: CodeGraphDetailLevel,
70 ) -> Result<CodeGraphContextUpdate> {
71 let block_id = self.graph.resolve_required(selector)?;
72 Ok(self
73 .context
74 .select_block(self.graph.document(), block_id, detail_level))
75 }
76
77 pub fn expand(
78 &mut self,
79 selector: &str,
80 mode: CodeGraphExpandMode,
81 traversal: &CodeGraphTraversalConfig,
82 ) -> Result<CodeGraphContextUpdate> {
83 let block_id = self.graph.resolve_required(selector)?;
84 Ok(match mode {
85 CodeGraphExpandMode::File => {
86 self.context
87 .expand_file_with_config(self.graph.document(), block_id, traversal)
88 }
89 CodeGraphExpandMode::Dependencies => self.context.expand_dependencies_with_config(
90 self.graph.document(),
91 block_id,
92 traversal,
93 ),
94 CodeGraphExpandMode::Dependents => self.context.expand_dependents_with_config(
95 self.graph.document(),
96 block_id,
97 traversal,
98 ),
99 })
100 }
101
102 pub fn hydrate_source(
103 &mut self,
104 selector: &str,
105 padding: usize,
106 ) -> Result<CodeGraphContextUpdate> {
107 let block_id = self.graph.resolve_required(selector)?;
108 Ok(self
109 .context
110 .hydrate_source(self.graph.document(), block_id, padding))
111 }
112
113 pub fn collapse(
114 &mut self,
115 selector: &str,
116 include_descendants: bool,
117 ) -> Result<CodeGraphContextUpdate> {
118 let block_id = self.graph.resolve_required(selector)?;
119 Ok(self
120 .context
121 .collapse(self.graph.document(), block_id, include_descendants))
122 }
123
124 pub fn pin(&mut self, selector: &str, pinned: bool) -> Result<CodeGraphContextUpdate> {
125 let block_id = self.graph.resolve_required(selector)?;
126 Ok(self.context.pin(block_id, pinned))
127 }
128
129 pub fn prune(&mut self, max_selected: Option<usize>) -> CodeGraphContextUpdate {
130 self.context.prune(self.graph.document(), max_selected)
131 }
132
133 pub fn export(
134 &self,
135 render: &CodeGraphRenderConfig,
136 export: &CodeGraphExportConfig,
137 ) -> CodeGraphContextExport {
138 export_codegraph_context_with_config(self.graph.document(), &self.context, render, export)
139 }
140
141 pub fn render_prompt(&self, render: &CodeGraphRenderConfig) -> String {
142 render_codegraph_context_prompt(self.graph.document(), &self.context, render)
143 }
144
145 pub fn find_nodes(&self, query: &CodeGraphFindQuery) -> Result<Vec<CodeGraphNodeSummary>> {
146 self.graph.find_nodes(query)
147 }
148
149 pub fn why_selected(&self, selector: &str) -> Result<CodeGraphSelectionExplanation> {
150 let block_id = self.graph.resolve_required(selector)?;
151 let node = self.graph.describe_node(block_id);
152 let Some(selected) = self.context.selected.get(&block_id) else {
153 return Ok(CodeGraphSelectionExplanation {
154 block_id,
155 selected: false,
156 focus: self.context.focus == Some(block_id),
157 pinned: false,
158 detail_level: None,
159 origin: None,
160 explanation: "Node is not currently selected in the session.".to_string(),
161 node,
162 anchor: None,
163 });
164 };
165
166 let anchor = selected
167 .origin
168 .as_ref()
169 .and_then(|origin| origin.anchor)
170 .and_then(|id| self.graph.describe_node(id));
171 let explanation = match selected.origin.as_ref().map(|origin| origin.kind) {
172 Some(CodeGraphSelectionOriginKind::Manual) => {
173 "Node was selected directly by the agent.".to_string()
174 }
175 Some(CodeGraphSelectionOriginKind::Overview) => {
176 "Node was selected as part of the overview scaffold.".to_string()
177 }
178 Some(CodeGraphSelectionOriginKind::FileSymbols) => {
179 "Node was selected while expanding file symbols.".to_string()
180 }
181 Some(CodeGraphSelectionOriginKind::Dependencies) => format!(
182 "Node was selected while following dependency edges{}.",
183 relation_suffix(selected.origin.as_ref())
184 ),
185 Some(CodeGraphSelectionOriginKind::Dependents) => format!(
186 "Node was selected while following dependent edges{}.",
187 relation_suffix(selected.origin.as_ref())
188 ),
189 None => "Node is selected in the session.".to_string(),
190 };
191
192 Ok(CodeGraphSelectionExplanation {
193 block_id,
194 selected: true,
195 focus: self.context.focus == Some(block_id),
196 pinned: selected.pinned,
197 detail_level: Some(selected.detail_level),
198 origin: selected.origin.clone(),
199 explanation,
200 node,
201 anchor,
202 })
203 }
204
205 pub fn diff(&self, other: &Self) -> CodeGraphSessionDiff {
206 let before = self
207 .context
208 .selected
209 .keys()
210 .copied()
211 .collect::<HashSet<_>>();
212 let after = other
213 .context
214 .selected
215 .keys()
216 .copied()
217 .collect::<HashSet<_>>();
218 let mut added = after
219 .difference(&before)
220 .copied()
221 .filter_map(|id| other.graph.describe_node(id))
222 .collect::<Vec<_>>();
223 let mut removed = before
224 .difference(&after)
225 .copied()
226 .filter_map(|id| self.graph.describe_node(id))
227 .collect::<Vec<_>>();
228 added.sort_by(|left, right| left.label.cmp(&right.label));
229 removed.sort_by(|left, right| left.label.cmp(&right.label));
230 CodeGraphSessionDiff {
231 added,
232 removed,
233 focus_before: self.context.focus,
234 focus_after: other.context.focus,
235 changed_focus: self.context.focus != other.context.focus,
236 }
237 }
238
239 pub fn apply_recommended_actions(
240 &mut self,
241 top: usize,
242 padding: usize,
243 depth: Option<usize>,
244 max_add: Option<usize>,
245 priority_threshold: Option<u16>,
246 ) -> Result<CodeGraphRecommendedActionsResult> {
247 let export_config = CodeGraphExportConfig {
248 max_frontier_actions: top.max(1).max(8),
249 ..Default::default()
250 };
251 let actions = self
252 .export(&CodeGraphRenderConfig::default(), &export_config)
253 .frontier
254 .into_iter()
255 .filter(|action| action.candidate_count > 0)
256 .filter(|action| {
257 priority_threshold
258 .map(|threshold| action.priority >= threshold)
259 .unwrap_or(true)
260 })
261 .take(top.max(1))
262 .collect::<Vec<_>>();
263 if actions.is_empty() {
264 return Err(anyhow::anyhow!(
265 "No recommended actions available for the current focus"
266 ));
267 }
268
269 let mut update = CodeGraphContextUpdate::default();
270 let mut applied_actions = Vec::new();
271 for action in actions {
272 let traversal = CodeGraphTraversalConfig {
273 depth: depth.unwrap_or(1),
274 relation_filters: action.relation.clone().into_iter().collect(),
275 max_add,
276 priority_threshold,
277 };
278 applied_actions.push(action_summary(&action));
279 merge_update(
280 &mut update,
281 match action.action.as_str() {
282 "hydrate_source" => {
283 self.context
284 .hydrate_source(self.graph.document(), action.block_id, padding)
285 }
286 "expand_file" => self.context.expand_file_with_config(
287 self.graph.document(),
288 action.block_id,
289 &traversal,
290 ),
291 "expand_dependencies" => self.context.expand_dependencies_with_config(
292 self.graph.document(),
293 action.block_id,
294 &traversal,
295 ),
296 "expand_dependents" => self.context.expand_dependents_with_config(
297 self.graph.document(),
298 action.block_id,
299 &traversal,
300 ),
301 "collapse" => {
302 self.context
303 .collapse(self.graph.document(), action.block_id, false)
304 }
305 _ => CodeGraphContextUpdate::default(),
306 },
307 );
308 }
309
310 Ok(CodeGraphRecommendedActionsResult {
311 applied_actions,
312 update,
313 })
314 }
315
316 pub fn path_between(
317 &self,
318 start_selector: &str,
319 end_selector: &str,
320 max_hops: usize,
321 ) -> Result<Option<crate::programmatic::types::CodeGraphPathResult>> {
322 let start = self.graph.resolve_required(start_selector)?;
323 let end = self.graph.resolve_required(end_selector)?;
324 Ok(query::path_between(
325 self.graph.document(),
326 start,
327 end,
328 max_hops,
329 ))
330 }
331}
332
333fn merge_update(into: &mut CodeGraphContextUpdate, next: CodeGraphContextUpdate) {
334 into.added.extend(next.added);
335 into.removed.extend(next.removed);
336 into.changed.extend(next.changed);
337 into.warnings.extend(next.warnings);
338 if next.focus.is_some() {
339 into.focus = next.focus;
340 }
341}
342
343fn action_summary(action: &CodeGraphContextFrontierAction) -> String {
344 match action.relation.as_deref() {
345 Some(relation) => format!("{} {} via {}", action.action, action.short_id, relation),
346 None => format!("{} {}", action.action, action.short_id),
347 }
348}
349
350fn relation_suffix(origin: Option<&crate::CodeGraphSelectionOrigin>) -> String {
351 origin
352 .and_then(|value| value.relation.as_deref())
353 .map(|relation| format!(" via `{}`", relation))
354 .unwrap_or_default()
355}