deepagents-cli 0.0.3__py3-none-any.whl → 0.0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of deepagents-cli might be problematic. Click here for more details.

Files changed (42) hide show
  1. deepagents_cli/__init__.py +5 -0
  2. deepagents_cli/__main__.py +6 -0
  3. deepagents_cli/agent.py +278 -0
  4. deepagents_cli/cli.py +13 -0
  5. deepagents_cli/commands.py +89 -0
  6. deepagents_cli/config.py +138 -0
  7. deepagents_cli/execution.py +644 -0
  8. deepagents_cli/file_ops.py +347 -0
  9. deepagents_cli/input.py +249 -0
  10. deepagents_cli/main.py +226 -0
  11. deepagents_cli/py.typed +0 -0
  12. deepagents_cli/token_utils.py +63 -0
  13. deepagents_cli/tools.py +140 -0
  14. deepagents_cli/ui.py +489 -0
  15. deepagents_cli-0.0.5.dist-info/METADATA +18 -0
  16. deepagents_cli-0.0.5.dist-info/RECORD +19 -0
  17. deepagents_cli-0.0.5.dist-info/entry_points.txt +3 -0
  18. deepagents_cli-0.0.5.dist-info/top_level.txt +1 -0
  19. deepagents/__init__.py +0 -7
  20. deepagents/cli.py +0 -567
  21. deepagents/default_agent_prompt.md +0 -64
  22. deepagents/graph.py +0 -144
  23. deepagents/memory/__init__.py +0 -17
  24. deepagents/memory/backends/__init__.py +0 -15
  25. deepagents/memory/backends/composite.py +0 -250
  26. deepagents/memory/backends/filesystem.py +0 -330
  27. deepagents/memory/backends/state.py +0 -206
  28. deepagents/memory/backends/store.py +0 -351
  29. deepagents/memory/backends/utils.py +0 -319
  30. deepagents/memory/protocol.py +0 -164
  31. deepagents/middleware/__init__.py +0 -13
  32. deepagents/middleware/agent_memory.py +0 -207
  33. deepagents/middleware/filesystem.py +0 -615
  34. deepagents/middleware/patch_tool_calls.py +0 -44
  35. deepagents/middleware/subagents.py +0 -481
  36. deepagents/pretty_cli.py +0 -289
  37. deepagents_cli-0.0.3.dist-info/METADATA +0 -551
  38. deepagents_cli-0.0.3.dist-info/RECORD +0 -24
  39. deepagents_cli-0.0.3.dist-info/entry_points.txt +0 -2
  40. deepagents_cli-0.0.3.dist-info/licenses/LICENSE +0 -21
  41. deepagents_cli-0.0.3.dist-info/top_level.txt +0 -1
  42. {deepagents_cli-0.0.3.dist-info → deepagents_cli-0.0.5.dist-info}/WHEEL +0 -0
@@ -0,0 +1,347 @@
1
+ """Helpers for tracking file operations and computing diffs for CLI display."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ from deepagents.backends.utils import perform_string_replacement
11
+
12
+ FileOpStatus = Literal["pending", "success", "error"]
13
+
14
+
15
+ @dataclass
16
+ class ApprovalPreview:
17
+ """Data used to render HITL previews."""
18
+
19
+ title: str
20
+ details: list[str]
21
+ diff: str | None = None
22
+ diff_title: str | None = None
23
+ error: str | None = None
24
+
25
+
26
+ def _safe_read(path: Path) -> str | None:
27
+ """Read file content, returning None on failure."""
28
+ try:
29
+ return path.read_text()
30
+ except (OSError, UnicodeDecodeError):
31
+ return None
32
+
33
+
34
+ def _count_lines(text: str) -> int:
35
+ """Count lines in text, treating empty strings as zero lines."""
36
+ if not text:
37
+ return 0
38
+ return len(text.splitlines())
39
+
40
+
41
+ def compute_unified_diff(
42
+ before: str,
43
+ after: str,
44
+ display_path: str,
45
+ *,
46
+ max_lines: int | None = 800,
47
+ ) -> str | None:
48
+ """Compute a unified diff between before and after content."""
49
+ before_lines = before.splitlines()
50
+ after_lines = after.splitlines()
51
+ diff_lines = list(
52
+ difflib.unified_diff(
53
+ before_lines,
54
+ after_lines,
55
+ fromfile=f"{display_path} (before)",
56
+ tofile=f"{display_path} (after)",
57
+ lineterm="",
58
+ )
59
+ )
60
+ if not diff_lines:
61
+ return None
62
+ if max_lines is not None and len(diff_lines) > max_lines:
63
+ truncated = diff_lines[: max_lines - 1]
64
+ truncated.append("... (diff truncated)")
65
+ return "\n".join(truncated)
66
+ return "\n".join(diff_lines)
67
+
68
+
69
+ @dataclass
70
+ class FileOpMetrics:
71
+ """Line and byte level metrics for a file operation."""
72
+
73
+ lines_read: int = 0
74
+ start_line: int | None = None
75
+ end_line: int | None = None
76
+ lines_written: int = 0
77
+ lines_added: int = 0
78
+ lines_removed: int = 0
79
+ bytes_written: int = 0
80
+
81
+
82
+ @dataclass
83
+ class FileOperationRecord:
84
+ """Track a single filesystem tool call."""
85
+
86
+ tool_name: str
87
+ display_path: str
88
+ physical_path: Path | None
89
+ tool_call_id: str | None
90
+ args: dict[str, Any] = field(default_factory=dict)
91
+ status: FileOpStatus = "pending"
92
+ error: str | None = None
93
+ metrics: FileOpMetrics = field(default_factory=FileOpMetrics)
94
+ diff: str | None = None
95
+ before_content: str | None = None
96
+ after_content: str | None = None
97
+ read_output: str | None = None
98
+
99
+
100
+ def resolve_physical_path(path_str: str | None, assistant_id: str | None) -> Path | None:
101
+ """Convert a virtual/relative path to a physical filesystem path."""
102
+ if not path_str:
103
+ return None
104
+ try:
105
+ if assistant_id and path_str.startswith("/memories/"):
106
+ agent_dir = Path.home() / ".deepagents" / assistant_id
107
+ suffix = path_str.removeprefix("/memories/").lstrip("/")
108
+ return (agent_dir / suffix).resolve()
109
+ path = Path(path_str)
110
+ if path.is_absolute():
111
+ return path
112
+ return (Path.cwd() / path).resolve()
113
+ except (OSError, ValueError):
114
+ return None
115
+
116
+
117
+ def format_display_path(path_str: str | None) -> str:
118
+ """Format a path for display."""
119
+ if not path_str:
120
+ return "(unknown)"
121
+ try:
122
+ path = Path(path_str)
123
+ if path.is_absolute():
124
+ return path.name or str(path)
125
+ return str(path)
126
+ except (OSError, ValueError):
127
+ return str(path_str)
128
+
129
+
130
+ def build_approval_preview(
131
+ tool_name: str,
132
+ args: dict[str, Any] | None,
133
+ assistant_id: str | None,
134
+ ) -> ApprovalPreview | None:
135
+ """Collect summary info and diff for HITL approvals."""
136
+ if args is None:
137
+ return None
138
+
139
+ path_str = str(args.get("file_path") or args.get("path") or "")
140
+ display_path = format_display_path(path_str)
141
+ physical_path = resolve_physical_path(path_str, assistant_id)
142
+
143
+ if tool_name == "write_file":
144
+ content = str(args.get("content", ""))
145
+ before = _safe_read(physical_path) if physical_path and physical_path.exists() else ""
146
+ after = content
147
+ diff = compute_unified_diff(before or "", after, display_path, max_lines=None)
148
+ additions = 0
149
+ if diff:
150
+ additions = sum(
151
+ 1
152
+ for line in diff.splitlines()
153
+ if line.startswith("+") and not line.startswith("+++")
154
+ )
155
+ total_lines = _count_lines(after)
156
+ details = [
157
+ f"File: {path_str}",
158
+ "Action: Create new file" + (" (overwrites existing content)" if before else ""),
159
+ f"Lines to write: {additions or total_lines}",
160
+ f"Bytes to write: {len(after.encode('utf-8'))}",
161
+ ]
162
+ return ApprovalPreview(
163
+ title=f"Write {display_path}",
164
+ details=details,
165
+ diff=diff,
166
+ diff_title=f"Diff {display_path}",
167
+ )
168
+
169
+ if tool_name == "edit_file":
170
+ if physical_path is None:
171
+ return ApprovalPreview(
172
+ title=f"Update {display_path}",
173
+ details=[f"File: {path_str}", "Action: Replace text"],
174
+ error="Unable to resolve file path.",
175
+ )
176
+ before = _safe_read(physical_path)
177
+ if before is None:
178
+ return ApprovalPreview(
179
+ title=f"Update {display_path}",
180
+ details=[f"File: {path_str}", "Action: Replace text"],
181
+ error="Unable to read current file contents.",
182
+ )
183
+ old_string = str(args.get("old_string", ""))
184
+ new_string = str(args.get("new_string", ""))
185
+ replace_all = bool(args.get("replace_all", False))
186
+ replacement = perform_string_replacement(before, old_string, new_string, replace_all)
187
+ if isinstance(replacement, str):
188
+ return ApprovalPreview(
189
+ title=f"Update {display_path}",
190
+ details=[f"File: {path_str}", "Action: Replace text"],
191
+ error=replacement,
192
+ )
193
+ after, occurrences = replacement
194
+ diff = compute_unified_diff(before, after, display_path, max_lines=None)
195
+ additions = 0
196
+ deletions = 0
197
+ if diff:
198
+ additions = sum(
199
+ 1
200
+ for line in diff.splitlines()
201
+ if line.startswith("+") and not line.startswith("+++")
202
+ )
203
+ deletions = sum(
204
+ 1
205
+ for line in diff.splitlines()
206
+ if line.startswith("-") and not line.startswith("---")
207
+ )
208
+ details = [
209
+ f"File: {path_str}",
210
+ f"Action: Replace text ({'all occurrences' if replace_all else 'single occurrence'})",
211
+ f"Occurrences matched: {occurrences}",
212
+ f"Lines changed: +{additions} / -{deletions}",
213
+ ]
214
+ return ApprovalPreview(
215
+ title=f"Update {display_path}",
216
+ details=details,
217
+ diff=diff,
218
+ diff_title=f"Diff {display_path}",
219
+ )
220
+
221
+ return None
222
+
223
+
224
+ class FileOpTracker:
225
+ """Collect file operation metrics during a CLI interaction."""
226
+
227
+ def __init__(self, *, assistant_id: str | None) -> None:
228
+ self.assistant_id = assistant_id
229
+ self.active: dict[str | None, FileOperationRecord] = {}
230
+ self.completed: list[FileOperationRecord] = []
231
+
232
+ def start_operation(
233
+ self, tool_name: str, args: dict[str, Any], tool_call_id: str | None
234
+ ) -> None:
235
+ if tool_name not in {"read_file", "write_file", "edit_file"}:
236
+ return
237
+ path_str = str(args.get("file_path") or args.get("path") or "")
238
+ display_path = format_display_path(path_str)
239
+ record = FileOperationRecord(
240
+ tool_name=tool_name,
241
+ display_path=display_path,
242
+ physical_path=resolve_physical_path(path_str, self.assistant_id),
243
+ tool_call_id=tool_call_id,
244
+ args=args,
245
+ )
246
+ if tool_name in {"write_file", "edit_file"} and record.physical_path:
247
+ record.before_content = _safe_read(record.physical_path) or ""
248
+ self.active[tool_call_id] = record
249
+
250
+ def complete_with_message(self, tool_message: Any) -> FileOperationRecord | None:
251
+ tool_call_id = getattr(tool_message, "tool_call_id", None)
252
+ record = self.active.get(tool_call_id)
253
+ if record is None:
254
+ return None
255
+
256
+ content = tool_message.content
257
+ if isinstance(content, list):
258
+ # Some tool messages may return list segments; join them for analysis.
259
+ joined = []
260
+ for item in content:
261
+ if isinstance(item, str):
262
+ joined.append(item)
263
+ else:
264
+ joined.append(str(item))
265
+ content_text = "\n".join(joined)
266
+ else:
267
+ content_text = str(content) if content is not None else ""
268
+
269
+ if getattr(
270
+ tool_message, "status", "success"
271
+ ) != "success" or content_text.lower().startswith("error"):
272
+ record.status = "error"
273
+ record.error = content_text
274
+ self._finalize(record)
275
+ return record
276
+
277
+ record.status = "success"
278
+
279
+ if record.tool_name == "read_file":
280
+ record.read_output = content_text
281
+ lines = _count_lines(content_text)
282
+ record.metrics.lines_read = lines
283
+ offset = record.args.get("offset")
284
+ limit = record.args.get("limit")
285
+ if isinstance(offset, int):
286
+ record.metrics.start_line = offset + 1
287
+ if lines:
288
+ record.metrics.end_line = offset + lines
289
+ elif lines:
290
+ record.metrics.start_line = 1
291
+ record.metrics.end_line = lines
292
+ if isinstance(limit, int) and lines > limit:
293
+ record.metrics.end_line = (record.metrics.start_line or 1) + limit - 1
294
+ else:
295
+ self._populate_after_content(record)
296
+ if record.after_content is None:
297
+ record.status = "error"
298
+ record.error = "Could not read updated file content."
299
+ self._finalize(record)
300
+ return record
301
+ record.metrics.lines_written = _count_lines(record.after_content)
302
+ before_lines = _count_lines(record.before_content or "")
303
+ diff = compute_unified_diff(
304
+ record.before_content or "",
305
+ record.after_content,
306
+ record.display_path,
307
+ max_lines=None,
308
+ )
309
+ record.diff = diff
310
+ if diff:
311
+ additions = sum(
312
+ 1
313
+ for line in diff.splitlines()
314
+ if line.startswith("+") and not line.startswith("+++")
315
+ )
316
+ deletions = sum(
317
+ 1
318
+ for line in diff.splitlines()
319
+ if line.startswith("-") and not line.startswith("---")
320
+ )
321
+ record.metrics.lines_added = additions
322
+ record.metrics.lines_removed = deletions
323
+ elif record.tool_name == "write_file" and (record.before_content or "") == "":
324
+ record.metrics.lines_added = record.metrics.lines_written
325
+ record.metrics.bytes_written = len(record.after_content.encode("utf-8"))
326
+ if record.diff is None and (record.before_content or "") != record.after_content:
327
+ record.diff = compute_unified_diff(
328
+ record.before_content or "",
329
+ record.after_content,
330
+ record.display_path,
331
+ max_lines=None,
332
+ )
333
+ if record.diff is None and before_lines != record.metrics.lines_written:
334
+ record.metrics.lines_added = max(record.metrics.lines_written - before_lines, 0)
335
+
336
+ self._finalize(record)
337
+ return record
338
+
339
+ def _populate_after_content(self, record: FileOperationRecord) -> None:
340
+ if record.physical_path is None:
341
+ record.after_content = None
342
+ return
343
+ record.after_content = _safe_read(record.physical_path)
344
+
345
+ def _finalize(self, record: FileOperationRecord) -> None:
346
+ self.completed.append(record)
347
+ self.active.pop(record.tool_call_id, None)
@@ -0,0 +1,249 @@
1
+ """Input handling, completers, and prompt session for the CLI."""
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from prompt_toolkit import PromptSession
8
+ from prompt_toolkit.completion import (
9
+ Completer,
10
+ Completion,
11
+ PathCompleter,
12
+ WordCompleter,
13
+ merge_completers,
14
+ )
15
+ from prompt_toolkit.document import Document
16
+ from prompt_toolkit.enums import EditingMode
17
+ from prompt_toolkit.formatted_text import HTML
18
+ from prompt_toolkit.key_binding import KeyBindings
19
+
20
+ from .config import COLORS, COMMANDS, COMMON_BASH_COMMANDS, SessionState, console
21
+
22
+
23
+ class FilePathCompleter(Completer):
24
+ """File path completer that triggers on @ symbol with case-insensitive matching."""
25
+
26
+ def __init__(self):
27
+ self.path_completer = PathCompleter(expanduser=True)
28
+
29
+ def get_completions(self, document, complete_event):
30
+ """Get file path completions when @ is detected."""
31
+ text = document.text_before_cursor
32
+
33
+ # Check if we're after an @ symbol
34
+ if "@" in text:
35
+ # Get the part after the last @
36
+ parts = text.split("@")
37
+ if len(parts) >= 2:
38
+ after_at = parts[-1]
39
+ # Create a document for just the path part
40
+ path_doc = Document(after_at, len(after_at))
41
+
42
+ # Get all completions from PathCompleter
43
+ all_completions = list(
44
+ self.path_completer.get_completions(path_doc, complete_event)
45
+ )
46
+
47
+ # If user has typed something, filter case-insensitively
48
+ if after_at.strip():
49
+ # Extract just the filename part for matching (not the full path)
50
+ search_parts = after_at.split("/")
51
+ search_term = search_parts[-1].lower() if search_parts else ""
52
+
53
+ # Filter completions case-insensitively
54
+ filtered_completions = [
55
+ c for c in all_completions if search_term in c.text.lower()
56
+ ]
57
+ else:
58
+ # No search term, show all completions
59
+ filtered_completions = all_completions
60
+
61
+ # Yield filtered completions
62
+ for completion in filtered_completions:
63
+ yield Completion(
64
+ text=completion.text,
65
+ start_position=completion.start_position,
66
+ display=completion.display,
67
+ display_meta=completion.display_meta,
68
+ style=completion.style,
69
+ )
70
+
71
+
72
+ class CommandCompleter(Completer):
73
+ """Command completer for / commands."""
74
+
75
+ def __init__(self):
76
+ self.word_completer = WordCompleter(
77
+ list(COMMANDS.keys()),
78
+ meta_dict=COMMANDS,
79
+ sentence=True,
80
+ ignore_case=True,
81
+ )
82
+
83
+ def get_completions(self, document, complete_event):
84
+ """Get command completions when / is at the start."""
85
+ text = document.text
86
+
87
+ # Only complete if line starts with /
88
+ if text.startswith("/"):
89
+ # Remove / for word completion
90
+ cmd_text = text[1:]
91
+ adjusted_doc = Document(
92
+ cmd_text, document.cursor_position - 1 if document.cursor_position > 0 else 0
93
+ )
94
+
95
+ for completion in self.word_completer.get_completions(adjusted_doc, complete_event):
96
+ yield completion
97
+
98
+
99
+ class BashCompleter(Completer):
100
+ """Bash command completer for ! commands."""
101
+
102
+ def __init__(self):
103
+ self.word_completer = WordCompleter(
104
+ list(COMMON_BASH_COMMANDS.keys()),
105
+ meta_dict=COMMON_BASH_COMMANDS,
106
+ sentence=True,
107
+ ignore_case=True,
108
+ )
109
+
110
+ def get_completions(self, document, complete_event):
111
+ """Get bash command completions when ! is at the start."""
112
+ text = document.text
113
+
114
+ # Only complete if line starts with !
115
+ if text.startswith("!"):
116
+ # Remove ! for word completion
117
+ cmd_text = text[1:]
118
+ adjusted_doc = Document(
119
+ cmd_text, document.cursor_position - 1 if document.cursor_position > 0 else 0
120
+ )
121
+
122
+ for completion in self.word_completer.get_completions(adjusted_doc, complete_event):
123
+ yield completion
124
+
125
+
126
+ def parse_file_mentions(text: str) -> tuple[str, list[Path]]:
127
+ """Extract @file mentions and return cleaned text with resolved file paths."""
128
+ pattern = r"@((?:[^\s@]|(?<=\\)\s)+)" # Match @filename, allowing escaped spaces
129
+ matches = re.findall(pattern, text)
130
+
131
+ files = []
132
+ for match in matches:
133
+ # Remove escape characters
134
+ clean_path = match.replace("\\ ", " ")
135
+ path = Path(clean_path).expanduser()
136
+
137
+ # Try to resolve relative to cwd
138
+ if not path.is_absolute():
139
+ path = Path.cwd() / path
140
+
141
+ try:
142
+ path = path.resolve()
143
+ if path.exists() and path.is_file():
144
+ files.append(path)
145
+ else:
146
+ console.print(f"[yellow]Warning: File not found: {match}[/yellow]")
147
+ except Exception as e:
148
+ console.print(f"[yellow]Warning: Invalid path {match}: {e}[/yellow]")
149
+
150
+ return text, files
151
+
152
+
153
+ def get_bottom_toolbar(session_state: SessionState):
154
+ """Return toolbar function that shows auto-approve status."""
155
+
156
+ def toolbar():
157
+ if session_state.auto_approve:
158
+ # Green background when auto-approve is ON
159
+ return [("class:toolbar-green", "auto-accept ON (CTRL+T to toggle)")]
160
+ # Orange background when manual accept (auto-approve OFF)
161
+ return [("class:toolbar-orange", "manual accept (CTRL+T to toggle)")]
162
+
163
+ return toolbar
164
+
165
+
166
+ def create_prompt_session(assistant_id: str, session_state: SessionState) -> PromptSession:
167
+ """Create a configured PromptSession with all features."""
168
+ # Set default editor if not already set
169
+ if "EDITOR" not in os.environ:
170
+ os.environ["EDITOR"] = "nano"
171
+
172
+ # Create key bindings
173
+ kb = KeyBindings()
174
+
175
+ # Bind Ctrl+T to toggle auto-approve
176
+ @kb.add("c-t")
177
+ def _(event):
178
+ """Toggle auto-approve mode."""
179
+ session_state.toggle_auto_approve()
180
+ # Force UI refresh to update toolbar
181
+ event.app.invalidate()
182
+
183
+ # Bind regular Enter to submit (intuitive behavior)
184
+ @kb.add("enter")
185
+ def _(event):
186
+ """Enter submits the input, unless completion menu is active."""
187
+ buffer = event.current_buffer
188
+
189
+ # If completion menu is showing, apply the current completion
190
+ if buffer.complete_state:
191
+ # Get the current completion (the highlighted one)
192
+ current_completion = buffer.complete_state.current_completion
193
+
194
+ # If no completion is selected (user hasn't navigated), select and apply the first one
195
+ if not current_completion and buffer.complete_state.completions:
196
+ # Move to the first completion
197
+ buffer.complete_next()
198
+ # Now apply it
199
+ buffer.apply_completion(buffer.complete_state.current_completion)
200
+ elif current_completion:
201
+ # Apply the already-selected completion
202
+ buffer.apply_completion(current_completion)
203
+ else:
204
+ # No completions available, close menu
205
+ buffer.complete_state = None
206
+ # Don't submit if buffer is empty or only whitespace
207
+ elif buffer.text.strip():
208
+ # Normal submit
209
+ buffer.validate_and_handle()
210
+ # If empty, do nothing (don't submit)
211
+
212
+ # Alt+Enter for newlines (press ESC then Enter, or Option+Enter on Mac)
213
+ @kb.add("escape", "enter")
214
+ def _(event):
215
+ """Alt+Enter inserts a newline for multi-line input."""
216
+ event.current_buffer.insert_text("\n")
217
+
218
+ # Ctrl+E to open in external editor
219
+ @kb.add("c-e")
220
+ def _(event):
221
+ """Open the current input in an external editor (nano by default)."""
222
+ event.current_buffer.open_in_editor()
223
+
224
+ from prompt_toolkit.styles import Style
225
+
226
+ # Define styles for the toolbar with full-width background colors
227
+ toolbar_style = Style.from_dict(
228
+ {
229
+ "bottom-toolbar": "noreverse", # Disable default reverse video
230
+ "toolbar-green": "bg:#10b981 #000000", # Green for auto-accept ON
231
+ "toolbar-orange": "bg:#f59e0b #000000", # Orange for manual accept
232
+ }
233
+ )
234
+
235
+ # Create the session
236
+ session = PromptSession(
237
+ message=HTML(f'<style fg="{COLORS["user"]}">></style> '),
238
+ multiline=True, # Keep multiline support but Enter submits
239
+ key_bindings=kb,
240
+ completer=merge_completers([CommandCompleter(), BashCompleter(), FilePathCompleter()]),
241
+ editing_mode=EditingMode.EMACS,
242
+ complete_while_typing=True, # Show completions as you type
243
+ mouse_support=False,
244
+ enable_open_in_editor=True, # Allow Ctrl+X Ctrl+E to open external editor
245
+ bottom_toolbar=get_bottom_toolbar(session_state), # Persistent status bar at bottom
246
+ style=toolbar_style, # Apply toolbar styling
247
+ )
248
+
249
+ return session