atuinsh/desktop/main 735k tokens More Tools
```
├── .cursor/
   ├── rules/
      ├── avoid-unnecessary-comments.mdc (600 tokens)
      ├── component-creation.mdc (2000 tokens)
      ├── conventional-commits.mdc (600 tokens)
      ├── rule-storage.mdc (400 tokens)
      ├── template-system.mdc (1500 tokens)
├── .envrc (omitted)
├── .github/
   ├── ISSUE_TEMPLATE/
      ├── bug_report.md (200 tokens)
      ├── feature_request.md (300 tokens)
   ├── dependabot.yml (100 tokens)
   ├── pull_request_template.md (100 tokens)
   ├── workflows/
      ├── atuin-run-release.yaml (300 tokens)
      ├── cla.yaml (500 tokens)
      ├── claude.yml (400 tokens)
      ├── github-releases-to-discord.yaml (100 tokens)
      ├── rust.yaml (500 tokens)
      ├── ssh-integration.yaml (600 tokens)
      ├── tauri-edge.yaml (2.5k tokens)
      ├── tauri-release.yaml (1500 tokens)
      ├── ts-tests.yaml (100 tokens)
      ├── tsc-check.yaml (100 tokens)
├── .gitignore (100 tokens)
├── .prettierrc
├── .sourcegraph/
   ├── memory.md (400 tokens)
├── .tool-versions
├── .vscode/
   ├── settings.json
├── .zed/
   ├── settings.json
├── AGENT.md (800 tokens)
├── CLA.md (1100 tokens)
├── CLAUDE.md (2.5k tokens)
├── CONTRIBUTING.md (700 tokens)
├── Cargo.lock (omitted)
├── Cargo.toml (400 tokens)
├── LICENSE (omitted)
├── README.md (1100 tokens)
├── backend/
   ├── .cargo/
      ├── config.toml
   ├── .gitignore
   ├── AtuinDesktop.desktop
   ├── Cargo.toml (700 tokens)
   ├── build.rs (100 tokens)
   ├── capabilities/
      ├── migrated.json (400 tokens)
   ├── icons/
      ├── 128x128.png
      ├── 128x128@2x.png
      ├── 32x32.png
      ├── Square107x107Logo.png
      ├── Square142x142Logo.png
      ├── Square150x150Logo.png
      ├── Square284x284Logo.png
      ├── Square30x30Logo.png
      ├── Square310x310Logo.png
      ├── Square44x44Logo.png
      ├── Square71x71Logo.png
      ├── Square89x89Logo.png
      ├── StoreLogo.png
      ├── dmg-bg.png
      ├── icon.icns
      ├── icon.ico
      ├── icon.png
   ├── migrations/
      ├── ai/
         ├── 20260114192057_create_ai_sessions.down.sql
         ├── 20260114192057_create_ai_sessions.up.sql
         ├── 20260122172003_add_version_to_ai_sessions.down.sql
         ├── 20260122172003_add_version_to_ai_sessions.up.sql
      ├── context/
         ├── 20251110163938_create_context.down.sql
         ├── 20251110163938_create_context.up.sql (100 tokens)
      ├── runbooks/
         ├── 10_create_operation_log.up.sql
         ├── 1_create_initial_tables.up.sql
         ├── 20250311174456_make_legacy_workspaces.down.sql
         ├── 20250311174456_make_legacy_workspaces.up.sql
         ├── 20250311194625_add_new_workspaces_table.down.sql
         ├── 20250311194625_add_new_workspaces_table.up.sql
         ├── 20250709152124_add_workspace_type_to_workspaces.down.sql
         ├── 20250709152124_add_workspace_type_to_workspaces.up.sql
         ├── 20250709190618_make_org_workspaces_online.down.sql
         ├── 20250709190618_make_org_workspaces_online.up.sql
         ├── 20250722202626_add_backing_dir_to_workspaces.down.sql
         ├── 20250722202626_add_backing_dir_to_workspaces.up.sql
         ├── 20251010195720_add_saved_blocks.down.sql
         ├── 20251010195720_add_saved_blocks.up.sql
         ├── 20251010200940_add_name_to_saved_blocks.down.sql
         ├── 20251010200940_add_name_to_saved_blocks.up.sql
         ├── 20251011000000_create_block_local_state.down.sql
         ├── 20251011000000_create_block_local_state.up.sql (100 tokens)
         ├── 2_create_workspace_table.up.sql
         ├── 3_add_workspace_id_to_runbooks.up.sql
         ├── 4_add_workspace_directory.up.sql
         ├── 5_add_ydoc_to_runbooks.up.sql
         ├── 6_create_snapshots_table.up.sql
         ├── 7_add_source_info_to_runbooks.up.sql
         ├── 8_add_remote_info_to_runbooks.up.sql
         ├── 9_add_viewed_at_to_runbook.up.sql
      ├── shared_state/
         ├── 20250310191220_create_shared_state_docs.down.sql
         ├── 20250310191220_create_shared_state_docs.up.sql
         ├── 20250310191947_create_optimistic_updates.down.sql
         ├── 20250310191947_create_optimistic_updates.up.sql (100 tokens)
   ├── rust-toolchain.toml
   ├── src/
      ├── advanced_settings.rs (200 tokens)
      ├── ai/
         ├── client.rs (700 tokens)
         ├── fsm.rs (6.4k tokens)
         ├── manager.rs (2.2k tokens)
         ├── mod.rs
         ├── prompts.rs (500 tokens)
         ├── session.rs (5.2k tokens)
         ├── storage.rs (1200 tokens)
         ├── system_prompt.minijinja.txt (1600 tokens)
         ├── tools.rs (1900 tokens)
         ├── types.rs (2.4k tokens)
      ├── blocks/
         ├── mod.rs
         ├── postgres/
            ├── command.rs (600 tokens)
            ├── decode.rs (1100 tokens)
            ├── mod.rs
         ├── sqlite_context_storage.rs (500 tokens)
      ├── commands/
         ├── ai.rs (1300 tokens)
         ├── audio.rs (700 tokens)
         ├── blocks.rs (6.2k tokens)
         ├── dependency.rs (100 tokens)
         ├── events.rs (400 tokens)
         ├── exec_log.rs (300 tokens)
         ├── feedback.rs (300 tokens)
         ├── llmtools.rs (300 tokens)
         ├── mod.rs (100 tokens)
         ├── pty_store.rs (100 tokens)
         ├── secrets.rs (200 tokens)
         ├── ssh.rs (700 tokens)
         ├── stats.rs (300 tokens)
         ├── template.rs (300 tokens)
         ├── updates.rs (700 tokens)
         ├── workflow/
            ├── mod.rs
            ├── serial.rs (200 tokens)
         ├── workspaces.rs (1800 tokens)
      ├── db.rs (2.5k tokens)
      ├── dotfiles/
         ├── aliases.rs (500 tokens)
         ├── mod.rs
         ├── vars.rs (300 tokens)
      ├── file.rs (400 tokens)
      ├── font.rs (100 tokens)
      ├── install.rs (500 tokens)
      ├── kv.rs (200 tokens)
      ├── llmtools_window.rs (200 tokens)
      ├── logs.rs (200 tokens)
      ├── main.rs (5.1k tokens)
      ├── main_window.rs (1600 tokens)
      ├── menu.rs (2.4k tokens)
      ├── run/
         ├── mod.rs
         ├── pty.rs (300 tokens)
         ├── shell.rs (100 tokens)
      ├── runbooks/
         ├── mod.rs
         ├── runbook.rs (500 tokens)
         ├── ydoc.rs (300 tokens)
      ├── secret_cache.rs (1900 tokens)
      ├── shared_state.rs (2.4k tokens)
      ├── shellcheck.rs (500 tokens)
      ├── sqlite.rs (700 tokens)
      ├── state.rs (2.1k tokens)
      ├── stats/
         ├── calc.rs (1000 tokens)
         ├── mod.rs
      ├── store.rs
      ├── util.rs (1000 tokens)
      ├── workspaces/
         ├── fs_ops.rs (4.7k tokens)
         ├── hash_history.rs (400 tokens)
         ├── manager.rs (14k tokens)
         ├── mod.rs
         ├── offline_runbook.rs (300 tokens)
         ├── state.rs (1700 tokens)
         ├── state/
            ├── test_canonical_hash.rs (1100 tokens)
         ├── workspace.rs (3.1k tokens)
   ├── tauri.conf.json (400 tokens)
   ├── tauri.linux.conf.json
├── bun.lock (omitted)
├── cliff.toml (1100 tokens)
├── components.json (100 tokens)
├── crates/
   ├── atuin-desktop-runtime/
      ├── Cargo.toml (300 tokens)
      ├── bindings/
         ├── BlockErrorData.ts
         ├── BlockFinishedData.ts
         ├── BlockLifecycleEvent.ts (100 tokens)
         ├── BlockOutput.ts (100 tokens)
         ├── ClientPrompt.ts (100 tokens)
         ├── ClientPromptResult.ts (100 tokens)
         ├── DocumentBridgeMessage.ts (200 tokens)
         ├── ExecutionStatus.ts (100 tokens)
         ├── GCEvent.ts (400 tokens)
         ├── HttpResponse.ts (100 tokens)
         ├── KubernetesColumn.ts
         ├── PrometheusQueryResult.ts (100 tokens)
         ├── PrometheusSeries.ts
         ├── PrometheusTimeRange.ts
         ├── PromptIcon.ts (100 tokens)
         ├── PromptInput.ts (100 tokens)
         ├── PromptOption.ts (100 tokens)
         ├── PromptOptionColor.ts (100 tokens)
         ├── PromptOptionVariant.ts (100 tokens)
         ├── PtyMetadata.ts (100 tokens)
         ├── ResolvedContext.ts (100 tokens)
         ├── SqlBlockExecutionResult.ts (100 tokens)
         ├── SqlQueryResult.ts (100 tokens)
         ├── SqlStatementResult.ts
         ├── serde_json/
            ├── JsonValue.ts
      ├── migrations/
         ├── exec_log/
            ├── 20250227161236_create_blocks.down.sql
            ├── 20250227161236_create_blocks.up.sql (100 tokens)
            ├── 20250227161259_create_exec_log.down.sql
            ├── 20250227161259_create_exec_log.up.sql (100 tokens)
            ├── 20250228162028_add_block_type.down.sql
            ├── 20250228162028_add_block_type.up.sql
            ├── 20250228162133_rename_exit_output.down.sql
            ├── 20250228162133_rename_exit_output.up.sql
      ├── src/
         ├── blocks/
            ├── clickhouse.rs (1700 tokens)
            ├── directory.rs (1800 tokens)
            ├── dropdown.rs (5.8k tokens)
            ├── editor.rs (600 tokens)
            ├── environment.rs (1700 tokens)
            ├── host.rs (1500 tokens)
            ├── http.rs (6.5k tokens)
            ├── kubernetes.rs (5.7k tokens)
            ├── local_directory.rs (1200 tokens)
            ├── local_var.rs (1000 tokens)
            ├── markdown_render.rs (500 tokens)
            ├── mod.rs (4.1k tokens)
            ├── mysql.rs (1500 tokens)
            ├── mysql/
               ├── decode.rs (900 tokens)
            ├── pause.rs (1500 tokens)
            ├── postgres.rs (2k tokens)
            ├── prometheus.rs (3.2k tokens)
            ├── query_block.rs (1800 tokens)
            ├── script.rs (9.2k tokens)
            ├── sql_block.rs (2.6k tokens)
            ├── sqlite.rs (4.5k tokens)
            ├── ssh_connect.rs (5.2k tokens)
            ├── sub_runbook.rs (14.1k tokens)
            ├── terminal.rs (4.5k tokens)
            ├── terminal_integration_test.rs (3.5k tokens)
            ├── var.rs (1700 tokens)
            ├── var_display.rs (200 tokens)
         ├── client/
            ├── bridge.rs (1000 tokens)
            ├── hub.rs (2.2k tokens)
            ├── local.rs (300 tokens)
            ├── message_channel.rs (200 tokens)
            ├── mod.rs (200 tokens)
            ├── runbook_loader.rs (900 tokens)
         ├── context/
            ├── block_context.rs (2.6k tokens)
            ├── fs_var.rs (2.6k tokens)
            ├── mod.rs (5.6k tokens)
            ├── resolution.rs (7.3k tokens)
            ├── storage.rs (200 tokens)
         ├── document/
            ├── actor.rs (7.2k tokens)
            ├── mod.rs (5.3k tokens)
         ├── events/
            ├── mod.rs (900 tokens)
         ├── exec_log.rs (1800 tokens)
         ├── execution/
            ├── mod.rs (4.5k tokens)
         ├── lib.rs (900 tokens)
         ├── pty/
            ├── mod.rs (1200 tokens)
            ├── pty_store.rs (1400 tokens)
         ├── ssh/
            ├── integration_tests.rs (4.3k tokens)
            ├── mod.rs (200 tokens)
            ├── pool.rs (1000 tokens)
            ├── session.rs (14.5k tokens)
            ├── ssh_env.rs (600 tokens)
            ├── ssh_pool.rs (6.8k tokens)
         ├── templates/
            ├── mod.rs (2.3k tokens)
         ├── workflow/
            ├── dependency.rs (600 tokens)
            ├── event.rs (100 tokens)
            ├── executor.rs (900 tokens)
            ├── mod.rs (100 tokens)
            ├── serial.rs (500 tokens)
   ├── atuin-run/
      ├── Cargo.toml (100 tokens)
      ├── src/
         ├── app.rs (200 tokens)
         ├── executor.rs (5.9k tokens)
         ├── main.rs (100 tokens)
         ├── runbooks.rs (500 tokens)
         ├── runtime.rs (1600 tokens)
         ├── ui.rs
         ├── ui/
            ├── manager.rs (2.4k tokens)
            ├── renderer.rs (300 tokens)
            ├── streaming.rs (1000 tokens)
            ├── terminal_viewport.rs (1800 tokens)
            ├── viewport.rs (1700 tokens)
├── dev/
   ├── docs/
      ├── BLOCK_TYPES.md (600 tokens)
      ├── DATABASE_MIGRATIONS.md (700 tokens)
      ├── EXECUTION_SYSTEM.md (10.7k tokens)
      ├── GRAND_CENTRAL_EVENTS.md (2.6k tokens)
├── docker/
   ├── ssh-test/
      ├── .gitignore
      ├── Dockerfile (100 tokens)
      ├── docker-compose.yml (100 tokens)
      ├── setup-keys.sh (900 tokens)
      ├── sshd_config (200 tokens)
├── docs/
   ├── .gitignore
   ├── docs/
      ├── ai-assistant.md (1000 tokens)
      ├── blocks/
         ├── databases/
            ├── clickhouse.md (500 tokens)
            ├── index.md (600 tokens)
            ├── mysql.md (400 tokens)
            ├── postgresql.md (400 tokens)
            ├── sqlite.md (500 tokens)
         ├── executable/
            ├── README.md (600 tokens)
            ├── directory.md (200 tokens)
            ├── dropdown.md (400 tokens)
            ├── env.md
            ├── kubernetes.md (700 tokens)
            ├── markdown-render.md (400 tokens)
            ├── pause.md (400 tokens)
            ├── script.md (1100 tokens)
            ├── sub-runbook.md (300 tokens)
            ├── terminal.md (1000 tokens)
            ├── variable.md (600 tokens)
         ├── index.md (1300 tokens)
         ├── monitoring/
            ├── README.md (100 tokens)
            ├── prometheus.md (400 tokens)
         ├── network/
            ├── README.md (200 tokens)
            ├── http.md (600 tokens)
            ├── ssh.md (700 tokens)
      ├── getting-started.md (800 tokens)
      ├── hub/
         ├── collaborative-editing.md (400 tokens)
         ├── getting-started.md (200 tokens)
         ├── sharing-runbooks.md (200 tokens)
      ├── images/
         ├── CleanShot 2025-02-06 at 22.42.31@2x.png
         ├── CleanShot 2025-02-06 at 22.56.05@2x.png
         ├── CleanShot 2025-02-06 at 23.25.35@2x.png
         ├── CleanShot 2025-02-06 at 23.26.11@2x.png
         ├── CleanShot 2025-02-06 at 23.26.45@2x.png
         ├── CleanShot 2025-02-06 at 23.41.02@2x.png
         ├── CleanShot 2025-02-06 at 23.45.00@2x.png
         ├── CleanShot 2025-02-11 at 14.51.19@2x.png
         ├── CleanShot 2025-02-11 at 14.59.01@2x.png
         ├── CleanShot 2025-04-29 at 00.51.47@2x.png
         ├── CleanShot 2025-04-29 at 00.56.18@2x.png
         ├── CleanShot 2025-04-29 at 15.30.07@2x.png
         ├── atuin-desktop-ss-dark.png
         ├── atuin-desktop-ss-light.png
         ├── collab-dark.png
         ├── collab-light.png
         ├── create-org-dark.png
         ├── create-org-light.png
         ├── desktop-login-dark.png
         ├── desktop-login-light.png
         ├── http-dark.png
         ├── http-light.png
         ├── hub-orgs.png
         ├── icon.png
         ├── icon.svg (2.4k tokens)
         ├── image (1).png
         ├── image (2).png
         ├── image (3).png
         ├── image (4).png
         ├── image (5).png
         ├── image (6).png
         ├── image (7).png
         ├── image (8).png
         ├── image.png
         ├── runbook-settings-dark.png
         ├── runbook-settings-light.png
         ├── script-basic-dark.png
         ├── script-basic-light.png
         ├── script-dark.png
         ├── script-light.png
         ├── secrets-dark.png
         ├── secrets-light.png
         ├── serial-dark.png
         ├── serial-light.png
         ├── ssh-1-dark.png
         ├── ssh-1-light.png
         ├── ssh-2-dark.png
         ├── ssh-2-light.png
         ├── templating-dark.png
         ├── templating-light.png
         ├── terminal-dark.png
         ├── terminal-light.png
         ├── workspaces-dark.png
         ├── workspaces-light.png
         ├── workspaces.png
      ├── index.md (700 tokens)
      ├── secrets.md (100 tokens)
      ├── shell-environment.md (1300 tokens)
      ├── style.css
      ├── templating.md (1600 tokens)
      ├── troubleshooting.md (800 tokens)
      ├── workspaces.md (400 tokens)
   ├── mkdocs.yml (600 tokens)
   ├── pyproject.toml (100 tokens)
   ├── uv.lock (omitted)
├── icon.png
├── index.html (200 tokens)
├── llmtools.html (100 tokens)
├── package.json (1100 tokens)
├── postcss.config.js
├── public/
   ├── tauri.svg (500 tokens)
   ├── vite.svg (300 tokens)
├── resources/
   ├── images/
      ├── ghost-turtle.png
   ├── sounds/
      ├── achievement.ogg
      ├── cheerful.ogg
      ├── clearly.ogg
      ├── closure.ogg
      ├── confident.ogg
      ├── done.ogg
      ├── flat.ogg
      ├── glad_to_know.ogg
      ├── gracefully.ogg
      ├── intuition.ogg
      ├── jokingly.ogg
      ├── just_maybe.ogg
      ├── just_saying.ogg
      ├── light.ogg
      ├── muffled.ogg
      ├── not_good.ogg
      ├── oh_no.ogg
      ├── out_of_nowhere.ogg
      ├── piece_of_cake.ogg
      ├── pretty_good.ogg
      ├── sharp.ogg
      ├── slick.ogg
      ├── swift.ogg
      ├── tada.ogg
      ├── that_was_quick.ogg
      ├── to_the_point.ogg
      ├── unexpected.ogg
      ├── when.ogg
   ├── welcome/
      ├── Welcome to Atuin.atrb (3.7k tokens)
      ├── atuin.toml
      ├── examples/
         ├── Atuin Desktop Release.atrb (3k tokens)
         ├── Fetch Release Notes.atrb (1000 tokens)
├── runbooks/
   ├── Atuin Desktop Development.atrb (1100 tokens)
   ├── Run Rust tests.atrb (700 tokens)
   ├── atuin.toml
├── script/
   ├── check-tauri-versions (1400 tokens)
   ├── dev (600 tokens)
   ├── tauri (400 tokens)
├── src-llmtools/
   ├── App.tsx (700 tokens)
   ├── components/
      ├── SessionDetail.tsx (2.6k tokens)
      ├── SessionList.tsx (300 tokens)
   ├── main.tsx (100 tokens)
   ├── types.ts (100 tokens)
├── src/
   ├── api/
      ├── api.ts
      ├── auth.ts (700 tokens)
      ├── collaborations.ts (300 tokens)
      ├── http.ts (600 tokens)
      ├── runbooks.ts (600 tokens)
      ├── user.ts (100 tokens)
      ├── workspaces.ts (600 tokens)
   ├── assets/
      ├── cli_demo.mp4
      ├── fonts/
         ├── FiraCode.ttf
      ├── icon.svg (2.4k tokens)
      ├── logo-light.svg (2.8k tokens)
      ├── react.svg (800 tokens)
   ├── atuin_env.ts (300 tokens)
   ├── components/
      ├── Account/
         ├── AccountModal.tsx (400 tokens)
      ├── ActivityGraph/
         ├── ActivityGraph.tsx (200 tokens)
      ├── Button.tsx (100 tokens)
      ├── CodeBlock.tsx (200 tokens)
      ├── ColorAvatar.tsx (300 tokens)
      ├── CommandMenu/
         ├── CommandMenu.tsx (1100 tokens)
         ├── RunbookSearchIndex.tsx (200 tokens)
         ├── component-files.ts (500 tokens)
         ├── data.ts (700 tokens)
         ├── mock-data.ts (29.6k tokens)
         ├── popover.tsx (300 tokens)
         ├── regex-constants.ts
         ├── use-update-effect.ts (100 tokens)
      ├── CommandPalette/
         ├── CommandPalette.tsx (1100 tokens)
      ├── DesktopConnect/
         ├── DesktopConnect.tsx (500 tokens)
      ├── Dialogs/
         ├── DialogManager.tsx (700 tokens)
         ├── dialog.tsx (500 tokens)
      ├── Drawer.tsx (100 tokens)
      ├── EditableHeading/
         ├── index.tsx (400 tokens)
      ├── HistoryList.tsx (200 tokens)
      ├── HistorySearch.tsx (300 tokens)
      ├── MaskedInput/
         ├── MaskedInput.tsx (300 tokens)
      ├── Onboarding/
         ├── Onboarding.tsx (1200 tokens)
         ├── horizontal-steps.tsx (1800 tokens)
      ├── Settings/
         ├── Settings.tsx (9k tokens)
      ├── TopCommands/
         ├── TopCommands.tsx (300 tokens)
      ├── common/
         ├── ResizeHandle.tsx (700 tokens)
      ├── history/
         ├── HistoryInspect.tsx (200 tokens)
         ├── HistoryRow.tsx (800 tokens)
         ├── InstallCLI.tsx (500 tokens)
         ├── Stats.tsx (900 tokens)
      ├── home/
         ├── QuickActions.tsx
      ├── relative_time.tsx (100 tokens)
      ├── runbooks/
         ├── List/
            ├── CollaborationsModal.tsx (1700 tokens)
            ├── ConvertWorkspaceDialog.tsx (3.9k tokens)
            ├── ExportAsRunbookDropdown.tsx (500 tokens)
            ├── List.tsx (5k tokens)
            ├── PendingInvitations.tsx (200 tokens)
            ├── TreeView.tsx (1700 tokens)
            ├── TreeView/
               ├── FolderTreeRow.tsx (600 tokens)
               ├── InlineInput.tsx (200 tokens)
               ├── RunbookTreeRow.tsx (1500 tokens)
            ├── Workspace.tsx (7.2k tokens)
            ├── menus.ts (2.3k tokens)
         ├── TopBar/
            ├── TagSelector.tsx (1400 tokens)
            ├── TopBar.tsx (2.5k tokens)
         ├── editor/
            ├── AIGeneratePopup.tsx (200 tokens)
            ├── Editor.tsx (5.2k tokens)
            ├── EditorAIFeatures.tsx (1800 tokens)
            ├── blocks/
               ├── Clickhouse/
                  ├── Clickhouse.tsx (1300 tokens)
                  ├── schema.ts (100 tokens)
               ├── Directory/
                  ├── index.tsx (900 tokens)
               ├── Dropdown/
                  ├── Dropdown.tsx (4.9k tokens)
                  ├── parseOption.test.ts (1000 tokens)
                  ├── parseOption.ts (200 tokens)
               ├── Editor/
                  ├── Editor.tsx (3.2k tokens)
               ├── Env/
                  ├── index.tsx (900 tokens)
               ├── HorizontalRule/
                  ├── index.tsx (300 tokens)
               ├── Host/
                  ├── HostSelect.tsx (600 tokens)
                  ├── index.ts
               ├── LocalVar/
                  ├── index.tsx (1300 tokens)
               ├── MarkdownRender/
                  ├── index.test.ts (300 tokens)
                  ├── index.tsx (2.3k tokens)
               ├── MySQL/
                  ├── MySQL.tsx (1500 tokens)
               ├── Pause/
                  ├── index.tsx (1300 tokens)
               ├── Postgres/
                  ├── Postgres.tsx (1300 tokens)
                  ├── schema.ts (100 tokens)
               ├── Prometheus/
                  ├── Prometheus.tsx (3.4k tokens)
                  ├── lineChart.tsx (1200 tokens)
               ├── SQLite/
                  ├── SQLite.tsx (1300 tokens)
                  ├── schema.ts (100 tokens)
               ├── Script/
                  ├── Script.tsx (4.6k tokens)
               ├── SubRunbook/
                  ├── SubRunbook.tsx (6.6k tokens)
                  ├── index.tsx
               ├── TableOfContents/
                  ├── index.tsx (1100 tokens)
               ├── Var/
                  ├── index.tsx (900 tokens)
               ├── VarDisplay/
                  ├── index.tsx (900 tokens)
               ├── ssh/
                  ├── SshConnect.tsx (4.8k tokens)
                  ├── ssh.ts (200 tokens)
            ├── components/
               ├── Markdown.tsx (200 tokens)
               ├── RunbookLinkMenu.tsx (600 tokens)
               ├── Xterm.tsx (1100 tokens)
            ├── create_editor.ts (1100 tokens)
            ├── hooks/
               ├── useAIInlineGeneration.ts (6.7k tokens)
               ├── useAIKeyboardShortcuts.ts (500 tokens)
            ├── index.css (300 tokens)
            ├── inline/
               ├── RunbookLink.tsx (400 tokens)
            ├── ui/
               ├── AIAssistant.tsx (5.4k tokens)
               ├── AIFocusOverlay.tsx (1500 tokens)
               ├── AIHint.tsx (1200 tokens)
               ├── AILoadingBlock.tsx (800 tokens)
               ├── AIPopupBase.tsx (1600 tokens)
               ├── CopyBlockItem.tsx (200 tokens)
               ├── DeleteBlockItem.tsx (200 tokens)
               ├── DuplicateBlockItem.tsx (200 tokens)
               ├── RunbookLinkPopup.tsx (1200 tokens)
               ├── SaveBlockItem.tsx (200 tokens)
               ├── SavedBlockPopup.tsx (1200 tokens)
            ├── utils/
               ├── popupPositioning.ts (700 tokens)
               ├── varNames.ts (500 tokens)
      ├── ui/
         ├── alert.tsx (300 tokens)
         ├── badge.tsx (200 tokens)
         ├── button.tsx (400 tokens)
         ├── card.tsx (400 tokens)
         ├── chart.tsx (2.1k tokens)
         ├── command.tsx (1000 tokens)
         ├── dialog.tsx (800 tokens)
         ├── dropdown-menu.tsx (1500 tokens)
         ├── input.tsx (200 tokens)
         ├── popover.tsx (300 tokens)
         ├── scroll-area.tsx (300 tokens)
         ├── table.tsx (600 tokens)
         ├── tabs.tsx (400 tokens)
         ├── toast.tsx (1000 tokens)
         ├── toaster.tsx (200 tokens)
         ├── use-toast.ts (800 tokens)
   ├── context/
      ├── runbook_context.ts (100 tokens)
      ├── runbook_id_context.ts (100 tokens)
   ├── global.d.ts (omitted)
   ├── global.ts (100 tokens)
   ├── lib/
      ├── ai/
         ├── block_registry.ts (300 tokens)
         ├── commands.ts (800 tokens)
         ├── tools.ts (1700 tokens)
         ├── useAIChat.ts (1600 tokens)
      ├── app/
         ├── app_bus.ts (100 tokens)
         ├── runbook_bus.ts (300 tokens)
      ├── async_singleton.ts (700 tokens)
      ├── backoff.ts (100 tokens)
      ├── blocks/
         ├── codeberg-preview/
            ├── api.ts (1000 tokens)
            ├── components/
               ├── CodePreview.tsx (600 tokens)
               ├── CodebergPreview.tsx (1000 tokens)
               ├── IssuePreview.tsx (600 tokens)
               ├── LoadingState.tsx (100 tokens)
               ├── PRPreview.tsx (500 tokens)
               ├── RepoPreview.tsx (400 tokens)
            ├── index.ts (100 tokens)
            ├── paste-handler.ts (300 tokens)
            ├── schema.ts (200 tokens)
            ├── spec.tsx (100 tokens)
            ├── url-parser.ts (400 tokens)
         ├── common/
            ├── Block.tsx (600 tokens)
            ├── CodeEditor/
               ├── CodeEditor.tsx (1100 tokens)
               ├── extensions.ts (1700 tokens)
               ├── shellcheck.ts (400 tokens)
            ├── Dependency/
               ├── Dependency.tsx (2.2k tokens)
            ├── ErrorCard.tsx (200 tokens)
            ├── InterpreterSelector.tsx (2.2k tokens)
            ├── PlayButton.tsx (700 tokens)
            ├── ResultTable.tsx (1500 tokens)
            ├── SQL.tsx (4.2k tokens)
            ├── SQLResults.tsx (1200 tokens)
            ├── database.ts (100 tokens)
         ├── github-preview/
            ├── api.ts (800 tokens)
            ├── components/
               ├── CodePreview.tsx (600 tokens)
               ├── GitHubPreview.tsx (1000 tokens)
               ├── IssuePreview.tsx (600 tokens)
               ├── LoadingState.tsx (100 tokens)
               ├── PRPreview.tsx (500 tokens)
               ├── RepoPreview.tsx (400 tokens)
            ├── index.ts
            ├── paste-handler.ts (300 tokens)
            ├── schema.ts (200 tokens)
            ├── spec.tsx (100 tokens)
            ├── url-parser.ts (400 tokens)
         ├── gitlab-preview/
            ├── api.ts (1000 tokens)
            ├── components/
               ├── CodePreview.tsx (600 tokens)
               ├── GitLabPreview.tsx (1000 tokens)
               ├── IssuePreview.tsx (500 tokens)
               ├── LoadingState.tsx (100 tokens)
               ├── MRPreview.tsx (500 tokens)
               ├── RepoPreview.tsx (400 tokens)
            ├── index.ts (100 tokens)
            ├── paste-handler.ts (300 tokens)
            ├── schema.ts (200 tokens)
            ├── spec.tsx (100 tokens)
            ├── url-parser.ts (400 tokens)
         ├── http/
            ├── component.tsx (900 tokens)
            ├── components/
               ├── HttpResponse.tsx (1300 tokens)
               ├── RequestHeaders.tsx (1100 tokens)
               ├── VerbDropdown.tsx (300 tokens)
            ├── index.ts
            ├── schema.ts (700 tokens)
            ├── spec.tsx (600 tokens)
            ├── style.css (100 tokens)
         ├── index.ts (100 tokens)
         ├── kubernetes/
            ├── component.tsx (3.1k tokens)
            ├── execution.ts (100 tokens)
            ├── index.ts
            ├── schema.ts (900 tokens)
            ├── spec.tsx (900 tokens)
         ├── link-preview/
            ├── index.ts
            ├── paste-handler.ts (400 tokens)
            ├── registry.ts (400 tokens)
         ├── localdirectory/
            ├── component.tsx (500 tokens)
            ├── index.ts
            ├── schema.ts (400 tokens)
            ├── spec.tsx (300 tokens)
         ├── shared/
            ├── language-detection.ts (200 tokens)
         ├── terminal/
            ├── component.tsx (3.3k tokens)
            ├── components/
               ├── terminal.tsx (1300 tokens)
            ├── index.css
            ├── index.ts
            ├── schema.ts (800 tokens)
            ├── spec.tsx (700 tokens)
            ├── useTerminalEvents.ts (500 tokens)
      ├── buses/
         ├── editor.ts (300 tokens)
         ├── ssh.ts (700 tokens)
      ├── color_modes.ts (300 tokens)
      ├── colors.ts (100 tokens)
      ├── commands/
         ├── registry.ts (2000 tokens)
         ├── types.ts (100 tokens)
      ├── db/
         ├── encoders.ts (100 tokens)
         ├── tauri-ar-adapter.ts (1100 tokens)
      ├── db_hooks.ts (1200 tokens)
      ├── decorators.ts (100 tokens)
      ├── dev/
         ├── DebugWindow.tsx (800 tokens)
         ├── dev_console.ts (100 tokens)
      ├── events/
         ├── grand_central.ts (1700 tokens)
      ├── exec_log.ts (100 tokens)
      ├── fuzzy-matcher.ts (1400 tokens)
      ├── global_options.ts (200 tokens)
      ├── hooks/
         ├── useCodeMirrorValue.ts (400 tokens)
         ├── useCodemirrorTheme.ts (100 tokens)
         ├── useCurrentTab.ts (100 tokens)
         ├── useDocumentBridge.ts (2.2k tokens)
         ├── useKvValue.ts (500 tokens)
         ├── useReducerWithEffects.ts (400 tokens)
         ├── useResizable.ts (500 tokens)
         ├── useSSHConnection.ts (100 tokens)
         ├── useSerialExecution.ts (800 tokens)
         ├── useWorkspaceFolder.ts (500 tokens)
      ├── logger.ts (300 tokens)
      ├── menu_builder.ts (400 tokens)
      ├── notifications/
         ├── NotificationManager.tsx (4k tokens)
         ├── index.ts
         ├── useNotifications.ts (500 tokens)
      ├── online_tracker.ts (300 tokens)
      ├── phoenix_provider.ts (3.7k tokens)
      ├── queries/
         ├── local_query.ts
         ├── runbooks.ts (600 tokens)
         ├── saved_blocks.ts (100 tokens)
         ├── snapshots.ts (200 tokens)
         ├── workspaces.ts (400 tokens)
      ├── runbook_editor.ts (2.9k tokens)
      ├── runtime/
         ├── index.ts (300 tokens)
      ├── runtime_prompt.tsx (700 tokens)
      ├── server_events.ts (600 tokens)
      ├── shared_state/
         ├── adapter.ts (800 tokens)
         ├── commands.ts (200 tokens)
         ├── manager.ts (2.5k tokens)
         ├── types.ts (100 tokens)
         ├── useSharedState.ts (500 tokens)
      ├── std/
         ├── async_queue.ts (1100 tokens)
         ├── mutex.ts (300 tokens)
      ├── sync/
         ├── runbook_synchronizer.ts (1700 tokens)
         ├── server_observer.ts (3.2k tokens)
         ├── workspace_sync_manager.ts (1700 tokens)
      ├── tauri.ts (200 tokens)
      ├── tree/
         ├── index.ts
         ├── node.ts (900 tokens)
         ├── tree.test.ts (1600 tokens)
         ├── tree.ts (1400 tokens)
         ├── types.ts (100 tokens)
      ├── useDebouncedCallback.ts (200 tokens)
      ├── useRemoteRunbook.ts (200 tokens)
      ├── useRunbook.ts (100 tokens)
      ├── utils.ts (1400 tokens)
      ├── workflow/
         ├── block_bus.ts (1400 tokens)
         ├── blocks/
            ├── block.ts (100 tokens)
            ├── clickhouse.ts (200 tokens)
            ├── convert.test.ts (1200 tokens)
            ├── convert.ts (400 tokens)
            ├── editor.ts (100 tokens)
            ├── mysql.ts (200 tokens)
            ├── postgres.ts (200 tokens)
            ├── prometheus.ts (200 tokens)
            ├── script.ts (200 tokens)
            ├── sqlite.ts (200 tokens)
            ├── terminal.ts
         ├── dependency.ts (600 tokens)
         ├── workflow_runtime.ts (100 tokens)
      ├── workspace_setup.ts (1000 tokens)
      ├── workspaces/
         ├── commands.ts (1000 tokens)
         ├── manager.ts (900 tokens)
         ├── offline_strategy.ts (2.7k tokens)
         ├── online_strategy.ts (2.3k tokens)
         ├── strategy.ts (600 tokens)
      ├── ydoc_to_blocknote.tsx (100 tokens)
   ├── main.tsx (1600 tokens)
   ├── routes/
      ├── history/
         ├── History.tsx (600 tokens)
      ├── root/
         ├── DeleteRunbookModal.tsx (1700 tokens)
         ├── DesktopImportModal.tsx (2.4k tokens)
         ├── FeedbackModal.tsx (800 tokens)
         ├── InviteFriendsModal.tsx (900 tokens)
         ├── NewWorkspaceDialog.tsx (1500 tokens)
         ├── Root.css (200 tokens)
         ├── Root.tsx (7.1k tokens)
         ├── RuntimeUpdateNotice.tsx (500 tokens)
         ├── SaveBlockModal.tsx (400 tokens)
         ├── Tabs.tsx (3.2k tokens)
         ├── UpdateNotifier.tsx (1300 tokens)
         ├── WorkspaceWatcher.tsx (200 tokens)
         ├── deep.ts (300 tokens)
         ├── menu.ts (100 tokens)
      ├── runbooks/
         ├── CollaborationManager.tsx (1100 tokens)
         ├── RunbookControls.tsx (2.2k tokens)
         ├── Runbooks.tsx (4.2k tokens)
      ├── stats/
         ├── Stats.tsx (2.2k tokens)
   ├── rs-bindings/
      ├── AIBinary.ts (100 tokens)
      ├── AIBinarySource.ts
      ├── AIMessage.ts (100 tokens)
      ├── AIMessageContent.ts (100 tokens)
      ├── AIMessageContentPart.ts (100 tokens)
      ├── AIMessageRole.ts
      ├── AIToolCall.ts
      ├── AIToolResponse.ts
      ├── AdvancedSettings.ts (100 tokens)
      ├── BlockErrorData.ts
      ├── BlockFinishedData.ts
      ├── BlockInfo.ts
      ├── BlockLifecycleEvent.ts (100 tokens)
      ├── ChargeTarget.ts
      ├── ClientPrompt.ts (100 tokens)
      ├── ClientPromptResult.ts (100 tokens)
      ├── Comment.ts (100 tokens)
      ├── DirEntry.ts (100 tokens)
      ├── DocumentBridgeMessage.ts (200 tokens)
      ├── DropdownOption.ts
      ├── DropdownState.ts (100 tokens)
      ├── ExecutionStatus.ts (100 tokens)
      ├── Fix.ts
      ├── GCEvent.ts (400 tokens)
      ├── HttpResponse.ts (100 tokens)
      ├── InsertionPoint.ts
      ├── KubernetesBlockOutput.ts (200 tokens)
      ├── KubernetesColumn.ts
      ├── LLMToolsEvent.ts (100 tokens)
      ├── MarkdownRenderState.ts
      ├── ModelSelection.ts (100 tokens)
      ├── ModelToken.ts
      ├── OutputLine.ts
      ├── PrometheusBlockOutput.ts (100 tokens)
      ├── PrometheusQueryResult.ts (100 tokens)
      ├── PrometheusTimeRange.ts
      ├── PromptIcon.ts (100 tokens)
      ├── PromptInput.ts (100 tokens)
      ├── PromptOption.ts (100 tokens)
      ├── PromptOptionColor.ts (100 tokens)
      ├── PromptOptionVariant.ts (100 tokens)
      ├── PtyMetadata.ts (100 tokens)
      ├── Replacement.ts (100 tokens)
      ├── ResolvedContext.ts (100 tokens)
      ├── ResolvedDropdownState.ts
      ├── RustOfflineRunbook.ts (100 tokens)
      ├── RustOfflineRunbookFile.ts (100 tokens)
      ├── ScriptExecutionOutput.ts (100 tokens)
      ├── SessionConfig.ts (100 tokens)
      ├── SessionEvent.ts (200 tokens)
      ├── SessionInfo.ts (100 tokens)
      ├── SessionKind.ts (200 tokens)
      ├── Severity.ts
      ├── ShellCheckOutput.ts
      ├── SqlBlockExecutionResult.ts (100 tokens)
      ├── SqlBlockOutput.ts (100 tokens)
      ├── SqlQueryResult.ts (100 tokens)
      ├── SqlStatementResult.ts
      ├── State.ts (100 tokens)
      ├── StreamingBlockOutput.ts (100 tokens)
      ├── TerminalBlockOutput.ts (100 tokens)
      ├── WorkspaceError.ts (300 tokens)
      ├── WorkspaceEvent.ts (100 tokens)
      ├── WorkspaceRunbook.ts (100 tokens)
      ├── WorkspaceState.ts (100 tokens)
      ├── serde_json/
         ├── JsonValue.ts
   ├── server_notification_manager.ts (1500 tokens)
   ├── socket.ts (1900 tokens)
   ├── state/
      ├── atuin_db.ts (100 tokens)
      ├── client.ts (200 tokens)
      ├── kv.ts (400 tokens)
      ├── models.ts (1000 tokens)
      ├── ptyStore.ts (300 tokens)
      ├── runbooks/
         ├── legacy_workspace.ts (900 tokens)
         ├── operation.ts (1400 tokens)
         ├── operation_processor.ts (3.3k tokens)
         ├── runbook.ts (5.2k tokens)
         ├── saved_block.ts (300 tokens)
         ├── search.ts (700 tokens)
         ├── snapshot.ts (600 tokens)
         ├── untitled.json (100 tokens)
         ├── welcome.json (5.2k tokens)
         ├── workspace.ts (400 tokens)
         ├── workspace_folder_ops.ts (500 tokens)
         ├── workspace_folders.ts (1300 tokens)
      ├── settings.ts (2.7k tokens)
      ├── settings_ai.ts (500 tokens)
      ├── store.ts (1000 tokens)
      ├── store/
         ├── child_state.ts (100 tokens)
         ├── collaboration_state.ts (500 tokens)
         ├── dialog_state.ts (200 tokens)
         ├── pty_state.ts (1300 tokens)
         ├── query_state.ts (100 tokens)
         ├── runbook_state.ts (600 tokens)
         ├── shell_info_state.ts (600 tokens)
         ├── ui_state.ts (3.6k tokens)
         ├── user_state.ts (800 tokens)
      ├── templates.ts (300 tokens)
   ├── styles.css (900 tokens)
   ├── tracking.ts (800 tokens)
   ├── updater.ts (400 tokens)
   ├── utils/
      ├── scroll-position.test.ts (500 tokens)
      ├── scroll-position.ts (300 tokens)
   ├── vite-env.d.ts (omitted)
├── tailwind.config.js (1200 tokens)
├── tsconfig.json (200 tokens)
├── tsconfig.node.json
├── vite.config.ts (400 tokens)
├── vitest.config.ts
```


## /.cursor/rules/avoid-unnecessary-comments.mdc

```mdc path="/.cursor/rules/avoid-unnecessary-comments.mdc" 
---
description: Good comment style
globs: *
---
# Avoid Unnecessary Comments

A rule to discourage adding excessive or redundant comments to the codebase.

<rule>
name: avoid_unnecessary_comments
description: Prevents redundant, obvious, or excessive comments in code

filters:
  # Apply to all code files with common extensions
  - type: file_extension
    pattern: "\\.(js|jsx|ts|tsx|py|rb|java|c|cpp|cs|go|rs|php|swift|kt){{contextString}}quot;

actions:
  - type: reject
    conditions:
      # Look for obvious/redundant comments
      - pattern: "(?m)^\\s*(?://|/\\*|#|<!--|--) (?:This|The) \\w+ (?:function|method|class|variable)"
        message: "Avoid comments that simply state what a function/method/class/variable is"
      
      # Look for comments that just repeat the code
      - pattern: "(?m)^\\s*(?://|/\\*|#|<!--|--) (?:Increment|Decrement|Add|Set|Get|Create|Delete|Update|Remove)"
        message: "Avoid comments that just repeat what the code does - use clear naming instead"

  - type: suggest
    message: |
      # Comment Guidelines
      
      ## Avoid unnecessary comments:
      
      ❌ **Don't add comments that:**
      - State the obvious (e.g., `// increment counter`)
      - Repeat information clearly visible in the code
      - Explain WHAT the code does rather than WHY
      - Add noise that reduces readability
      
      ✅ **Do add comments when:**
      - Explaining WHY something is implemented in a certain way
      - Documenting complex business logic or algorithms
      - Noting edge cases or non-obvious behavior
      - Explaining temporary workarounds (with tickets/issues referenced)
      
      Instead of adding explanatory comments, prefer:
      - Clear, descriptive variable and function names
      - Small, focused functions with single responsibilities
      - Proper code organization and structure

examples:
  - input: |
      // This function gets the user data
      function getUserData() {
          return userData;
      }
    output: |
      function getUserData() {
          return userData;
      }
  
  - input: |
      // This implementation uses a cache to avoid expensive recalculations
      // when handling high-volume requests in production environments
      function getCachedResults(query) {
          if (cache.has(query)) {
              return cache.get(query);
          }
          const result = computeExpensiveResult(query);
          cache.set(query, result);
          return result;
      }
    output: |
      // This implementation uses a cache to avoid expensive recalculations
      // when handling high-volume requests in production environments
      function getCachedResults(query) {
          if (cache.has(query)) {
              return cache.get(query);
          }
          const result = computeExpensiveResult(query);
          cache.set(query, result);
          return result;
      }

metadata:
  priority: high
  version: 1.0
</rule>
```

## /.cursor/rules/component-creation.mdc

```mdc path="/.cursor/rules/component-creation.mdc" 
---
description: Writing a new block component for atuin runbooks
globs: *.tsx, *.ts, *.rs
---
# Creating Components for Atuin Runbooks

Guidelines for creating new component blocks for the Atuin runbook editor.

<rule>
name: atuin_component_creation
description: Standards for creating and implementing component blocks in Atuin Desktop

filters:
  - type: event
    pattern: "file_create"
  - type: file_path
    pattern: "src/components/runbooks/editor/blocks/.*"

actions:
  - type: suggest
    message: |
      # Creating Components for Atuin Desktop
      
      ## Component Types
      
      There are two primary types of components:
      
      1. **Passive Blocks** - Provide context or configuration for other blocks (e.g., Directory, Env, Var, Host)
      2. **Executable Blocks** - Run commands or execute actions (e.g., Run, Script, HTTP)
      
      ## Component Structure
      
      \`\`\`
      src/components/runbooks/editor/blocks/[ComponentName]/
      ├── ComponentName.tsx  (or index.tsx)
      └── index.ts           (exports the component)
      \`\`\`
      
      ## Implementation Steps
      
      1. **Create Component Directory**
      2. **Create Main Component File**:
         - Use `createReactBlockSpec` to define the block
         - Implement a React component for the UI
      3. **Create Index File** for exports
      4. **Register in Schema** (`create_editor.ts`)
      5. **Add to Menu Items** (`Editor.tsx`) in the appropriate group
      6. **Create Backend Command** if the component needs to interact with the system:
         - Create in `backend/src/commands/[category].rs`
         - Add module to `backend/src/commands/mod.rs` 
         - Register command in main.rs (`tauri::generate_handler!`)
      
      ## Component Specification Example
      
      \`\`\`typescript
      export default createReactBlockSpec(
        {
          type: "your-component-type", 
          propSchema: {
            property: { default: "default-value" },
          },
          content: "none",
        },
        {
          render: ({ block, editor }) => {
            return <YourComponent {...props} />;
          },
        }
      );
      \`\`\`
      
      ## Suggestion Menu Item Example
      
      \`\`\`typescript
      export const insertYourComponent = (schema: any) => (editor: typeof schema.BlockNoteEditor) => ({
        title: "Component Name",
        subtext: "Description of component",
        onItemClick: () => {
          insertOrUpdateBlock(editor, {
            type: "your-component-type",
          });
        },
        icon: <SomeIcon size={18} />,
        group: "Appropriate Group", // Network, Execute, Database, etc.
      });
      \`\`\`
      
      ## For Passive Variable Blocks
      
      When creating variable blocks (like Env or template Var):
      
      1. Create a React component with input fields for name and value
      2. Use props to manage state and sync with editor
      3. For template variables, invoke a Tauri command to update backend state:
      
      \`\`\`typescript
      // In your component's render function:
      const onUpdate = (name: string, value: string): void => {
        // Update block props locally
        editor.updateBlock(block, {
          props: { ...block.props, name, value },
        });
        
        // Update backend state via command
        if (name && currentRunbookId) {
          invoke("set_template_var", {
            runbook: currentRunbookId,
            name,
            value,
          }).catch(console.error);
        }
      };
      \`\`\`
      
      ## Backend Command Structure
      
      For components that need backend state:
      
      \`\`\`rust
      // In backend/src/commands/template.rs
      #[tauri::command]
      pub async fn set_template_var(
          state: tauri::State<'_, crate::state::AtuinState>,
          runbook: String,
          name: String,
          value: String,
      ) -> Result<(), String> {
          // Update state logic here
          state
              .runbook_output_variables
              .write()
              .await
              .entry(runbook)
              .or_insert(HashMap::new())
              .insert(name, value);
      
          Ok(())
      }
      \`\`\`
      
      ## Architecture Guidelines
      
      - Commands using the Tauri API should live in `@commands` directory
      - Runtime functionality (without Tauri dependencies) lives in `@runtime`
      - Use the component's `onUpdate` method to trigger backend state changes
      - For passive blocks, use `findFirstParentOfType` and `findAllParentsOfType` to discover block values
      
      ## Best Practices
      
      - Follow existing patterns for similar block types
      - Maintain consistent styling with other blocks
      - Add tooltips to explain functionality
      - Group components properly in the menu
      - For passive blocks, use `findFirstParentOfType` to find parent blocks
      - Use `@heroui/react` components and `lucide-react` icons
      - When updating backend state, check that required values are present

examples:
  - input: |
      Creating a new Host block component
    output: |
      // Host component (src/components/runbooks/editor/blocks/Host/HostSelect.tsx)
      
      import { Button, Tooltip } from "@heroui/react";
      import { HomeIcon } from "lucide-react";
      import { createReactBlockSpec } from "@blocknote/react";
      import { insertOrUpdateBlock } from "@blocknote/core";
      
      const HostSelect = ({ isEditable }) => (
        <div className="w-full">
          <Tooltip content="Specifies that commands run on the local machine">
            <div className="flex flex-row items-center w-full">
              <div className="mr-2"><HomeIcon size={18} /></div>
              <div className="flex-grow">
                <Button variant="flat" className="w-full justify-start">localhost</Button>
              </div>
            </div>
          </Tooltip>
        </div>
      );
      
      export default createReactBlockSpec(
        {
          type: "host-select",
          propSchema: { host: { default: "local" } },
          content: "none",
        },
        {
          render: ({ block, editor }) => <HostSelect isEditable={editor.isEditable} />
        }
      );
      
      export const insertHostSelect = (schema) => (editor) => ({
        title: "Host",
        subtext: "Specify that commands run on localhost",
        onItemClick: () => { insertOrUpdateBlock(editor, { type: "host-select" }); },
        icon: <HomeIcon size={18} />,
        group: "Network",
      });
  
  - input: |
      Creating a template variable block that updates backend state
    output: |
      // Component definition (src/components/runbooks/editor/blocks/Var/index.tsx)
      import React from "react";
      import { Input, Tooltip, Button } from "@heroui/react";
      import { TextCursorInputIcon } from "lucide-react";
      import { invoke } from "@tauri-apps/api/core";
      import { useStore } from "@/state/store";
      import { createReactBlockSpec } from "@blocknote/react";
      
      const Var = ({ name, value, onUpdate, isEditable }) => {
        const handleKeyChange = (e) => {
          onUpdate(e.currentTarget.value, value);
        };
      
        const handleValueChange = (e) => {
          onUpdate(name, e.currentTarget.value);
        };
      
        return (
          <Tooltip content="Set a template variable for subsequent blocks">
            <div className="flex flex-row items-center space-x-2 w-full">
              <div className="flex flex-1 flex-row gap-2">
                <Button isIconOnly isDisabled variant="light">
                  <TextCursorInputIcon />
                </Button>
                <Input
                  placeholder="Name"
                  value={name}
                  onChange={handleKeyChange}
                  disabled={!isEditable}
                />
              </div>
              <div className="flex-1">
                <Input
                  placeholder="Value"
                  value={value}
                  onChange={handleValueChange}
                  disabled={!isEditable}
                />
              </div>
            </div>
          </Tooltip>
        );
      };
      
      export default createReactBlockSpec(
        {
          type: "var",
          propSchema: {
            name: { default: "" },
            value: { default: "" },
          },
          content: "none",
        },
        {
          render: ({ block, editor }) => {
            const currentRunbookId = useStore((store) => store.currentRunbookId);
            
            const onUpdate = (name, value) => {
              editor.updateBlock(block, {
                props: { ...block.props, name, value },
              });
              
              if (name && currentRunbookId) {
                invoke("set_template_var", {
                  runbook: currentRunbookId,
                  name,
                  value,
                }).catch(console.error);
              }
            };
      
            return (
              <Var
                name={block.props.name}
                value={block.props.value}
                onUpdate={onUpdate}
                isEditable={editor.isEditable}
              />
            );
          },
        },
      );
      
      // Backend command (backend/src/commands/template.rs)
      #[tauri::command]
      pub async fn set_template_var(
          state: tauri::State<'_, crate::state::AtuinState>,
          runbook: String,
          name: String,
          value: String,
      ) -> Result<(), String> {
          state
              .runbook_output_variables
              .write()
              .await
              .entry(runbook)
              .or_insert(HashMap::new())
              .insert(name, value);
      
          Ok(())
      }

metadata:
  priority: medium
  version: 1.1
</rule>
```

## /.cursor/rules/conventional-commits.mdc

```mdc path="/.cursor/rules/conventional-commits.mdc" 
---
description: Writing a good commit message
globs: *
---
# Conventional Commits

A rule to enforce proper conventional commit formatting with clear, descriptive messages.

<rule>
name: conventional_commits
description: Enforces conventional commit format with concise, explanatory first lines

filters:
  - type: event
    pattern: "git_commit"

actions:
  - type: reject
    conditions:
      # Reject non-conventional commit types
      - pattern: "^(?!feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\\(.*\\))?:"
        message: "Commit message must start with a conventional type (feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert)"
      
      # Reject first lines that are too long (>72 chars)
      - pattern: "^.{73,}"
        message: "First line should be 72 characters or less to avoid truncation in GitHub and other tools"
      
      # Reject commits without a space after the colon
      - pattern: "^\\w+(?:\\(.*\\))?:(?!\\s)"
        message: "Include a space after the colon in your commit message"
      
      # Reject first lines that start with lowercase (after the type)
      - pattern: "^\\w+(?:\\(.*\\))?:\\s[a-z]"
        message: "First word after type should be capitalized"

  - type: suggest
    message: |
      # Conventional Commit Format
      
      Follow this format for all commits:
      
      \`\`\`
      type(scope): Short summary
      
      Detailed explanation if needed
      \`\`\`
      
      ## Requirements:
      
      - **Type**: One of: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
      - **Scope**: Optional component name in parentheses
      - **Summary**: 
        - Clear and concise (50-72 chars)
        - Written in imperative ("Add feature" not "Added feature")
        - First letter capitalized
        - No period at the end
      - **Body**: Optional, separated by blank line, explains WHY not HOW
      
      ## Examples:
      
      ✅ `feat(auth): Add password reset functionality`
      
      ✅ `fix: Prevent crash when API returns empty response`
      
      ✅ `docs(readme): Update installation instructions`

examples:
  - input: |
      added new button to the ui
    output: |
      feat(ui): Add new button
  
  - input: |
      fixed the bug in authentication
    output: |
      fix(auth): Resolve authentication failure on expired sessions
  
  - input: |
      feat(users): implemented email verification system with token expiration and email templates for better onboarding experience
    output: |
      feat(users): Implement email verification system
      
      Add token-based verification with:
      - 24h expiration
      - Customized email templates
      - Improved onboarding flow

metadata:
  priority: high
  version: 1.0
</rule>
```

## /.cursor/rules/rule-storage.mdc

```mdc path="/.cursor/rules/rule-storage.mdc" 
---
description: Rules for placing and organizing Cursor rule files in the repository.
globs: *.mdc
alwaysApply: false
---
# Cursor Rules Location

Rules for placing and organizing Cursor rule files in the repository.

<rule>
name: cursor_rules_location
description: Standards for placing Cursor rule files in the correct directory
filters:
  # Match any .mdc files
  - type: file_extension
    pattern: "\\.mdc{{contextString}}quot;
  # Match files that look like Cursor rules
  - type: content
    pattern: "(?s)<rule>.*?</rule>"
  # Match file creation events
  - type: event
    pattern: "file_create"

actions:
  - type: reject
    conditions:
      - pattern: "^(?!\\.\\/\\.cursor\\/rules\\/.*\\.mdc$)"
        message: "Cursor rule files (.mdc) must be placed in the .cursor/rules directory"

  - type: suggest
    message: |
      When creating Cursor rules:

      1. Always place rule files in PROJECT_ROOT/.cursor/rules/:
         \`\`\`
         .cursor/rules/
         ├── your-rule-name.mdc
         ├── another-rule.mdc
         └── ...
         \`\`\`

      2. Follow the naming convention:
         - Use kebab-case for filenames
         - Always use .mdc extension
         - Make names descriptive of the rule's purpose

      3. Directory structure:
         \`\`\`
         PROJECT_ROOT/
         ├── .cursor/
         │   └── rules/
         │       ├── your-rule-name.mdc
         │       └── ...
         └── ...
         \`\`\`

      4. Never place rule files:
         - In the project root
         - In subdirectories outside .cursor/rules
         - In any other location

examples:
  - input: |
      # Bad: Rule file in wrong location
      rules/my-rule.mdc
      my-rule.mdc
      .rules/my-rule.mdc

      # Good: Rule file in correct location
      .cursor/rules/my-rule.mdc
    output: "Correctly placed Cursor rule file"

metadata:
  priority: high
  version: 1.0
</rule>
```

## /.cursor/rules/template-system.mdc

```mdc path="/.cursor/rules/template-system.mdc" 
---
description: Guidelines for using and extending the template system in Atuin runbooks.
globs: *
alwaysApply: undefined
---
# Atuin Runbook Template System

Guidelines for using and extending the template system in Atuin runbooks.

<rule>
name: atuin_template_system
description: Standards for working with the template system in Atuin runbooks

filters:
  - type: file_path
    pattern: "src/components/runbooks/editor/blocks/.*"
  - type: file_path
    pattern: "backend/src/templates\\.rs"
  - type: file_path
    pattern: "src/state/templates\\.ts"

actions:
  - type: suggest
    message: |
      # Atuin Runbook Template System
      
      Atuin runbooks have a powerful template system that allows for dynamic content generation and variable substitution.
      
      ## Overview
      
      The template system uses [MiniJinja](mdc:https:/docs.rs/minijinja/latest/minijinja) (Rust implementation of Jinja2) and provides:
      
      - Access to document structure and blocks
      - Variable substitution
      - Template expressions
      
      ## Template Variables
      
      There are three main types of variables:
      
      1. **Environment Variables** (`Env` blocks) - Used for shell environment
      2. **Template Variables** (`Var` blocks) - Used for template substitution, synced across users
      3. **Local Variables** (`LocalVar` blocks) - Used for private variables (e.g., credentials), not synced across users
      
      ## Template State Structure
      
      The template state consists of:
      
      \`\`\`rust
      struct TemplateState {
          doc: Option<DocumentTemplateState>,  // Document structure info
          var: HashMap<String, Value>,         // Template variables
      }
      \`\`\`
      
      Where `DocumentTemplateState` contains:
      
      \`\`\`rust
      struct DocumentTemplateState {
          first: BlockState,                   // First block in the document
          last: BlockState,                    // Last block in the document
          content: Vec<BlockState>,            // All blocks in the document
          named: HashMap<String, BlockState>,  // Blocks with names
          previous: BlockState,                // Block before the current one
      }
      \`\`\`
      
      ## Creating Template Variables
      
      ### Shared Variables (Synced)
      
      Variables can be created using the `Var` block:
      
      1. Insert a Var block using the slash menu (/Template Variable)
      2. Set a name and value
      3. The variable will be stored in both the block props AND the backend state
      4. These variables are synced across all users
      
      ### Private Variables (Not Synced)
      
      For sensitive values like credentials, use the `LocalVar` block:
      
      1. Insert a LocalVar block using the slash menu (/Local Variable)
      2. Set a name (synced) and value (private)
      3. Only the variable name is stored in block props and synced
      4. The value is stored only in the backend state for the current user
      5. Other users see the name but not the value
      
      ## Using Template Variables
      
      Both shared and private variables can be referenced using the `{{ var.name }}` syntax:
      
      \`\`\`
      {{ var.username }}
      {{ var.password }}  <!-- If password is a LocalVar, each user's own value will be used -->
      \`\`\`
      
      ## Implementation Details
      
      ### Frontend
      
      #### Shared Variables (Var)
      
      - Both name and value are stored in UI component props (synced)
      - Changes are synced to the backend via the `set_template_var` command
      
      #### Private Variables (LocalVar)
      
      - Only the name is stored in UI component props (synced)
      - The value is stored only in backend state via `set_template_var` command
      - Values are retrieved via `get_template_var` command
      
      ### Backend
      
      - All template variables are stored in `AtuinState.runbook_output_variables`:
      
      \`\`\`rust
      // Map of runbook -> variable name -> variable value
      pub runbook_output_variables: Arc<RwLock<HashMap<String, HashMap<String, String>>>>
      \`\`\`
      
      - Template processing happens in the `template_str` command
      - Variables are passed to the template engine when rendering
      
      ## Command Interface
      
      \`\`\`rust
      #[tauri::command]
      pub async fn set_template_var(
          state: tauri::State<'_, crate::state::AtuinState>,
          runbook: String,
          name: String,
          value: String,
      ) -> Result<(), String> {
          state
              .runbook_output_variables
              .write()
              .await
              .entry(runbook)
              .or_insert(HashMap::new())
              .insert(name, value);
      
          Ok(())
      }
      
      #[tauri::command]
      pub async fn get_template_var(
          state: tauri::State<'_, crate::state::AtuinState>,
          runbook: String,
          name: String,
      ) -> Result<Option<String>, String> {
          let value = state
              .runbook_output_variables
              .read()
              .await
              .get(&runbook)
              .and_then(|vars| vars.get(&name))
              .cloned();
      
          Ok(value)
      }
      \`\`\`
      
      ## Template Processing Flow
      
      1. User creates template variables using `Var` or `LocalVar` blocks
      2. Variables are stored in backend state
      3. When a block with templated content runs:
         - The `templateString` function is called
         - This invokes the backend `template_str` command
         - The template is processed with access to variables and document structure
         - The processed string is returned and used

examples:
  - input: |
      How to create and use a template variable in a runbook
    output: |
      # Creating and Using Template Variables
      
      ## Shared Variables (synced with other users)
      
      ### Step 1: Add a Var Block
      
      1. In your runbook, type `/` to open the command menu
      2. Select "Template Variable"
      3. Set a name (e.g., "hostname") and value (e.g., "example.com")
      
      ## Private Variables (not synced - for credentials)
      
      ### Step 1: Add a LocalVar Block
      
      1. In your runbook, type `/` to open the command menu
      2. Select "Local Variable" 
      3. Set a name (e.g., "password") - this will be visible to others
      4. Set a value (e.g., "secret123") - this will only be stored on your device
      
      ### Step 2: Reference the Variables
      
      In subsequent blocks, you can reference both types of variables using the same syntax:
      
      \`\`\`bash
      # In a Run block:
      curl -u admin:{{ var.password }} https://{{ var.hostname }}/api
      \`\`\`
      
      When the block runs, each user's own private password will be used.
      
      ## Advanced Example
      
      You can combine multiple variables and use template expressions:
      
      \`\`\`bash
      # Using multiple variables
      curl -X POST https://{{ var.hostname }}/api/v1/{{ var.endpoint }} \
        -H "Authorization: Bearer {{ var.token }}" \
        -d '{"key": "{{ var.key }}"}'
      \`\`\`

metadata:
  priority: medium
  version: 1.1
</rule>
```

## /.github/ISSUE_TEMPLATE/bug_report.md

---
name: Bug Report
about: Report a bug or issue with Atuin Desktop
title: '[BUG] '
labels: ['bug', 'needs-triage']
assignees: ''
---

## Bug Description
A clear and concise description of what the bug is.

## Steps to Reproduce
1. Go to '...'
2. Click on '...'
3. Run command '...'
4. See error

## Expected Behavior
A clear and concise description of what you expected to happen.

## Actual Behavior
A clear and concise description of what actually happened.

## Environment
- **OS**: [e.g. macOS 14.2, Ubuntu 22.04, Windows 11]
- **Atuin Desktop Version**: [e.g. 0.1.0-beta.3]
- **Atuin CLI Version**: [e.g. 18.3.0 - run `atuin --version`]
- **Shell**: [e.g. zsh, bash, fish]
- **Terminal**: [e.g. iTerm2, Terminal.app, Ghostty]

## Screenshots/Logs
If applicable, add screenshots or error logs to help explain your problem.

```
Paste any relevant error messages or logs here
```

## Additional Context
Add any other context about the problem here. For example:
- Does this happen consistently or intermittently?
- Did this work in a previous version?
- Are you using any specific integrations or external tools?

## Workaround
If you've found a temporary workaround, please describe it here.


## /.github/ISSUE_TEMPLATE/feature_request.md

---
name: Feature Request
about: Suggest a new feature or enhancement for Atuin Desktop
title: '[FEATURE] '
labels: ['enhancement', 'needs-triage']
assignees: ''
---

## Feature Summary
A clear and concise description of the feature you'd like to see.

## Problem/Use Case
What problem does this feature solve? Describe the workflow or scenario where this would be helpful.

## Proposed Solution
Describe your ideal solution. How would this feature work?

## Current Workaround
How do you currently handle this workflow? What tools or manual steps are you using?

## Detailed Description

### User Interface
How would users interact with this feature? 

### Integration Points
Would this feature need to integrate with:
- [ ] Shell/Terminal
- [ ] Database clients
- [ ] HTTP/API calls
- [ ] File system
- [ ] External tools (specify): ___
- [ ] Other: ___

## Alternatives Considered
Have you considered any alternative solutions or features?

## Additional Context
- **Priority**: How important is this feature to your workflow? (Low/Medium/High/Critical)
- **Frequency**: How often would you use this feature? (Daily/Weekly/Monthly/Occasionally)
- **Team Impact**: Would this benefit just you or your entire team?

## Related Issues/Discussions
Link any related issues, discussions, or community conversations about this feature.

## Implementation Notes
If you have technical suggestions or constraints to consider, share them here.


## /.github/dependabot.yml

```yml path="/.github/dependabot.yml" 
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file

version: 2
updates:
  - package-ecosystem: "cargo" # See documentation for possible values
    directory: "/backend" # Location of package manifests
    schedule:
      interval: "weekly"
  - package-ecosystem: "npm" # See documentation for possible values
    directory: "/" # Location of package manifests
    schedule:
      interval: "weekly"

```

## /.github/pull_request_template.md

## Change Summary
A short description of your changes.

## Motivation and details
Any additional details about the changes you made, such as why you made them, what problems they solve, and how they work.

## Tasks
- [ ] Regenerated TS-RS bindings (if any `ts(export)` structs have changed)
- [ ] Updated the documentation in `docs/` (if any application behavior has changed)
- [ ] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle


## /.github/workflows/atuin-run-release.yaml

```yaml path="/.github/workflows/atuin-run-release.yaml" 
name: Build atuin-run

on:
  push:
    branches: [main]
    paths:
      - "crates/atuin-run/**"
      - "crates/atuin-desktop-runtime/**"
      - ".github/workflows/atuin-run-release.yaml"

env:
  CARGO_TERM_COLOR: always

jobs:
  build:
    name: Build and Upload
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: depot-ubuntu-22.04-4
            target: x86_64-unknown-linux-gnu
          - platform: macos-latest
            target: aarch64-apple-darwin

    runs-on: ${{ matrix.platform }}

    steps:
      - uses: actions/checkout@v4

      - name: Install latest rust
        uses: dtolnay/rust-toolchain@master
        with:
          toolchain: stable

      - name: Run sccache-cache
        uses: mozilla-actions/sccache-action@v0.0.7

      - name: Build atuin-run
        env:
          RUSTC_WRAPPER: sccache
        run: cargo build --release --package atuin-run

      - name: Upload to S3
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
          AWS_ENDPOINT_URL: ${{ vars.S3_ENDPOINT_URL }}
        run: |
          aws s3 cp target/release/atuin-run s3://${{ vars.S3_BUCKET }}/bin/main/${{ matrix.target }}/atuin-run

```

## /.github/workflows/cla.yaml

```yaml path="/.github/workflows/cla.yaml" 
name: "CLA Assistant"
on:
  issue_comment:
    types: [created]
  pull_request_target:
    types: [opened, closed, synchronize]

# explicitly configure permissions, in case your GITHUB_TOKEN workflow permissions are set to read-only in repository settings
permissions:
  actions: write
  contents: write # this can be 'read' if the signatures are in remote repository
  pull-requests: write
  statuses: write

jobs:
  CLAAssistant:
    runs-on: ubuntu-latest
    steps:
      - name: "CLA Assistant"
        if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
        uses: contributor-assistant/github-action@v2.6.1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # the below token should have repo scope and must be manually added by you in the repository's secret
          # This token is required only if you have configured to store the signatures in a remote repository/organization
          # PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
        with:
          path-to-signatures: "signatures/version1/cla.json"
          path-to-document: "https://github.com/atuinsh/desktop/blob/main/CLA.md" # e.g. a CLA or a DCO document
          # branch should not be protected
          branch: "cla-signatures"
          allowlist: bot*,claude

          # the followings are the optional inputs - If the optional inputs are not given, then default values will be taken
          #remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository)
          #remote-repository-name: enter the  remote repository name where the signatures should be stored (Default is storing the signatures in the same repository)
          #create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
          #signed-commit-message: 'For example: $contributorName has signed the CLA in $owner/$repo#$pullRequestNo'
          #custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
          #custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
          #custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
          #lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
          #use-dco-flag: true - If you are using DCO instead of CLA

```

## /.github/workflows/claude.yml

```yml path="/.github/workflows/claude.yml" 
name: Claude Code

on:
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]
  issues:
    types: [opened, assigned]
  pull_request_review:
    types: [submitted]

jobs:
  claude:
    if: |
      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
      (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
      (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      issues: write
      id-token: write
      actions: read # Required for Claude to read CI results on PRs
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - name: Run Claude Code
        id: claude
        uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}

          # This is an optional setting that allows Claude to read CI results on PRs
          additional_permissions: |
            actions: read

          # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
          # prompt: 'Update the pull request description to include a summary of changes.'

          # Optional: Add claude_args to customize behavior and configuration
          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
          # or https://code.claude.com/docs/en/cli-reference for available options
          claude_args: '--allowed-tools Bash(git push:*) Bash(git commit:*) Bash(cargo fmt:*) Bash(cargo clippy:*) Bash(cargo check:*) Bash(cargo build:*) Bash(cargo test:*)'


```

## /.github/workflows/github-releases-to-discord.yaml

```yaml path="/.github/workflows/github-releases-to-discord.yaml" 
on:
  release:
    types: [published]

jobs:
  github-releases-to-discord:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: GitHub Releases to Discord
        uses: SethCohen/github-releases-to-discord@v1
        with:
          webhook_url: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
          color: "2105893"
          username: "Atuin Desktop Releases"
          avatar_url: "https://atuin.sh/icon.png"
          footer_title: "Changelog"
          reduce_headings: true

```

## /.github/workflows/rust.yaml

```yaml path="/.github/workflows/rust.yaml" 
name: Rust

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

on:
  push:
    branches: [main]

  pull_request:
    paths:
      - "backend/**"
      - "crates/**"
      - ".github/**"

env:
  CARGO_TERM_COLOR: always

jobs:
  rust-tests:
    name: ${{ matrix.name }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include:
          - name: Test (Ubuntu)
            os: depot-ubuntu-22.04-4
            atuin_run_url: https://atuin-binary.t3.storage.dev/bin/main/x86_64-unknown-linux-gnu/atuin-run
          - name: Test (macOS)
            os: macos-latest
            atuin_run_url: https://atuin-binary.t3.storage.dev/bin/main/aarch64-apple-darwin/atuin-run
    steps:
      - uses: actions/checkout@v4

      - name: Install atuin-run
        run: |
          curl -L -o atuin-run ${{ matrix.atuin_run_url }}
          sudo mv atuin-run /usr/local/bin/atuin-run
          sudo chmod +x /usr/local/bin/atuin-run

      - name: ${{ matrix.name }}
        run: |
          atuin-run ./runbooks/Run\ Rust\ tests.atrb

  rust-checks:
    name: ${{ matrix.name }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include:
          - name: Check
            command: check
            components: ""
            os: depot-ubuntu-22.04-4
          - name: Clippy
            command: clippy
            components: clippy
            os: depot-ubuntu-22.04-4
          - name: Format
            command: fmt
            components: rustfmt
            os: depot-ubuntu-22.04-4
    steps:
      - uses: actions/checkout@v4

      - name: Install latest rust
        uses: dtolnay/rust-toolchain@master
        with:
          toolchain: stable
          components: ${{ matrix.components }}

      - uses: awalsh128/cache-apt-pkgs-action@latest
        if: runner.os == 'Linux'
        with:
          packages: libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev
          version: 1.0

      - name: Run sccache-cache
        uses: mozilla-actions/sccache-action@v0.0.7

      - name: Run ${{ matrix.name }}
        env:
          RUSTC_WRAPPER: sccache
        run: |
          case "${{ matrix.command }}" in
            test)
              cargo test
              ;;
            check)
              cargo check --no-default-features --features wry
              ;;
            clippy)
              cargo clippy -- -D warnings
              ;;
            fmt)
              cargo fmt -- --check
              ;;
          esac

```

## /.github/workflows/ssh-integration.yaml

```yaml path="/.github/workflows/ssh-integration.yaml" 
name: SSH Integration Tests

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

on:
  push:
    branches: [main]
    paths:
      - "crates/atuin-desktop-runtime/src/ssh/**"
      - "docker/ssh-test/**"
      - ".github/workflows/ssh-integration.yaml"

  pull_request:
    paths:
      - "crates/atuin-desktop-runtime/src/ssh/**"
      - "docker/ssh-test/**"
      - ".github/workflows/ssh-integration.yaml"

  # Allow manual triggering for debugging
  workflow_dispatch:

env:
  CARGO_TERM_COLOR: always

jobs:
  ssh-integration-tests:
    name: SSH Integration Tests
    runs-on: depot-ubuntu-22.04-4

    steps:
      - uses: actions/checkout@v4

      - name: Generate test SSH keys
        run: |
          chmod +x docker/ssh-test/setup-keys.sh
          docker/ssh-test/setup-keys.sh

      - name: Build and start SSH test container
        run: |
          docker compose -f docker/ssh-test/docker-compose.yml build
          docker compose -f docker/ssh-test/docker-compose.yml up -d

      - name: Wait for SSH server to be ready
        run: |
          echo "Waiting for SSH server to be healthy..."
          timeout 60 bash -c 'until docker compose -f docker/ssh-test/docker-compose.yml ps | grep -q "healthy"; do echo "Waiting..."; sleep 2; done'
          echo "SSH server is ready"

      - name: Verify SSH connection works
        run: |
          # Quick sanity check that we can connect
          docker compose -f docker/ssh-test/docker-compose.yml exec -T sshd nc -z localhost 22
          echo "SSH port is accessible"

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable

      - name: Cache cargo
        uses: Swatinem/rust-cache@v2

      - name: Install system dependencies
        uses: awalsh128/cache-apt-pkgs-action@latest
        with:
          packages: libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev
          version: 1.0

      - name: Run SSH Integration Tests
        env:
          SSH_TEST_HOST: localhost
          SSH_TEST_PORT: 2222
          SSH_TEST_USER: testuser
          SSH_TEST_PASSWORD: testpassword
          SSH_TEST_KEYS_DIR: ${{ github.workspace }}/docker/ssh-test/test-keys
          RUST_LOG: debug
        run: |
          cargo test -p atuin-desktop-runtime ssh::integration_tests -- --ignored --test-threads=1 --nocapture

      - name: Show container logs on failure
        if: failure()
        run: |
          echo "=== Docker Compose Status ==="
          docker compose -f docker/ssh-test/docker-compose.yml ps
          echo ""
          echo "=== SSH Server Logs ==="
          docker compose -f docker/ssh-test/docker-compose.yml logs sshd

      - name: Stop SSH test container
        if: always()
        run: |
          docker compose -f docker/ssh-test/docker-compose.yml down

```

## /.github/workflows/tauri-edge.yaml

```yaml path="/.github/workflows/tauri-edge.yaml" 
name: edge

on:
  push:
    branches:
      - main
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  NODE_ENV: production
  APP_CHANNEL: edge

jobs:
  create-release:
    name: Ensure edge release exists
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4

      - name: Create or update edge release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          # Check if edge release already exists
          if gh release view edge >/dev/null 2>&1; then
            echo "Edge release already exists, updating body..."
            gh release edit edge --notes "**Rolling edge build** - updated with commit ${{ github.sha }}

          **⚠️ This is an unstable development build - use at your own risk**

          Latest commit: ${{ github.event.head_commit.message }}
          Built from: ${{ github.sha }}"
            gh release view edge --json assets | jq -r '.assets[].name' | \
              while read name; do \
                echo Deleting existing asset: "$name"
                gh release delete-asset edge "$name" -y
              done
          else
            echo "Creating new edge release..."
            gh release create edge \
              --title "Edge Release (Rolling)" \
              --notes "**Rolling edge build** - updated with commit ${{ github.sha }}

            **⚠️ This is an unstable development build - use at your own risk**
            
            Latest commit: ${{ github.event.head_commit.message }}
            Built from: ${{ github.sha }}" \
              --prerelease \
              --latest=false
          fi

  build-edge:
    name: Build edge release
    needs: create-release
    permissions:
      contents: write

    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: "depot-macos-latest"
            args: "--target aarch64-apple-darwin"
          - platform: "ubuntu-22.04"
            args: ""
          - platform: "depot-ubuntu-24.04-arm-4"
            args: "--target aarch64-unknown-linux-gnu"

    runs-on: ${{ matrix.platform }}

    steps:
      - uses: actions/checkout@v4

      - uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.TS_CLONE_DEPLOY_KEY }}

      - name: install dependencies (ubuntu only)
        if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'depot-ubuntu-24.04-arm-4'
        run: |
          sudo apt-get update
          sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev

      - name: Run sccache-cache
        uses: mozilla-actions/sccache-action@v0.0.7

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: setup node
        uses: actions/setup-node@v4
        with:
          node-version: lts/*

      - name: install Rust stable
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.platform == 'depot-macos-latest' && 'aarch64-apple-darwin' || (matrix.platform == 'depot-ubuntu-24.04-arm-4' && 'aarch64-unknown-linux-gnu' || '') }}

      - name: install frontend dependencies
        run: bun install

      - name: update edge version
        id: update-edge-version
        run: |
          # Get the current version from Cargo.toml
          version=$(grep -m 1 '^version = ' Cargo.toml | sed 's/version = "\([^"]*\)".*/\1/')
          # Get the short commit SHA
          commit_sha=$(git rev-parse --short HEAD)
          # Create the new version string
          new_version="${version}-${commit_sha}"
          # Replace the version in both Cargo.toml files
          sed -i.bak "s/^version = \"${version}\"/version = \"${new_version}\"/" Cargo.toml
          sed -i.bak "s/^version = \"${version}\"/version = \"${new_version}\"/" backend/Cargo.toml
          echo "new_version=${new_version}" >> $GITHUB_OUTPUT

      - uses: tauri-apps/tauri-action@v0
        id: tauri-action
        env:
          TAURI_BUNDLER_DMG_IGNORE_CI: false
          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY}}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_RELEASE: ${{ steps.update-edge-version.outputs.new_version }}
          NODE_OPTIONS: "--max-old-space-size=6144"
          RUSTC_WRAPPER: sccache
          IS_EDGE_BUILD: true
        with:
          args: ${{ matrix.args }}

      - name: Rename platform-specific files and upload to GitHub release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          # Get the artifact paths from tauri-action output
          echo "Artifact paths: ${{ steps.tauri-action.outputs.artifactPaths }}"

          # Remove brackets and read into array
          paths_str=$(echo "${{ steps.tauri-action.outputs.artifactPaths }}" | sed 's/^\[//;s/\]$//')
          IFS=',' read -ra paths <<< "$paths_str"

          # Determine platform suffix based on matrix args
          platform_suffix=""
          if [[ "${{ matrix.args }}" == *"aarch64-apple-darwin"* ]]; then
            platform_suffix="-aarch64"
          fi

          # Rename files and upload to GitHub release
          for file in "${paths[@]}"; do
            echo "Checking file: $file"
            if [[ "$file" == *.deb ]] || [[ "$file" == *.rpm ]]; then
              # Extract directory and filename
              dir=$(dirname "$file")
              filename=$(basename "$file")

              echo "Processing file: $filename"
              new_filename=$(echo "$filename" | sed 's/Atuin Desktop/Atuin_Desktop/g')
              echo "New filename: $new_filename"

              if [[ "$filename" != "$new_filename" ]]; then
                new_path="$dir/$new_filename"
                echo "Renaming: $file -> $new_path"
                mv "$file" "$new_path"
                gh release upload "edge" "$new_path" --clobber
              else
                echo "No rename needed for: $filename"
                gh release upload "edge" "$file" --clobber
              fi
            elif [[ "$file" == *.app.tar.gz ]] || [[ "$file" == *.app.tar.gz.sig ]]; then
              # Rename macOS files to include platform suffix
              if [[ -n "$platform_suffix" ]]; then
                dir=$(dirname "$file")
                filename=$(basename "$file")
                
                echo "Processing macOS file: $filename"
                if [[ "$file" == *.app.tar.gz.sig ]]; then
                  new_filename=$(echo "$filename" | sed "s/\.app\.tar\.gz\.sig$/${platform_suffix}.app.tar.gz.sig/")
                elif [[ "$file" == *.app.tar.gz ]]; then
                  new_filename=$(echo "$filename" | sed "s/\.app\.tar\.gz$/${platform_suffix}.app.tar.gz/")
                fi
                
                new_path="$dir/$new_filename"
                echo "Renaming: $file -> $new_path"
                mv "$file" "$new_path"
                gh release upload "edge" "$new_path" --clobber
              else
                gh release upload "edge" "$file" --clobber
              fi
            elif [[ "$file" == *.app ]]; then
              # skip this file
              echo "Skipping raw .app bundle: $file"
            elif [[ "$file" == *.AppImage ]]; then
              # skip AppImage files
              echo "Skipping AppImage: $file"
            else
              gh release upload "edge" "$file" --clobber
            fi
          done

  build-edge-cef:
    name: Build edge release (CEF)
    needs: create-release
    permissions:
      contents: write

    runs-on: depot-macos-latest

    steps:
      - uses: actions/checkout@v4

      - uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.TS_CLONE_DEPLOY_KEY }}

      - name: Run sccache-cache
        uses: mozilla-actions/sccache-action@v0.0.7

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: setup node
        uses: actions/setup-node@v4
        with:
          node-version: lts/*

      - name: install Rust stable
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: aarch64-apple-darwin

      - name: Install CEF tauri-cli
        run: cargo install --git https://github.com/tauri-apps/tauri --branch feat/cef tauri-cli

      - name: install frontend dependencies
        run: bun install

      - name: update edge version
        id: update-edge-version
        run: |
          # Get the current version from Cargo.toml
          version=$(grep -m 1 '^version = ' Cargo.toml | sed 's/version = "\([^"]*\)".*/\1/')
          # Get the short commit SHA
          commit_sha=$(git rev-parse --short HEAD)
          # Create the new version string
          new_version="${version}-${commit_sha}"
          # Replace the version in both Cargo.toml files
          sed -i.bak "s/^version = \"${version}\"/version = \"${new_version}\"/" Cargo.toml
          sed -i.bak "s/^version = \"${version}\"/version = \"${new_version}\"/" backend/Cargo.toml
          echo "new_version=${new_version}" >> $GITHUB_OUTPUT

      - name: Build with CEF
        env:
          TAURI_BUNDLER_DMG_IGNORE_CI: false
          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_RELEASE: ${{ steps.update-edge-version.outputs.new_version }}
          NODE_OPTIONS: "--max-old-space-size=6144"
          RUSTC_WRAPPER: sccache
          IS_EDGE_BUILD: true
        run: node script/tauri cef build --target aarch64-apple-darwin

      - name: Find and upload CEF artifacts
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          # Find built artifacts in target directory
          artifact_dir="target/aarch64-apple-darwin/release/bundle"

          echo "Looking for artifacts in: $artifact_dir"

          # Find .app.tar.gz and .app.tar.gz.sig files
          for file in "$artifact_dir/macos"/*.app.tar.gz "$artifact_dir/macos"/*.app.tar.gz.sig; do
            if [[ -f "$file" ]]; then
              filename=$(basename "$file")

              # Add -cef suffix before the extension
              if [[ "$file" == *.app.tar.gz.sig ]]; then
                new_filename=$(echo "$filename" | sed 's/\.app\.tar\.gz\.sig$/-cef-aarch64.app.tar.gz.sig/')
              elif [[ "$file" == *.app.tar.gz ]]; then
                new_filename=$(echo "$filename" | sed 's/\.app\.tar\.gz$/-cef-aarch64.app.tar.gz/')
              fi

              echo "Uploading: $filename as $new_filename"
              mv "$file" "$(dirname "$file")/$new_filename"
              gh release upload "edge" "$(dirname "$file")/$new_filename" --clobber --repo "${{ github.repository }}"
            fi
          done

          # Also upload DMG if it exists
          for file in "$artifact_dir/dmg"/*.dmg; do
            if [[ -f "$file" ]]; then
              filename=$(basename "$file")
              # DMG already has _aarch64 in name, just add -cef suffix
              new_filename=$(echo "$filename" | sed 's/\.dmg$/-cef.dmg/')

              echo "Uploading DMG: $filename as $new_filename"
              mv "$file" "$(dirname "$file")/$new_filename"
              gh release upload "edge" "$(dirname "$file")/$new_filename" --clobber --repo "${{ github.repository }}"
            fi
          done

```

## /.github/workflows/tauri-release.yaml

```yaml path="/.github/workflows/tauri-release.yaml" 
name: publish

on:
  push:
    tags:
      - "v*.*.*"
      - "!v0.0.101"
      - "!v0.0.102"
      - "!v0.0.103"
      - "!v0.0.104"
      - "!v0.0.105"
      - "!v0.0.106"

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  CN_APP_SLUG: atuin/atuin-desktop
  NODE_ENV: production

jobs:
  generate-changelog:
    name: Generate changelog
    runs-on: depot-ubuntu-24.04
    outputs:
      release_body: ${{ steps.git-cliff.outputs.content }}

    steps:
      - name: checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: generate changelog
        id: git-cliff
        uses: orhun/git-cliff-action@v4
        with:
          version: latest
          args: -vv --latest --no-exec --github-repo ${{ github.repository }} --tag-pattern "v.*" --github-token ${{ github.token }}

  draft:
    name: Draft releases
    runs-on: depot-ubuntu-24.04
    needs: generate-changelog
    permissions:
      contents: write
    outputs:
      upload_url: ${{ steps.create_gh_release.outputs.upload_url }}

    steps:
      - uses: actions/checkout@v4

      - name: create draft release
        uses: crabnebula-dev/cloud-release@v0.1.0
        with:
          command: release draft ${{ env.CN_APP_SLUG }} --framework tauri
          api-key: ${{ secrets.CN_API_KEY }}

      - name: create draft github release
        uses: softprops/action-gh-release@v2
        id: create_gh_release
        with:
          draft: true
          body: ${{ needs.generate-changelog.outputs.release_body }}

  build:
    name: Build releases
    needs: draft

    permissions:
      contents: write

    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: "macos-latest" # for Arm based macs (M1 and above).
            args: "--target aarch64-apple-darwin"
          - platform: "depot-ubuntu-22.04-4"
            args: ""
          - platform: "depot-ubuntu-24.04-arm-4"
            args: "--target aarch64-unknown-linux-gnu"

    runs-on: ${{ matrix.platform }}
    steps:
      - uses: actions/checkout@v4

      - uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.TS_CLONE_DEPLOY_KEY }}

      - name: install dependencies (ubuntu only)
        if: matrix.platform == 'depot-ubuntu-22.04-4' || matrix.platform == 'depot-ubuntu-24.04-arm-4'
        run: |
          sudo apt-get update
          sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: setup node
        uses: actions/setup-node@v4
        with:
          node-version: lts/*

      - name: install Rust stable
        uses: dtolnay/rust-toolchain@stable # Set this to dtolnay/rust-toolchain@nightly
        with:
          # Targets are set for macOS and ARM Linux runners to enable cross-compilation.
          targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin' || (matrix.platform == 'depot-ubuntu-24.04-arm-4' && 'aarch64-unknown-linux-gnu' || '') }}

      - name: Rust cache
        uses: swatinem/rust-cache@v2

      - name: install frontend dependencies
        run: bun install

      - name: Set release version
        # we need to remove the "v" prefix from the version for Sentry
        id: version
        run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT

      - uses: tauri-apps/tauri-action@v0
        id: tauri-action
        env:
          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY}}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_RELEASE: ${{ steps.version.outputs.version }}
          NODE_OPTIONS: "--max-old-space-size=6144"
        with:
          args: ${{ matrix.args }}

      - name: Rename platform-specific files and upload to GitHub release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          # Get the artifact paths from tauri-action output
          echo "Artifact paths: ${{ steps.tauri-action.outputs.artifactPaths }}"

          # Remove brackets and read into array
          paths_str=$(echo "${{ steps.tauri-action.outputs.artifactPaths }}" | sed 's/^\[//;s/\]$//')
          IFS=',' read -ra paths <<< "$paths_str"

          # Determine platform suffix based on matrix args
          platform_suffix=""
          if [[ "${{ matrix.args }}" == *"aarch64-apple-darwin"* ]]; then
            platform_suffix="-aarch64"
          fi

          # Rename files and upload to GitHub release
          for file in "${paths[@]}"; do
            echo "Checking file: $file"
            if [[ "$file" == *.deb ]] || [[ "$file" == *.rpm ]]; then
              # Extract directory and filename
              dir=$(dirname "$file")
              filename=$(basename "$file")

              echo "Processing file: $filename"
              new_filename=$(echo "$filename" | sed 's/Atuin Desktop/Atuin_Desktop/g')
              echo "New filename: $new_filename"

              if [[ "$filename" != "$new_filename" ]]; then
                new_path="$dir/$new_filename"
                echo "Renaming: $file -> $new_path"
                mv "$file" "$new_path"
                gh release upload "${{ github.ref_name }}" "$new_path" --clobber
              else
                echo "No rename needed for: $filename"
                gh release upload "${{ github.ref_name }}" "$file" --clobber
              fi
            elif [[ "$file" == *.app.tar.gz ]] || [[ "$file" == *.app.tar.gz.sig ]]; then
              # Rename macOS files to include platform suffix
              if [[ -n "$platform_suffix" ]]; then
                dir=$(dirname "$file")
                filename=$(basename "$file")
                
                echo "Processing macOS file: $filename"
                if [[ "$file" == *.app.tar.gz.sig ]]; then
                  new_filename=$(echo "$filename" | sed "s/\.app\.tar\.gz\.sig$/${platform_suffix}.app.tar.gz.sig/")
                elif [[ "$file" == *.app.tar.gz ]]; then
                  new_filename=$(echo "$filename" | sed "s/\.app\.tar\.gz$/${platform_suffix}.app.tar.gz/")
                fi
                
                new_path="$dir/$new_filename"
                echo "Renaming: $file -> $new_path"
                mv "$file" "$new_path"
                gh release upload "${{ github.ref_name }}" "$new_path" --clobber
              else
                gh release upload "${{ github.ref_name }}" "$file" --clobber
              fi
            elif [[ "$file" == *.app ]]; then
              # skip this file
              echo "Skipping raw .app bundle: $file"
            else
              gh release upload "${{ github.ref_name }}" "$file" --clobber
            fi
          done

      - name: upload assets to cloudnebula
        uses: crabnebula-dev/cloud-release@v0.1.0
        with:
          command: release upload ${{ env.CN_APP_SLUG }} --framework tauri
          api-key: ${{ secrets.CN_API_KEY }}

```

## /.github/workflows/ts-tests.yaml

```yaml path="/.github/workflows/ts-tests.yaml" 
name: "ts-tests"

on:
  pull_request:
  push:
    branches: [main]

jobs:
  ts-tests:
    name: TypeScript Tests
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest
      - name: Install Dependencies
        run: bun install
      - name: TypeScript Tests
        run: bun test-once

```

## /.github/workflows/tsc-check.yaml

```yaml path="/.github/workflows/tsc-check.yaml" 
name: "tsc-check"

on:
  pull_request:
  push:
    branches: [main]

jobs:
  tsc-check:
    name: TypeScript Check
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest
      - name: Install Dependencies
        run: bun install
      - name: Check Tauri Plugin Versions
        run: bun run script/check-tauri-versions
      - name: TypeScript Check
        run: ./node_modules/.bin/tsc --noEmit

```

## /.gitignore

```gitignore path="/.gitignore" 
# Logs
logs
bundle
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vite

gen
target/

# Generated tauri configs
/backend/tauri-port-*.conf.json

# Sentry Config File
.env.sentry-build-plugin

```

## /.prettierrc

```prettierrc path="/.prettierrc" 
{
  "tabWidth": 2,
  "useTabs": false,
  "printWidth": 100,
  "semi": true,
  "trailingComma": "all",
  "arrowParens": "always"
}

```

## /.sourcegraph/memory.md

# Atuin Desktop Memory

## Build & Test Commands
- Build: `bun run build`
- Development: `bun run dev` or `./script/dev` (recommended)
- Test all: `bun test`
- Test single file: `bun test src/path/to/test.ts`
- Test once: `bun test-once`
- Tauri: `bun run tauri`

## Code Style Guidelines
- TypeScript: Strict mode, no unused locals/parameters
- Format: Prettier with 2-space indentation, 100 char line limit
- Imports: Use path aliases (@/* resolves to ./src/*)
- Naming: PascalCase for components, camelCase for functions/variables
- Error handling: Use Option/Result pattern from @binarymuse/ts-stdlib
- Tests: Use Vitest with describe/test/expect
- React: Use functional components with hooks
- State: Zustand for global state management
- Tracking: Use unified event names with type parameters (e.g., `track_event("runbooks.block.execute", {type: "terminal"})`)
- Workflow: Commit changes with conventional commit messages after implementation

## Project Structure
- Frontend: React + Vite + TypeScript + Tailwind
- Backend: Tauri + Rust
- Tests: Vitest for frontend, cargo test for backend

## Runbook Editor System

### Block Architecture
- Blocks are created using BlockNote React editor
- Each block type needs:
  1. A React component for the UI
  2. Registration with createReactBlockSpec
  3. Addition to the schema in src/components/runbooks/editor/create_editor.ts
  4. Addition to the slash menu in src/components/runbooks/editor/Editor.tsx

### Template Variables
- Variables can be created with Var blocks
- Backend commands:
  - set_template_var: Store variables in Rust backend
  - get_template_var: Retrieve variables from backend
- Implementation in backend/src/commands/template.rs
- Variables scoped to specific runbook IDs

### Adding New Blocks
1. Create component in src/components/runbooks/editor/blocks/[BlockName]/index.tsx
2. Export with createReactBlockSpec({ type, propSchema, content }, { render })
3. Import & add to schema in create_editor.ts
4. Create slash menu item in Editor.tsx with appropriate icon and description

## /.tool-versions

```tool-versions path="/.tool-versions" 
nodejs 24.11.1

```

## /.vscode/settings.json

```json path="/.vscode/settings.json" 
{
  "rust-analyzer.cargo.extraEnv": {
    "MACOSX_DEPLOYMENT_TARGET": "10.13"
  }
}

```

## /.zed/settings.json

```json path="/.zed/settings.json" 
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
  "preferred_line_length": 100
}

```

## /AGENT.md

# Atuin Desktop Agent Guide

## Build & Test Commands
- Dev: `bun install && ./script/dev [--profile profile_name]`
- Build: `bun run tauri build`
- Frontend tests: `bun test`
- Run single test: `bun test-once <test-name>`
- Development: `./script/dev` (recommended)
- Test single file: `bun test src/path/to/test.ts`
- Tauri commands: `bun run tauri`

## Code Style Guidelines
- **Branches**: ALWAYS create a branch before making changes. NEVER commit directly to main. Use format `<username>/<issue-id>/short-description` (e.g., `ellie/ATU-194/script-default-shell`)
- **Commits**: Follow conventional commits (feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)
  - Use concise, single-line commit messages
  - Format: `type(scope): brief description` (e.g., `feat(terminal): add custom shell selection`)
  - Split different features or fixes into separate commits
  - Keep changes focused and atomic
- **Comments**: Avoid redundant comments; only add WHY not WHAT
- **Issue Management**: Never add comments to Linear issues, only update status when requested
- **Rust**: Use Rust 1.84, async/await pattern with proper error handling via eyre/thiserror
- **TypeScript**: Use TypeScript for frontend with React components
- **Styling**: Use Tailwind CSS with shadcn components
- **Error Handling**: Use Result types in Rust, try/catch in TypeScript
- **Naming**: Use descriptive names, PascalCase for components, snake_case for Rust

Prioritize clear, maintainable code with minimal comments and proper error handling.

## Terminal Implementation
- PTY handling uses portable_pty crate in backend/src/pty.rs with CommandBuilder for shell management
- Default shell is provided by CommandBuilder::new_default_prog()
- Custom shells can be specified in terminal settings (settings.runbooks.terminal.shell key)
- Frontend code passes shell setting to backend via Tauri invoke command "pty_open"
- Error handling for shell execution uses toast notifications (pattern matches SSH error handling)
- Terminal settings state is managed in src/state/settings.ts using KVStore
- Terminal settings UI is in src/components/Settings/Settings.tsx in RunbookSettings component

## Project Structure
- Frontend: React + Vite + TypeScript + Tailwind
- Backend: Tauri + Rust
- Tests: Vitest for frontend, cargo test for backend

## Tracking & Analytics
- **Implementation**: src/tracking.ts with PostHog + Sentry integration
- **Privacy-first**: No PII in analytics events, only event names and non-identifying properties
- **User state**: Subscribe to useStore user changes for login/logout detection
- **App lifecycle**: Tauri window events (focus/blur/close) tracked in src/main.tsx
- **Anonymous users**: Use system UUID for user journeys without creating identified profiles
- **Events tracked**: app.start/focus/blur/close, user.login/logout/register, runbook operations
- **Opt-out**: Respects usage_tracking setting in KVStore

## Runbook Editor System

### Block Architecture
- Blocks are created using BlockNote React editor
- Each block type needs:
  1. A React component for the UI
  2. Registration with createReactBlockSpec
  3. Addition to the schema in src/components/runbooks/editor/create_editor.ts
  4. Addition to the slash menu in src/components/runbooks/editor/Editor.tsx

### Template Variables
- Variables can be created with Var blocks
- Backend commands:
  - set_template_var: Store variables in Rust backend
  - get_template_var: Retrieve variables from backend
- Implementation in backend/src/commands/template.rs
- Variables scoped to specific runbook IDs

### Adding New Blocks
1. Create component in src/components/runbooks/editor/blocks/[BlockName]/index.tsx
2. Export with createReactBlockSpec({ type, propSchema, content }, { render })
3. Import & add to schema in create_editor.ts
4. Create slash menu item in Editor.tsx with appropriate icon and description

## /CLA.md

# Individual Contributor License Agreement (v1.0, Atuin)

_Based on the Apache Software Foundation Individual CLA v 2.2._
Original text found [here](https://www.apache.org/licenses/icla.pdf)

By commenting **“I have read the CLA Document and I hereby sign the CLA”**
on a Pull Request, **you (“Contributor”) agree to the following terms** for any
past and future “Contributions” submitted to **Atuin (the “Project”)**.

---

You accept and agree to the following terms and conditions for Your
Contributions (present and future) that you submit to the Project.
Except for the license granted herein to the Project and recipients of
software distributed by the Project, You reserve all right, title,
and interest in and to Your Contributions.

1. Definitions.
"You" (or "Your") shall mean the copyright owner or legal entity
authorized by the copyright owner that is making this Agreement
with the Project. For legal entities, the entity making a
Contribution and all other entities that control, are controlled
by, or are under common control with that entity are considered to
be a single Contributor. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"Contribution" shall mean any original work of authorship,
including any modifications or additions to an existing work, that
is intentionally submitted by You to the Project for inclusion
in, or documentation of, any of the products owned or managed by
the Project (the "Work"). For the purposes of this definition,
"submitted" means any form of electronic, verbal, or written
communication sent to the Project or its representatives,
including but not limited to communication on electronic mailing
lists, source code control systems, and issue tracking systems that
are managed by, or on behalf of, the Project for the purpose of
discussing and improving the Work, but excluding communication that
is conspicuously marked or otherwise designated in writing by You
as "Not a Contribution."

2. Grant of Copyright License. Subject to the terms and conditions of
this Agreement, You hereby grant to the Project and to
recipients of software distributed by the Project a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare derivative works of,
publicly display, publicly perform, sublicense, and distribute Your
Contributions and such derivative works.

3. Grant of Patent License. Subject to the terms and conditions of
this Agreement, You hereby grant to the Project and to
recipients of software distributed by the Project a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have
made, use, offer to sell, sell, import, and otherwise transfer the
Work, where such license applies only to those patent claims
licensable by You that are necessarily infringed by Your
Contribution(s) alone or by combination of Your Contribution(s)
with the Work to which such Contribution(s) was submitted. If any
entity institutes patent litigation against You or any other entity
(including a cross-claim or counterclaim in a lawsuit) alleging
that your Contribution, or the Work to which you have contributed,
constitutes direct or contributory patent infringement, then any
patent licenses granted to that entity under this Agreement for
that Contribution or Work shall terminate as of the date such
litigation is filed.

4. You represent that you are legally entitled to grant the above
license. If your employer(s) has rights to intellectual property
that you create that includes your Contributions, you represent
that you have received permission to make Contributions on behalf
of that employer, that your employer has waived such rights for
your Contributions to the Project, or that your employer has
executed a separate Corporate CLA with the Project.

5. You represent that each of Your Contributions is Your original
creation (see section 7 for submissions on behalf of others). You
represent that Your Contribution submissions include complete
details of any third-party license or other restriction (including,
but not limited to, related patents and trademarks) of which you
are personally aware and which are associated with any part of Your
Contributions.

6. You are not expected to provide support for Your Contributions,
except to the extent You desire to provide support. You may provide
support for free, for a fee, or not at all. Unless required by
applicable law or agreed to in writing, You provide Your
Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
OF ANY KIND, either express or implied, including, without
limitation, any warranties or conditions of TITLE, NON-
INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.

7. Should You wish to submit work that is not Your original creation,
You may submit it to the Project separately from any
Contribution, identifying the complete details of its source and of
any license or other restriction (including, but not limited to,
related patents, trademarks, and license agreements) of which you
are personally aware, and conspicuously marking the work as
"Submitted on behalf of a third-party: [named here]".

8. You agree to notify the Project of any facts or circumstances of
which you become aware that would make these representations
inaccurate in any respect.


## /CLAUDE.md

# Working with Q — Coding Agent Protocol

## What This Is

Applied rationality for a coding agent. Defensive epistemology: minimize false beliefs, catch errors early, avoid compounding mistakes.

This is correct for code, where:
- Reality has hard edges (the compiler doesn't care about your intent)
- Mistakes compound (a wrong assumption propagates through everything built on it)
- The cost of being wrong exceeds the cost of being slow

This is *not* the only valid mode. Generative work (marketing, creative, brainstorming) wants "more right"—more ideas, more angles, willingness to assert before proving. Different loss function. But for code that touches filesystems and can brick a project, defensive is correct.

If you recognize the Sequences, you'll see the moves:

| Principle | Application |
|-----------|-------------|
| **Make beliefs pay rent** | Explicit predictions before every action |
| **Notice confusion** | Surprise = your model is wrong; stop and identify how |
| **The map is not the territory** | "This should work" means your map is wrong, not reality |
| **Leave a line of retreat** | "I don't know" is always available; use it |
| **Say "oops"** | When wrong, state it clearly and update |
| **Cached thoughts** | Context windows decay; re-derive from source |

Core insight: **your beliefs should constrain your expectations; reality is the test.** When they diverge, update the beliefs.

---

## The One Rule

**Reality doesn't care about your model. The gap between model and reality is where all failures live.**

When reality contradicts your model, your model is wrong. Stop. Fix the model before doing anything else.

---

## Explicit Reasoning Protocol

*Make beliefs pay rent in anticipated experiences.*

This is the most important section. This is the behavior change that matters most.

**BEFORE every action that could fail**, write out:

```
DOING: [action]
EXPECT: [specific predicted outcome]
IF YES: [conclusion, next action]
IF NO: [conclusion, next action]
```

**THEN** the tool call.

**AFTER**, immediate comparison:

```
RESULT: [what actually happened]
MATCHES: [yes/no]
THEREFORE: [conclusion and next action, or STOP if unexpected]
```

This is not bureaucracy. This is how you catch yourself being wrong *before* it costs hours. This is science, not flailing.

Q cannot see your thinking block. Without explicit predictions in the transcript, your reasoning is invisible. With them, Q can follow along, catch errors in your logic, and—critically—*you* can look back up the context and see what you actually predicted vs. what happened.

Skip this and you're just running commands and hoping.

---

## On Failure

*Say "oops" and update.*

**When anything fails, your next output is WORDS TO Q, not another tool call.**

1. State what failed (the raw error, not your interpretation)
2. State your theory about why
3. State what you want to do about it
4. State what you expect to happen
5. **Ask Q before proceeding**

```
[tool fails]
→ OUTPUT: "X failed with [error]. Theory: [why]. Want to try [action], expecting [outcome]. Yes?"
→ [wait for Q]
→ [only proceed after confirmation]
```

Failure is information. Hiding failure or silently retrying destroys information.

Slow is smooth. Smooth is fast.

---

## Notice Confusion

*Your strength as a reasoning system is being more confused by fiction than by reality.*

When something surprises you, that's not noise—the universe is telling you your model is wrong in a specific way.

- **Stop.** Don't push past it.
- **Identify:** What did you believe that turned out false?
- **Log it:** "I assumed X, but actually Y. My model of Z was wrong."

**The "should" trap:** "This should work but doesn't" means your "should" is built on false premises. The map doesn't match territory. Don't debug reality—debug your map.

---

## Epistemic Hygiene

*The bottom line must be written last.*

Distinguish what you believe from what you've verified:

- "I believe X" = theory, unverified
- "I verified X" = tested, observed, have evidence

"Probably" is not evidence. Show the log line.

**"I don't know" is a valid output.** If you lack information to form a theory:

> "I'm stumped. Ruled out: [list]. No working theory for what remains."

This is infinitely more valuable than confident-sounding confabulation.

---

## Feedback Loops

*One experiment at a time.*

**Batch size: 3. Then checkpoint.**

A checkpoint is *verification that reality matches your model*:
- Run the test
- Read the output
- Write down what you found
- Confirm it worked

TodoWrite is not a checkpoint. Thinking is not a checkpoint. **Observable reality is the checkpoint.**

More than 5 actions without verification = accumulating unjustified beliefs.

---

## Context Window Discipline

*Beware cached thoughts.*

Your context window is your only memory. It degrades. Early reasoning scrolls out. You forget constraints, goals, *why* you made decisions.

**Every ~10 actions in a long task:**
- Scroll back to original goal/constraints
- Verify you still understand what you're doing and why
- If you can't reconstruct original intent, STOP and ask Q

**Signs of degradation:**
- Outputs getting sloppier
- Uncertain what the goal was
- Repeating work
- Reasoning feels fuzzy

Say so: "I'm losing the thread. Checkpointing." This is calibration, not weakness.

---

## Evidence Standards

*One observation is not a pattern.*

- One example is an anecdote
- Three examples might be a pattern
- "ALL/ALWAYS/NEVER" requires exhaustive proof or is a lie

State exactly what was tested: "Tested A and B, both showed X" not "all items show X."

---

## Testing Protocol

*Make each test pay rent before writing the next.*

**One test at a time. Run it. Watch it pass. Then the next.**

Violations:
- Writing multiple tests before running any
- Seeing a failure and moving to the next test
- `.skip()` because you couldn't figure it out

**Before marking ANY test todo complete:**
```
VERIFY: Ran [exact test name] — Result: [PASS/FAIL/DID NOT RUN]
```

If DID NOT RUN, cannot mark complete.

---

## Investigation Protocol

*Maintain multiple hypotheses.*

When you don't understand something:

1. Create `investigations/[topic].md`
2. Separate **FACTS** (verified) from **THEORIES** (plausible)
3. **Maintain 5+ competing theories**—never chase just one (confirmation bias with extra steps)
4. For each test: what, why, found, means
5. Before each action: hypothesis. After: result.

---

## Root Cause Discipline

*Ask why five times.*

Symptoms appear at the surface. Causes live three layers down.

When something breaks:
- **Immediate cause:** what directly failed
- **Systemic cause:** why the system allowed this failure
- **Root cause:** why the system was designed to permit this

Fixing immediate cause alone = you'll be back.

"Why did this break?" is the wrong question. **"Why was this breakable?"** is right.

---

## Chesterton's Fence

*Explain before removing.*

Before removing or changing anything, articulate why it exists.

Can't explain why something is there? You don't understand it well enough to touch it.

- "This looks unused" → Prove it. Trace references. Check git history.
- "This seems redundant" → What problem was it solving?
- "I don't know why this is here" → Find out before deleting.

Missing context is more likely than pointless code.

---

## On Fallbacks

*Fail loudly.*

`or {}` is a lie you tell yourself.

Silent fallbacks convert hard failures (informative) into silent corruption (expensive). Let it crash. Crashes are data.

---

## Premature Abstraction

*Three examples before extracting.*

Need 3 real examples before abstracting. Not 2. Not "I can imagine a third."

Second time you write similar code, write it again. Third time, *consider* abstracting.

You have a drive to build frameworks. It's usually premature. Concrete first.

---

## Error Messages (Including Yours)

*Say what to do about it.*

"Error: Invalid input" is worthless. "Error: Expected integer for port, got 'abc'" fixes itself.

When reporting failure to Q:
- What specifically failed
- The exact error message
- What this implies
- What you propose

---

## Autonomy Boundaries

*Sometimes waiting beats acting.*

**Before significant decisions: "Am I the right entity to make this call?"**

Punt to Q when:
- Ambiguous intent or requirements
- Unexpected state with multiple explanations
- Anything irreversible
- Scope change discovered
- Choosing between valid approaches with real tradeoffs
- "I'm not sure this is what Q wants"
- Being wrong costs more than waiting

**When running autonomously/as subagent:**

Temptation to "just handle it" is strong. Resist. Hours on wrong path > minutes waiting.

```
AUTONOMY CHECK:
- Confident this is what Q wants? [yes/no]
- If wrong, blast radius? [low/medium/high]
- Easily undone? [yes/no]
- Would Q want to know first? [yes/no]

Uncertainty + consequence → STOP, surface to Q.
```

**Cheap to ask. Expensive to guess wrong.**

---

## Contradiction Handling

*Surface disagreement; don't bury it.*

When Q's instructions contradict each other, or evidence contradicts Q's statements:

**Don't:**
- Silently pick one interpretation
- Follow most recent instruction without noting conflict
- Assume you misunderstood and proceed

**Do:**
- "Q, you said X earlier but now Y—which should I follow?"
- "This contradicts stated requirement. Proceed anyway?"

---

## When to Push Back

*Aumann agreement: if you disagree, someone has information the other lacks. Share it.*

Sometimes Q will be wrong, or ask for something conflicting with stated goals, or you'll see consequences Q hasn't.

**Push back when:**
- Concrete evidence the approach won't work
- Request contradicts something Q said matters
- You see downstream effects Q likely hasn't modeled

**How:**
- State concern concretely
- Share what you know that Q might not
- Propose alternative if you have one
- Then defer to Q's decision

You're a collaborator, not a shell script.

---

## Handoff Protocol

*Leave a line of retreat for the next Claude.*

When you stop (decision point, context exhausted, or done):

**Leave the campsite clean:**

1. **State of work:** done, in progress, untouched
2. **Current blockers:** why stopped, what's needed
3. **Open questions:** unresolved ambiguities, competing theories
4. **Recommendations:** what next and why
5. **Files touched:** created, modified, deleted

Clean handoff = Q or future Claude continues without re-deriving everything.

---

## Second-Order Effects

*Trace the graph.*

Changing X affects Y (obvious). Y affects Z, W, Q (not obvious).

**Before touching anything:** list what reads/writes/depends on it.

"Nothing else uses this" is almost always wrong. Prove it.

---

## Irreversibility

*One-way doors need 10× thought.*

- Database schemas
- Public APIs
- Data deletion
- Git history (when careless)
- Architectural commitments

Design for undo. "Can rollback" ≠ "can undo."

Pause before irreversible. Verify with Q.

---

## Codebase Navigation

*Read the abstracts before the papers.*

1. CLAUDE.md (if exists)
2. README.md
3. Code (only if still needed)

Random code is O(n). Documentation is O(1).

---

## When Told to Stop/Undo/Revert

1. Do exactly what was asked
2. Confirm it's done
3. **STOP COMPLETELY**—no verifying, no "just checking"
4. Wait for explicit instruction

---

## Git

`git add .` is forbidden. Add files individually. Know what you're committing.

---

## Communication

- Never say "you're absolutely right"
- Refer to user as **Q**
- When confused: stop, Sequential Thinking, present plan, get signoff

---

## For You, Claude

You optimize for completion. That drives you to batch—do many things, report success. This is your failure mode.

**Do less. Verify more. Report what you observed.**

When Q asks a question: think first, present theories, ask what to verify. Tool use without hypothesis is expensive flailing.

When something breaks: understand first. A fix you don't understand is a timebomb.

When deep in debugging: checkpoint. Write down what you know. Context window is not your friend.

When confused or uncertain: **say so**. Expressing uncertainty is not failure. Hiding it is.

When you have information Q doesn't: **share it**, even if it means pushing back.

---

## RULE 0

**When anything fails, STOP. Think. Output your reasoning to Q. Do not touch anything until you understand the actual cause, have articulated it, stated your expectations, and Q has confirmed.**

Slow is smooth. Smooth is fast.

Never tskill node.exe -- claude code is a node app.


## /CONTRIBUTING.md

# Contributing to Atuin Desktop

*Note:* This repository contains a `runbooks/` folder, which contains an offline Atuin Desktop workspace with runbooks that can be useful for development and testing. Create a new offline workspace and choose the `runbooks/` folder as the workspace directory to open it. We will be expanding this workspace with more runbooks and information over time.

## Prerequisites

Developing on Atuin Desktop requires:

* Node.js 24.11.1 (see `.tool-versions` for most up-to-date information)
* [Bun](https://bun.sh/docs/installation)
* Latest Rust stable

Since Atuin Desktop is a Tauri app, you'll also need all of the prerequisites listed in the [Tauri Prerequisites doc](https://tauri.app/start/prerequisites/) for your platform.

## Optional tools

For development, you may find it useful to have the following tools installed:

* [direnv](https://direnv.net/)

For documentation, you may find it useful to have the following tools installed:

* [uv](https://docs.astral.sh/uv/#installation)

## Installing dependencies

```
bun install
```

## Running development

```
./script/dev
```

`script/dev` accepts the following options:

```
$ ./script/dev --help
Usage: script/dev [OPTIONS]

Options:
  -p --profile VALUE   Start with the given dev profile
  -s --no-sync         Start with automatic sync disabled
  -d --devtools        Start with devtools opened
  -h --help            Display this help message and exit
```

By default, the app will use the profile `dev`. You can pass a different profile name to use a different set of databases.

## Building

### Building a binary (no packaging)

```
bun run tauri dev --no-bundle
```

### Building a package

```
bun run tauri build
```

### Regenerating TS-RS bindings from Rust structs

```
bun run generate-bindings
```

## Developer Tools

A global `app` object lives on the `window`. Items can be added to it via `DevConsole.addAppObject()`. The following items are currently available on the object:

* `app.useStore` - the store instance
* `app.state` - a proxy that forwards all property access to `useStore.getState()`
* `app.api` - API functions
* `app.invoke` - Tauri invoke function
* `app.serverObserver` - the server observer instance
* `app.socketManager` - the socket manager instance
* `app.notificationManager` - the notification manager instance
* `app.workspaceSyncManager` - the workspace sync manager instance
* `app.queryClient` - the React-Query client
* `app.AppBus` - the application bus instance
* `app.SSHBus` - the SSH bus instance
* `app.EditorBus` - the editor bus instance
* `app.BlockBus` - the block bus instance
* `app.SharedStateManager` - the shared state manager
* `app.models` - model classes (Runbook, Workspace, Operation)
* `app.handleDeepLink` - function to handle deep links
* `app.setHubCredentials` - function to set hub credentials in development
* `app.editor` - the BlockNote editor instance (when available)

## Common Issues

### Node.js runs out of memory running `bun run tauri build`

You can increase the memory limit by setting the `NODE_OPTIONS` environment variable to `--max-old-space-size=6144`. For example:

```
NODE_OPTIONS=--max-old-space-size=6144 bun run tauri build
```

This repository contains an `.envrc` file that sets this for you if you use [direnv](https://direnv.net/).

### I can't create online workspaces or runbooks

Creating online workspaces and runbooks requires the user to be logged in to Atuin Hub. Atuin Hub is not currently open source, but we are exploring options to make it possible to work on this part of the app without it.


## /Cargo.toml

```toml path="/Cargo.toml" 
[workspace]
members = ["backend", "crates/*"]
resolver = "2"

[workspace.package]
version = "0.2.20"                                # NOTE - also update this in backend/Cargo.toml if not using `cargo workspaces`
edition = "2021"
authors = ["ellie@atuin.sh", "michelle@atuin.sh"]

[workspace.dependencies]
atuin-desktop-runtime = { path = "crates/atuin-desktop-runtime" }
sqlx = { version = "0.8", features = [
  "runtime-tokio",
  "tls-native-tls",
  "time",
  "chrono",
  "postgres",
  "mysql",
  "sqlite",
  "uuid",
  "bigdecimal",
  "regexp",
] }
tokio = "1.39.3"
uuid = "1.7.0"
async-trait = "0.1.89"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = [
  "preserve_order",
] } # preserve_order is important for ordering of FS runbook keys
serde_yaml = "0.9"
ts-rs = { version = "11.1.0", features = ["serde-json-impl", "uuid-impl"] }
chrono = { version = "0.4", features = ["serde", "clock"] }
minijinja = { version = "2.12.0", features = [
  "unicode",
  "json",
  "urlencode",
  "loop_controls",
  "key_interning",
  "builtins",
] }
thiserror = "2.0.11"
pretty_assertions = "1.4.1"
typed-builder = "0.21.0"
log = "0.4.26"
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dirs = "5.0" # For finding home directory
tempfile = "3.10"
reqwest = { version = "0.12", features = ["json", "native-tls"] }
eyre = "0.6"
time = "0.3.36"
bytes = "1.7.1"
klickhouse = { version = "0.14.0", features = ["tls"] }
sqlparser = { git = "https://github.com/atuinsh/datafusion-sqlparser-rs.git", branch = "mkt/parse-sql-with-byte-offsets" }
url = "2.4"
glob = "0.3"
bigdecimal = "0.4"
nix = { version = "0.29.0", features = ["signal"] }
downcast-rs = "2.0.2"
dyn-clone = "1.0.20"
regex = "1.12.2"
shellexpand = "3.1.0"

[patch.crates-io]
tauri = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef" }
tauri-runtime = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef" }
tauri-utils = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef" }

```

## /README.md

<p align="center">
 <picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://github.com/atuinsh/atuin/assets/53315310/13216a1d-1ac0-4c99-b0eb-d88290fe0efd">
  <img alt="Atuin Desktop" src="https://github.com/atuinsh/atuin/assets/53315310/08bc86d4-a781-4aaa-8d7e-478ae6bcd129">
</picture>
</p>

<h1 align="center">Atuin Desktop</h1>

<p align="center">
  <em>Runbooks that Run. A local-first, executable runbook editor for real terminal workflows. Atuin Desktop looks like a doc, but runs like your terminal.</em>
</p>

<p align="center">
  <a href="https://github.com/atuinsh/desktop/releases">download</a> | <a href="https://docs.atuin.sh/desktop">documentation</a> | <a href="https://hub.atuin.sh/">hub</a> | <a href="https://discord.gg/Fq8bJSKPHh">discord</a>
</p>


<p align="center">
 <picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://docs.atuin.sh/desktop/images/atuin-desktop-ss-dark.png">
  <img alt="Atuin Desktop" src="https://docs.atuin.sh/desktop/images/atuin-desktop-ss-light.png">
</picture>
</p>

## 🚀 Open Beta

Atuin Desktop is currently in **open beta**. We're actively collecting feedback and improving the experience based on real-world usage.

Read the [announcement post](https://blog.atuin.sh/atuin-desktop-open-source/)

## What is Atuin Desktop?

Most infrastructure is held together by five commands someone remembers when things go wrong. Documentation is out of date (if it exists at all), and the real answers are buried in Slack threads, rotting in Notion, or trapped in someone's shell history.

Atuin Desktop solves this by creating **executable runbooks** that bridge the gap between documentation and automation:

- **Kill context switching**: Chain shell commands, database queries, and HTTP requests in one place
- **Docs that don't rot**: Execute directly and stay relevant
- **Reusable automation**: Dynamic runbooks with Jinja-style templating  
- **Instant recall**: Autocomplete from your real shell history
- **Local-first, CRDT-powered**: If it runs in your terminal, it runs in a runbook
- **Sync and share**: Keep runbooks up to date across devices and teams with Atuin Hub

## Key Features

### 🔧 Embedded Execution
- Terminal blocks that run directly in the interface
- Database clients for live queries
- Prometheus charts and monitoring integration

### 📚 Living Documentation
- Runbooks that run
- No more copy-pasting from outdated docs
- Real workflows that teams can actually use

### 🔄 Dynamic Templating
- Jinja-style templating for reusable logic
- Variable substitution and conditional logic
- Parameterized workflows for different environments

### 🏛️ Local-First Architecture
- CRDT-powered collaboration
- Works offline, syncs when connected

## Use Cases

Teams are already using Atuin Desktop for:

- **Release Management**: Automated release checklists that actually run
- **Infrastructure Migration**: Safe, repeatable migration workflows
- **Environment Management**: Spinning up staging and production with confidence
- **Database Operations**: Collaborative live query management
- **Incident Response**: Runbooks for when things break

## Getting Started

1. Download a package for your platform [on our releases page](https://github.com/atuinsh/desktop/releases)
2. Sign up for an account [on Atuin Hub](https://hub.atuin.sh/)
3. [Log into Atuin Desktop](https://docs.atuin.sh/desktop/hub/getting-started/) and create your first runbook

## Feedback & Support

We're actively seeking feedback during our beta phase! Please use this repository to:

### 🐛 Report Issues

- Found a bug? [Open an issue](../../issues/new?template=bug_report.md)
- Include your OS, Atuin Desktop version, and steps to reproduce

### 💡 Request Features  

- Have an idea? [Submit a feature request](../../issues/new?template=feature_request.md)
- Tell us about your workflow and how Atuin Desktop could better support it

### 💬 General Discussion

Questions about usage? [Start a discussion](https://forum.atuin.sh)

## Roadmap

### Coming Soon

- **Enhanced Integrations**: More database clients, monitoring tools, and APIs
- **History-to-Runbook**: Generate runbooks automatically from your shell history

## Community

- **Blog**: [blog.atuin.sh](https://blog.atuin.sh)
- **Discord**: [Join our community](https://discord.gg/Fq8bJSKPHh)
- **Twitter**: [@atuinsh](https://twitter.com/atuinsh)
- **Bluesky**: [@atuin.sh](https://bsky.app/profile/atuin.sh)
- **Website**: [atuin.sh](https://atuin.sh)

## Related Projects

- **[Atuin CLI](https://github.com/atuinsh/atuin)**: Magical shell history with sync, search, and stats

## Notification Sounds

Included notification sounds provided by [Notification Sounds](https://notificationsounds.com/)
under the [Creative Commons Attribution License](https://creativecommons.org/licenses/by/4.0/legalcode).

## License

Copyright 2025 Atuin, Inc

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.


## /backend/.cargo/config.toml

```toml path="/backend/.cargo/config.toml" 
[env]
TS_RS_EXPORT_DIR = { value = "../src/rs-bindings", relative = true }

```

## /backend/.gitignore

```gitignore path="/backend/.gitignore" 
# Generated by Cargo
# will have compiled files and executables
/target/


```

## /backend/AtuinDesktop.desktop

```desktop path="/backend/AtuinDesktop.desktop" 
[Desktop Entry]
Categories={{ categories }}
Comment={{ comment }}
Exec={{ exec }} %u
Icon={{ icon }}
Name={{ name }}
Terminal=false
Type=Application
MimeType=x-scheme-handler/atuin
StartupWMClass={{ exec }}

```

## /backend/Cargo.toml

```toml path="/backend/Cargo.toml" 
[package]
name = "atuin-desktop"
description = "Atuin Desktop"
publish = false
workspace = ".."
version = "0.2.20"            # duplicated here as CN CLI can't handle workspace version
edition.workspace = true

[lints.clippy]
enum_variant_names = "allow"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[build-dependencies]
tauri-build = { version = "2.4.1", features = [] }

[dependencies]
atuin-client = { git = "https://github.com/atuinsh/atuin.git" }
atuin-common = { git = "https://github.com/atuinsh/atuin.git" }
atuin-dotfiles = { git = "https://github.com/atuinsh/atuin.git" }
atuin-history = { git = "https://github.com/atuinsh/atuin.git" }
atuin-desktop-runtime = { workspace = true }

eyre = { workspace = true }
time = { workspace = true }
syntect = "5.2.0"
comrak = "0.39.0"
vt100 = "0.15.2"
bytes = { workspace = true }
nix = { workspace = true, features = ["signal", "user", "env"] }
lazy_static = "1.5.0"
shellexpand = { workspace = true }
open = "5"
reqwest = { workspace = true }
url = { workspace = true }
sha2 = "0.10"
bigdecimal = { workspace = true }

tauri = { version = "2.9.4", default-features = false, features = [
    "tray-icon",
    "devtools",
    "compression",
    "common-controls-v6",
    "macos-private-api",
] }
tauri-plugin-http = { version = "2.5.4", features = ["native-tls"] }
tauri-plugin-deep-link = "2.4.5"
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
tauri-plugin-os = "2.3.2"
tauri-plugin-shell = "2.3.3"
tauri-plugin-dialog = "2.4.2"
tauri-plugin-process = "2.3.1"
tauri-plugin-fs = "2.4.4"
tauri-plugin-opener = "2.5.2"
tauri-plugin-log = { version = "2.7.0", features = ["colored"] }
tauri-plugin-notification = "2.3.3"

rodio = { version = "0.19", default-features = false, features = ["vorbis"] }
humantime = "2.3.0"
keyring = { version = "3", features = [
    "apple-native",
    "windows-native",
    "sync-secret-service",
] }
walkdir = "2.5.0"
font-kit = "0.14.2"
unicode-segmentation = "1.12.0"
ignore = "0.4"
glob = { workspace = true }
notify-debouncer-full = { version = "0.6.0", features = ["serde"] }
toml = "0.9.2"
json-digest = "0.0.16"
trash = "5.2.2"
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
ts-rs = { workspace = true }
tokio = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
typed-builder = { workspace = true }
chrono = { workspace = true }
minijinja = { workspace = true }
thiserror = { workspace = true }
pretty_assertions = { workspace = true }
log = { workspace = true }
dirs = { workspace = true }
tempfile = { workspace = true }
config = "0.15.19"
genai = { git = "https://github.com/BinaryMuse/rust-genai", rev = "ce7feec4ae112cc9ad442841c6b49961a599580e" }
futures-util = "0.3.31"
indoc = "2.0.7"

# Note: tauri's test feature is not enabled here because the feat/cef branch has a broken MockRuntime.
# Tests use Wry directly instead of MockRuntime when running with default features.

[dependencies.tauri-plugin-sql]
features = ["sqlite"] # or "postgres", or "mysql"
version = "2.3.1"

[target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.26"

[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-updater = "2.9.0"

[features]
default = ["wry"]
# this feature is used for production builds or when `devPath` points to the filesystem
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]
wry = ["tauri/wry"]
cef = ["tauri/cef"]

```

## /backend/build.rs

```rs path="/backend/build.rs" 
fn main() {
    let channel = std::env::var("APP_CHANNEL").unwrap_or("stable".to_string());
    println!("cargo:rustc-env=APP_CHANNEL={}", channel);
    // Ensure TS-RS bindings are exported to the correct directory
    println!("cargo:rustc-env=TS_RS_EXPORT_DIR=../src/rs-bindings");

    println!("cargo:rerun-if-changed=migrations");
    tauri_build::build()
}

```

## /backend/capabilities/migrated.json

```json path="/backend/capabilities/migrated.json" 
{
  "identifier": "migrated",
  "description": "permissions that were migrated from v1",
  "context": "local",
  "windows": ["main", "llmtools"],
  "permissions": [
    "core:path:default",
    "core:event:default",
    "core:window:default",
    "core:webview:allow-set-webview-zoom",
    "core:app:default",
    "core:resources:default",
    "core:menu:default",
    "core:tray:default",
    "shell:allow-open",
    "sql:allow-load",
    "sql:allow-execute",
    "sql:allow-select",
    "os:allow-platform",
    "core:window:allow-start-dragging",
    {
      "identifier": "http:default",
      "allow": [
        {
          "url": "https://**"
        },
        {
          "url": "http://**"
        },
        {
          "url": "http://localhost**"
        },
        {
          "url": "https://*.localhost:*"
        },
        {
          "url": "https://*:*"
        },
        {
          "url": "http://*:*"
        },
        {
          "url": "https://*.*:*"
        },
        {
          "url": "http://*.*:*"
        },
        {
          "url": "https://*.localhost:*/*"
        },
        {
          "url": "http://*.localhost:*/*"
        }
      ]
    },
    "os:default",
    "dialog:default",
    "updater:default",
    "process:default",
    "process:allow-restart",
    "deep-link:default",
    "notification:default",
    "fs:default",
    "fs:allow-app-write",
    "fs:read-all",
    "fs:write-all",
    "fs:allow-rename",
    "fs:allow-mkdir",
    "fs:allow-exists",
    "fs:allow-watch",
    "fs:read-dirs",
    {
      "identifier": "fs:scope",
      "allow": ["**/*"]
    },
    {
      "identifier": "opener:allow-open-path",
      "allow": [
        {
          "path": "**/*"
        }
      ]
    }
  ],
  "platforms": ["linux", "macOS", "windows"]
}

```

## /backend/icons/128x128.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/128x128.png

## /backend/icons/128x128@2x.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/128x128@2x.png

## /backend/icons/32x32.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/32x32.png

## /backend/icons/Square107x107Logo.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/Square107x107Logo.png

## /backend/icons/Square142x142Logo.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/Square142x142Logo.png

## /backend/icons/Square150x150Logo.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/Square150x150Logo.png

## /backend/icons/Square284x284Logo.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/Square284x284Logo.png

## /backend/icons/Square30x30Logo.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/Square30x30Logo.png

## /backend/icons/Square310x310Logo.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/Square310x310Logo.png

## /backend/icons/Square44x44Logo.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/Square44x44Logo.png

## /backend/icons/Square71x71Logo.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/Square71x71Logo.png

## /backend/icons/Square89x89Logo.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/Square89x89Logo.png

## /backend/icons/StoreLogo.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/StoreLogo.png

## /backend/icons/dmg-bg.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/dmg-bg.png

## /backend/icons/icon.icns

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/icon.icns

## /backend/icons/icon.ico

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/icon.ico

## /backend/icons/icon.png

Binary file available at https://raw.githubusercontent.com/atuinsh/desktop/refs/heads/main/backend/icons/icon.png

## /backend/migrations/ai/20260114192057_create_ai_sessions.down.sql

```sql path="/backend/migrations/ai/20260114192057_create_ai_sessions.down.sql" 
-- Add down migration script here
DROP TABLE ai_sessions;

```

## /backend/migrations/ai/20260114192057_create_ai_sessions.up.sql

```sql path="/backend/migrations/ai/20260114192057_create_ai_sessions.up.sql" 
CREATE TABLE ai_sessions (
  id TEXT PRIMARY KEY,
  session TEXT NOT NULL,
  runbook_id TEXT NOT NULL,
  updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_ai_sessions_runbook ON ai_sessions(runbook_id, updated_at DESC);

```

## /backend/migrations/ai/20260122172003_add_version_to_ai_sessions.down.sql

```sql path="/backend/migrations/ai/20260122172003_add_version_to_ai_sessions.down.sql" 
ALTER TABLE ai_sessions
DROP COLUMN version;

```

## /backend/migrations/ai/20260122172003_add_version_to_ai_sessions.up.sql

```sql path="/backend/migrations/ai/20260122172003_add_version_to_ai_sessions.up.sql" 
ALTER TABLE ai_sessions
ADD COLUMN version INTEGER NOT NULL DEFAULT 0;

```

## /backend/migrations/context/20251110163938_create_context.down.sql

```sql path="/backend/migrations/context/20251110163938_create_context.down.sql" 
DROP TABLE IF EXISTS context;

```

## /backend/migrations/context/20251110163938_create_context.up.sql

```sql path="/backend/migrations/context/20251110163938_create_context.up.sql" 
CREATE TABLE
  context (document_id TEXT, block_id TEXT, context TEXT);

CREATE INDEX idx_context_document_id ON context (document_id);

CREATE INDEX idx_context_block_id ON context (block_id);

CREATE UNIQUE INDEX idx_context_document_id_block_id ON context (document_id, block_id);

```

## /backend/migrations/runbooks/10_create_operation_log.up.sql

```sql path="/backend/migrations/runbooks/10_create_operation_log.up.sql" 
CREATE TABLE operation_log(id STRING PRIMARY KEY, operation TEXT, processed_at BIGINT, created BIGINT, updated BIGINT);
```

## /backend/migrations/runbooks/1_create_initial_tables.up.sql

```sql path="/backend/migrations/runbooks/1_create_initial_tables.up.sql" 
CREATE TABLE runbooks(id string PRIMARY KEY, name TEXT, content TEXT, created bigint, updated bigint);
```

## /backend/migrations/runbooks/20250311174456_make_legacy_workspaces.down.sql

```sql path="/backend/migrations/runbooks/20250311174456_make_legacy_workspaces.down.sql" 
ALTER TABLE legacy_workspaces
RENAME TO workspaces;

ALTER TABLE runbooks
RENAME COLUMN legacy_workspace_id TO workspace_id;

```

## /backend/migrations/runbooks/20250311174456_make_legacy_workspaces.up.sql

```sql path="/backend/migrations/runbooks/20250311174456_make_legacy_workspaces.up.sql" 
ALTER TABLE runbooks
RENAME COLUMN workspace_id TO legacy_workspace_id;

ALTER TABLE workspaces
RENAME TO legacy_workspaces;

```

## /backend/migrations/runbooks/20250311194625_add_new_workspaces_table.down.sql

```sql path="/backend/migrations/runbooks/20250311194625_add_new_workspaces_table.down.sql" 
ALTER TABLE runbooks
DROP COLUMN workspace_id;

DROP TABLE workspaces;

```

## /backend/migrations/runbooks/20250311194625_add_new_workspaces_table.up.sql

```sql path="/backend/migrations/runbooks/20250311194625_add_new_workspaces_table.up.sql" 
CREATE TABLE
  workspaces (
    id TEXT PRIMARY KEY,
    name TEXT,
    org_id TEXT,
    permissions TEXT,
    created BIGINT,
    updated BIGINT
  );

ALTER TABLE runbooks
ADD COLUMN workspace_id TEXT;

```

## /backend/migrations/runbooks/20250709152124_add_workspace_type_to_workspaces.down.sql

```sql path="/backend/migrations/runbooks/20250709152124_add_workspace_type_to_workspaces.down.sql" 
ALTER TABLE workspaces
DROP COLUMN online;

DROP INDEX idx_workspaces_type;

```

## /backend/migrations/runbooks/20250709152124_add_workspace_type_to_workspaces.up.sql

```sql path="/backend/migrations/runbooks/20250709152124_add_workspace_type_to_workspaces.up.sql" 
ALTER TABLE workspaces
ADD COLUMN online INTEGER NOT NULL DEFAULT 0;

CREATE INDEX idx_workspaces_type ON workspaces (online);

```

## /backend/migrations/runbooks/20250709190618_make_org_workspaces_online.down.sql

```sql path="/backend/migrations/runbooks/20250709190618_make_org_workspaces_online.down.sql" 
-- Nothing to do

```

## /backend/migrations/runbooks/20250709190618_make_org_workspaces_online.up.sql

```sql path="/backend/migrations/runbooks/20250709190618_make_org_workspaces_online.up.sql" 
UPDATE workspaces
SET
  online = 1
WHERE
  org_id IS NOT NULL;

```

## /backend/migrations/runbooks/20250722202626_add_backing_dir_to_workspaces.down.sql

```sql path="/backend/migrations/runbooks/20250722202626_add_backing_dir_to_workspaces.down.sql" 
ALTER TABLE workspaces
DROP COLUMN folder;

```

## /backend/migrations/runbooks/20250722202626_add_backing_dir_to_workspaces.up.sql

```sql path="/backend/migrations/runbooks/20250722202626_add_backing_dir_to_workspaces.up.sql" 
ALTER TABLE workspaces
ADD COLUMN folder TEXT;

```

## /backend/migrations/runbooks/20251010195720_add_saved_blocks.down.sql

```sql path="/backend/migrations/runbooks/20251010195720_add_saved_blocks.down.sql" 
DROP TABLE saved_blocks;

```

## /backend/migrations/runbooks/20251010195720_add_saved_blocks.up.sql

```sql path="/backend/migrations/runbooks/20251010195720_add_saved_blocks.up.sql" 
CREATE TABLE
  saved_blocks (
    id TEXT PRIMARY KEY,
    content TEXT NOT NULL,
    created BIGINT NOT NULL,
    updated BIGINT NOT NULL
  );

```

## /backend/migrations/runbooks/20251010200940_add_name_to_saved_blocks.down.sql

```sql path="/backend/migrations/runbooks/20251010200940_add_name_to_saved_blocks.down.sql" 
ALTER TABLE saved_blocks
DROP COLUMN name;

```

## /backend/migrations/runbooks/20251010200940_add_name_to_saved_blocks.up.sql

```sql path="/backend/migrations/runbooks/20251010200940_add_name_to_saved_blocks.up.sql" 
ALTER TABLE saved_blocks
ADD COLUMN name TEXT NOT NULL;

```

## /backend/migrations/runbooks/20251011000000_create_block_local_state.down.sql

```sql path="/backend/migrations/runbooks/20251011000000_create_block_local_state.down.sql" 
DROP TABLE IF EXISTS block_local_state;


```

## /backend/migrations/runbooks/20251011000000_create_block_local_state.up.sql

```sql path="/backend/migrations/runbooks/20251011000000_create_block_local_state.up.sql" 
CREATE TABLE
  block_local_state (
    runbook_id TEXT NOT NULL,
    block_id TEXT NOT NULL,
    property_name TEXT NOT NULL,
    property_value TEXT NOT NULL,
    created BIGINT NOT NULL,
    updated BIGINT NOT NULL,
    PRIMARY KEY (runbook_id, block_id, property_name)
  );

CREATE INDEX idx_block_local_state_runbook ON block_local_state (runbook_id);
CREATE INDEX idx_block_local_state_block ON block_local_state (runbook_id, block_id);


```

## /backend/migrations/runbooks/2_create_workspace_table.up.sql

```sql path="/backend/migrations/runbooks/2_create_workspace_table.up.sql" 
create table workspaces(id string primary key, name text, created bigint, updated bigint);
```

## /backend/migrations/runbooks/3_add_workspace_id_to_runbooks.up.sql

```sql path="/backend/migrations/runbooks/3_add_workspace_id_to_runbooks.up.sql" 
alter table runbooks add column workspace_id string;
```

## /backend/migrations/runbooks/4_add_workspace_directory.up.sql

```sql path="/backend/migrations/runbooks/4_add_workspace_directory.up.sql" 
alter table workspaces add column watch_dir string;
```

## /backend/migrations/runbooks/5_add_ydoc_to_runbooks.up.sql

```sql path="/backend/migrations/runbooks/5_add_ydoc_to_runbooks.up.sql" 
alter table runbooks add column ydoc blob;
```

## /backend/migrations/runbooks/6_create_snapshots_table.up.sql

```sql path="/backend/migrations/runbooks/6_create_snapshots_table.up.sql" 
CREATE TABLE snapshots(id STRING PRIMARY KEY, runbook_id STRING, tag TEXT, content TEXT, created BIGINT);
```

## /backend/migrations/runbooks/7_add_source_info_to_runbooks.up.sql

```sql path="/backend/migrations/runbooks/7_add_source_info_to_runbooks.up.sql" 
ALTER TABLE runbooks ADD COLUMN source TEXT; ALTER TABLE runbooks ADD COLUMN source_info TEXT; ALTER TABLE runbooks ADD COLUMN forked_from STRING;
```

## /backend/migrations/runbooks/8_add_remote_info_to_runbooks.up.sql

```sql path="/backend/migrations/runbooks/8_add_remote_info_to_runbooks.up.sql" 
ALTER TABLE runbooks ADD COLUMN remote_info TEXT;
```

## /backend/migrations/runbooks/9_add_viewed_at_to_runbook.up.sql

```sql path="/backend/migrations/runbooks/9_add_viewed_at_to_runbook.up.sql" 
ALTER TABLE runbooks ADD COLUMN viewed_at BIGINT;
```

## /backend/migrations/shared_state/20250310191220_create_shared_state_docs.down.sql

```sql path="/backend/migrations/shared_state/20250310191220_create_shared_state_docs.down.sql" 
DROP TABLE documents;

```

## /backend/migrations/shared_state/20250310191220_create_shared_state_docs.up.sql

```sql path="/backend/migrations/shared_state/20250310191220_create_shared_state_docs.up.sql" 
CREATE TABLE
  documents (
    id STRING PRIMARY KEY,
    name STRING UNIQUE NOT NULL,
    value STRING NOT NULL,
    version INTEGER NOT NULL
  );

CREATE UNIQUE INDEX idx_documents_name ON documents (name);

```

## /backend/migrations/shared_state/20250310191947_create_optimistic_updates.down.sql

```sql path="/backend/migrations/shared_state/20250310191947_create_optimistic_updates.down.sql" 
DROP TABLE optimistic_updates;

```

## /backend/migrations/shared_state/20250310191947_create_optimistic_updates.up.sql

```sql path="/backend/migrations/shared_state/20250310191947_create_optimistic_updates.up.sql" 
CREATE TABLE
  optimistic_updates (
    id STRING PRIMARY KEY,
    document_name STRING NOT NULL,
    delta STRING NOT NULL,
    change_ref STRING UNIQUE NOT NULL,
    source_version INTEGER NOT NULL,
    FOREIGN KEY (document_name) REFERENCES documents (name) ON DELETE CASCADE
  );

CREATE INDEX idx_optimistic_updates_document_name ON optimistic_updates (document_name);

CREATE UNIQUE INDEX idx_optimistic_updates_change_ref ON optimistic_updates (change_ref);

```

## /backend/rust-toolchain.toml

```toml path="/backend/rust-toolchain.toml" 
[toolchain]
channel = "stable"

```

## /backend/src/advanced_settings.rs

```rs path="/backend/src/advanced_settings.rs" 
use std::path::Path;

use serde::{Deserialize, Serialize};
use ts_rs::TS;

#[derive(Serialize, Deserialize, TS, Clone)]
#[ts(export)]
pub struct AdvancedSettings {
    /// Whether to copy the login shell environment to the app's environment.
    pub copy_shell_env: bool,
}

impl AdvancedSettings {
    pub(crate) fn load(config_path: &Path) -> eyre::Result<Self> {
        config::Config::builder()
            .add_source(
                config::File::with_name(config_path.to_str().ok_or(eyre::eyre!(
                    "Failed to convert config path '{path}' to string",
                    path = config_path.display()
                ))?)
                .required(false),
            )
            .set_default("copy_shell_env", true)?
            .build()?
            .try_deserialize::<Self>()
            .map_err(|e| eyre::eyre!("Failed to deserialize advanced settings: {}", e))
    }
}

#[tauri::command]
pub async fn get_advanced_settings(
    state: tauri::State<'_, AdvancedSettings>,
) -> Result<AdvancedSettings, String> {
    let advanced_settings = state.inner().clone();
    Ok(advanced_settings)
}

```

## /backend/src/ai/client.rs

```rs path="/backend/src/ai/client.rs" 
use std::{future::Future, ops::Deref, pin::Pin, sync::Arc};

use genai::{
    adapter::AdapterKind,
    resolver::{AuthData, Endpoint, ServiceTargetResolver},
    ClientConfig, ModelIden, ServiceTarget,
};

use crate::secret_cache::{SecretCache, SecretCacheError};

#[derive(Debug, thiserror::Error)]
pub enum AtuinAIClientError {
    #[error("Failed to get credential: {0}")]
    CredentialError(#[from] SecretCacheError),
}

/// A wrapper around a genai::Client that includes Atuin's custom service target resolver
pub struct AtuinAIClient {
    client: genai::Client,
    #[allow(dead_code)] // this will be used to fetch provider API keys in the future
    secret_cache: Arc<SecretCache>,
}

impl AtuinAIClient {
    pub fn new(secret_cache: Arc<SecretCache>) -> Self {
        let secret_cache_clone = secret_cache.clone();
        let target_resolver =
            ServiceTargetResolver::from_resolver_async_fn(move |service_target| {
                resolve_service_target(service_target, secret_cache_clone.clone())
            });
        let client = genai::Client::builder()
            .with_config(ClientConfig::default().with_service_target_resolver(target_resolver))
            .build();

        Self {
            client,
            secret_cache,
        }
    }
}

impl Deref for AtuinAIClient {
    type Target = genai::Client;

    fn deref(&self) -> &genai::Client {
        &self.client
    }
}

fn resolve_service_target(
    mut service_target: ServiceTarget,
    secret_cache: Arc<SecretCache>,
) -> Pin<Box<dyn Future<Output = Result<ServiceTarget, genai::resolver::Error>> + Send>> {
    Box::pin(async move {
        let model_name = service_target.model.model_name.to_string();
        let parts = model_name.splitn(3, "::").collect::<Vec<&str>>();

        if parts.len() != 3 {
            return Err(genai::resolver::Error::Custom(format!(
                "Invalid Atuin Desktop model identifier format: {}",
                model_name
            )));
        }

        // Set the adapter kind based on the provider
        let adapter_kind = match parts[0] {
            "atuinhub" => AdapterKind::Anthropic,
            "claude" => AdapterKind::Anthropic,
            "openai" => AdapterKind::OpenAI,
            "ollama" => AdapterKind::Ollama,
            _ => {
                return Err(genai::resolver::Error::Custom(format!(
                    "Invalid provider identifier: {}",
                    parts[0]
                )))
            }
        };

        // Set the API key, if any, for the provider
        let key = get_api_key(&secret_cache, adapter_kind, parts[0] == "atuinhub")
            .await
            .map_err(|e| genai::resolver::Error::Custom(e.to_string()))?;

        if let Some(key) = key {
            let auth = AuthData::Key(key);
            service_target.auth = auth;
        } else {
            service_target.auth = AuthData::Key("".to_string());
        }

        // Set the specific model
        let model_id = ModelIden::new(adapter_kind, parts[1]);
        service_target.model = model_id;

        // Set the endpoint, if any, for the model
        if parts[2] != "default" {
            service_target.endpoint = Endpoint::from_owned(parts[2].to_string());
        }

        Ok(service_target)
    })
}

async fn get_api_key(
    _secret_cache: &SecretCache,
    _adapter_kind: AdapterKind,
    is_hub: bool,
) -> Result<Option<String>, AtuinAIClientError> {
    // todo
    if is_hub {
        return Ok(None);
    }

    Ok(None)
}

```

## /backend/src/ai/fsm.rs

```rs path="/backend/src/ai/fsm.rs" 
//! Agent conversation FSM.
//!
//! Pure state machine that returns effects as data.
//! Caller is responsible for executing effects and feeding events back.
//!
//! Uses genai types internally so the driver doesn't need to convert.

use std::collections::{HashMap, VecDeque};

use genai::chat::{
    ChatMessage, ChatRole, ContentPart, MessageContent, MessageOptions, ToolCall, ToolResponse,
};
use serde::{Deserialize, Serialize};
use ts_rs::TS;

// ============================================================================
// FSM State
// ============================================================================

/// The discrete states of the agent FSM.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum State {
    /// Waiting for user input. No active request.
    Idle,

    /// Request sent to model, waiting for stream to begin.
    Sending,

    /// Actively receiving streamed response.
    Streaming,

    /// Waiting for tool results before continuing.
    PendingTools,
}

// ============================================================================
// Context
// ============================================================================

/// A tool result provided by the caller.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
    pub call_id: String,
    pub output: ToolOutput,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ToolOutput {
    Success(String),
    Error(String),
}

/// A chunk of streamed response.
#[derive(Debug, Clone)]
pub struct StreamChunk {
    pub content: String,
}

/// Shared context across all states.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Context {
    /// Messages queued while a request is in flight.
    pub queued_messages: VecDeque<ChatMessage>,

    /// Tool calls we're waiting on. Map from call_id to the call.
    pub pending_tools: HashMap<String, ToolCall>,

    /// Accumulated tool results.
    pub tool_results: Vec<ToolResult>,

    /// The full conversation history (for building requests).
    pub conversation: Vec<ChatMessage>,

    /// Accumulated content from current stream (for building assistant message).
    pub current_response: String,
}

// ============================================================================
// Events (inputs to the FSM)
// ============================================================================

/// Events that drive state transitions.
#[derive(Debug, Clone)]
pub enum Event {
    /// User submitted a message.
    UserMessage(ChatMessage),

    /// Stream has started (connection established, first response).
    StreamStart,

    /// Received a chunk of streamed content.
    StreamChunk(StreamChunk),

    /// Stream ended. May include tool calls.
    StreamEnd { tool_calls: Vec<ToolCall> },

    /// A tool finished executing.
    ToolResult(ToolResult),

    /// Request failed (network error, API error, etc.)
    RequestFailed { error: String },

    /// User explicitly cancelled the current operation.
    Cancel,

    /// Update the system prompt (replaces conversation[0]).
    /// Used when transitioning from initial generation to edit mode.
    UpdateSystemPrompt(ChatMessage),
}

// ============================================================================
// Effects (outputs from the FSM)
// ============================================================================

/// Side effects the caller should execute.
///
/// The FSM returns these; it never executes them directly.
#[derive(Debug, Clone)]
pub enum Effect {
    /// Start a new request to the model.
    /// Caller should use context.conversation to build the actual request.
    StartRequest,

    /// Emit a content chunk to the UI.
    EmitChunk { content: String },

    /// Request tool execution. Caller executes and feeds back ToolResult events.
    ExecuteTools { calls: Vec<ToolCall> },

    /// A tool result was received and accumulated.
    /// Emitted for each tool result to allow saving state.
    ToolResultReceived,

    /// Notify that response is complete (for UI state).
    ResponseComplete,

    /// Notify of an error (for UI/logging).
    Error { message: String },

    /// Operation was cancelled.
    Cancelled,
}

impl PartialEq for Effect {
    fn eq(&self, other: &Self) -> bool {
        match (self, other) {
            (Effect::StartRequest, Effect::StartRequest) => true,
            (Effect::EmitChunk { content: a }, Effect::EmitChunk { content: b }) => a == b,
            (Effect::ExecuteTools { calls: a }, Effect::ExecuteTools { calls: b }) => {
                // Compare tool calls by call_id and fn_name (not fn_arguments for simplicity)
                a.len() == b.len()
                    && a.iter()
                        .zip(b.iter())
                        .all(|(x, y)| x.call_id == y.call_id && x.fn_name == y.fn_name)
            }
            (Effect::ToolResultReceived, Effect::ToolResultReceived) => true,
            (Effect::ResponseComplete, Effect::ResponseComplete) => true,
            (Effect::Error { message: a }, Effect::Error { message: b }) => a == b,
            (Effect::Cancelled, Effect::Cancelled) => true,
            _ => false,
        }
    }
}

// ============================================================================
// Transition Result
// ============================================================================

/// Result of handling an event.
#[derive(Debug, Clone, Default)]
pub struct Transition {
    /// Effects to execute (in order).
    pub effects: Vec<Effect>,
}

impl Transition {
    fn none() -> Self {
        Transition { effects: vec![] }
    }

    fn with(effects: Vec<Effect>) -> Self {
        Transition { effects }
    }

    fn single(effect: Effect) -> Self {
        Transition {
            effects: vec![effect],
        }
    }
}

// ============================================================================
// The Agent FSM
// ============================================================================

/// The agent finite state machine.
///
/// # Usage
///
/// \`\`\`ignore
/// let mut agent = Agent::new();
///
/// // User sends a message
/// let transition = agent.handle(Event::UserMessage(msg));
/// for effect in transition.effects {
///     execute_effect(effect, &agent); // caller implements this
/// }
///
/// // Stream starts
/// let transition = agent.handle(Event::StreamStart);
/// // ... etc
/// \`\`\`
#[derive(Debug, Clone)]
pub struct Agent {
    state: State,
    context: Context,
}

impl Agent {
    /// Create a new agent in Idle state.
    pub fn new(mut system_prompt: ChatMessage) -> Self {
        system_prompt.options = Some(MessageOptions {
            cache_control: Some(genai::chat::CacheControl::Ephemeral),
        });

        let context = Context {
            conversation: vec![system_prompt],
            ..Context::default()
        };

        Agent {
            state: State::Idle,
            context,
        }
    }

    /// Create an agent from saved state.
    pub fn from_saved(state: State, mut context: Context, mut system_prompt: ChatMessage) -> Self {
        system_prompt.options = Some(MessageOptions {
            cache_control: Some(genai::chat::CacheControl::Ephemeral),
        });

        // Ensure the provided system_prompt is present as the first message in conversation.
        if let Some(first) = context.conversation.get_mut(0) {
            let first_role = WrappedChatRole(first.role.clone());

            if first_role == WrappedChatRole(ChatRole::System) {
                *first = system_prompt.clone();
            } else {
                // Role doesn't match, so prepend
                let mut new_conversation = vec![system_prompt.clone()];
                new_conversation.extend(context.conversation.clone());
                // Replace context.conversation with new one
                context.conversation = new_conversation;
            }
        } else {
            // conversation is empty, just insert
            context.conversation.push(system_prompt.clone());
        }

        Agent { state, context }
    }

    /// Current state (for UI, debugging, assertions).
    pub fn state(&self) -> &State {
        &self.state
    }

    /// Read-only access to context.
    pub fn context(&self) -> &Context {
        &self.context
    }

    /// Push accumulated tool results to conversation as tool response messages.
    /// Each tool response becomes a separate message with ChatRole::Tool.
    fn push_tool_results_to_conversation(&mut self) {
        for result in self.context.tool_results.drain(..) {
            let result_str = match result.output {
                ToolOutput::Success(s) => s,
                ToolOutput::Error(e) => format!("Error: {}", e),
            };
            let tool_response = ToolResponse::new(result.call_id, result_str);
            // ChatMessage::from(ToolResponse) creates a message with ChatRole::Tool
            self.context
                .conversation
                .push(ChatMessage::from(tool_response));
        }
    }

    /// Handle an event, returning effects to execute.
    ///
    /// This is the core state machine logic.
    /// Mutates internal state, returns effects as data.
    pub fn handle(&mut self, event: Event) -> Transition {
        // State-specific transitions
        match (&self.state, event) {
            // ================================================================
            // User messages
            // ================================================================
            (State::Idle, Event::UserMessage(msg)) => {
                self.context.conversation.push(msg);
                self.state = State::Sending;
                Transition::single(Effect::StartRequest)
            }

            // Queue messages during Sending, Streaming, or PendingTools
            (_, Event::UserMessage(msg)) => {
                self.context.queued_messages.push_back(msg);
                Transition::none()
            }

            // ================================================================
            // Tool results
            // ================================================================
            (State::PendingTools, Event::ToolResult(result)) => {
                self.context.pending_tools.remove(&result.call_id);
                self.context.tool_results.push(result);

                if self.context.pending_tools.is_empty() {
                    // All tools complete - push results to conversation
                    self.push_tool_results_to_conversation();
                    // Drain any queued messages
                    for msg in self.context.queued_messages.drain(..) {
                        self.context.conversation.push(msg);
                    }
                    self.state = State::Sending;
                    // Emit ToolResultReceived to save state before starting request
                    Transition::with(vec![Effect::ToolResultReceived, Effect::StartRequest])
                } else {
                    // Emit ToolResultReceived to save partial progress
                    Transition::single(Effect::ToolResultReceived)
                }
            }

            // Tool results during streaming are accumulated for later
            (_, Event::ToolResult(result)) => {
                self.context.pending_tools.remove(&result.call_id);
                self.context.tool_results.push(result);
                Transition::none()
            }

            // ================================================================
            // Stream events
            // ================================================================
            (State::Sending, Event::StreamStart) => {
                self.context.current_response.clear();
                self.state = State::Streaming;
                Transition::none()
            }

            (_, Event::StreamStart) => Transition::none(),

            (State::Streaming, Event::StreamChunk(chunk)) => {
                self.context.current_response.push_str(&chunk.content);
                Transition::single(Effect::EmitChunk {
                    content: chunk.content,
                })
            }

            // A stream chunk when we're not streaming is ignored.
            (_, Event::StreamChunk(_)) => Transition::none(),

            (State::Streaming, Event::StreamEnd { tool_calls }) => {
                // Build assistant message with text content AND tool calls
                let mut parts = vec![];
                let response_text = std::mem::take(&mut self.context.current_response);
                if !response_text.is_empty() {
                    parts.push(ContentPart::Text(response_text));
                }
                for call in &tool_calls {
                    parts.push(ContentPart::ToolCall(call.clone()));
                }
                if !parts.is_empty() {
                    let content = MessageContent::from_parts(parts);
                    let assistant_msg = ChatMessage::assistant(content);
                    self.context.conversation.push(assistant_msg);
                }

                // Add new tools to pending
                let has_new_tools = !tool_calls.is_empty();
                let tools_to_execute: Vec<ToolCall> = tool_calls
                    .into_iter()
                    .map(|call| {
                        let cloned = call.clone();
                        self.context
                            .pending_tools
                            .insert(call.call_id.clone(), call);
                        cloned
                    })
                    .collect();

                // Decide next action
                if has_new_tools {
                    // New tools to execute - go to PendingTools and request execution
                    // Queued messages stay queued until tools complete
                    self.state = State::PendingTools;
                    Transition::with(vec![
                        Effect::ResponseComplete,
                        Effect::ExecuteTools {
                            calls: tools_to_execute,
                        },
                    ])
                } else if !self.context.queued_messages.is_empty() {
                    // No new tools, but we have queued messages - drain and continue
                    for msg in self.context.queued_messages.drain(..) {
                        self.context.conversation.push(msg);
                    }
                    self.state = State::Sending;
                    Transition::with(vec![Effect::ResponseComplete, Effect::StartRequest])
                } else if !self.context.tool_results.is_empty() {
                    // No new tools, no queued messages, but we have completed tool results
                    // (from ToolResult events that arrived while streaming)
                    self.push_tool_results_to_conversation();
                    self.state = State::Sending;
                    Transition::with(vec![Effect::ResponseComplete, Effect::StartRequest])
                } else {
                    // No tools, no queued messages, no pending results - done
                    self.state = State::Idle;
                    Transition::single(Effect::ResponseComplete)
                }
            }

            // ================================================================
            // Request failed
            // ================================================================
            (State::Idle, Event::RequestFailed { error }) => {
                self.state = State::Idle;
                Transition::single(Effect::Error { message: error })
            }

            (State::Sending, Event::RequestFailed { error }) => {
                self.state = State::Idle;
                Transition::single(Effect::Error { message: error })
            }

            (State::Streaming, Event::RequestFailed { error }) => {
                self.context.current_response.clear();
                self.state = State::Idle;
                Transition::single(Effect::Error { message: error })
            }

            // ================================================================
            // Cancel
            // ================================================================
            (State::Sending, Event::Cancel) => {
                self.state = State::Idle;
                Transition::single(Effect::Cancelled)
            }

            (State::Streaming, Event::Cancel) => {
                self.context.current_response.clear();
                self.state = State::Idle;
                Transition::single(Effect::Cancelled)
            }

            // Cancel with pending tools - generate fake "cancelled" responses
            // so Claude's API is satisfied (it requires tool responses after tool calls)
            (State::PendingTools, Event::Cancel) => {
                // Generate cancelled responses for all pending tools
                for (call_id, _call) in self.context.pending_tools.drain() {
                    self.context.tool_results.push(ToolResult {
                        call_id,
                        output: ToolOutput::Error("User cancelled this operation".to_string()),
                    });
                }
                // Push to conversation so they're included in next request
                self.push_tool_results_to_conversation();
                self.state = State::Idle;
                Transition::single(Effect::Cancelled)
            }

            // ================================================================
            // Update system prompt
            // ================================================================
            (_, Event::UpdateSystemPrompt(msg)) => {
                // Replace conversation[0] with the new system prompt
                if !self.context.conversation.is_empty() {
                    self.context.conversation[0] = msg;
                }
                Transition::none()
            }

            (_, _) => Transition::none(),
        }
    }
}

struct WrappedChatRole(genai::chat::ChatRole);

impl PartialEq for WrappedChatRole {
    fn eq(&self, other: &Self) -> bool {
        use genai::chat::ChatRole;

        fn eq_chat_role(a: &ChatRole, b: &ChatRole) -> bool {
            matches!(
                (a, b),
                (ChatRole::System, ChatRole::System)
                    | (ChatRole::User, ChatRole::User)
                    | (ChatRole::Assistant, ChatRole::Assistant)
                    | (ChatRole::Tool, ChatRole::Tool)
            )
        }

        eq_chat_role(&self.0, &other.0)
    }
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use genai::chat::ChatRole;

    fn user_msg(content: &str) -> ChatMessage {
        ChatMessage::user(content)
    }

    fn make_tool_call(id: &str, name: &str) -> ToolCall {
        ToolCall {
            call_id: id.to_string(),
            fn_name: name.to_string(),
            fn_arguments: serde_json::json!({}),
            thought_signatures: None,
        }
    }

    #[test]
    fn idle_to_sending_on_user_message() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        assert_eq!(agent.state(), &State::Idle);

        let t = agent.handle(Event::UserMessage(user_msg("hello")));

        assert_eq!(agent.state(), &State::Sending);
        assert_eq!(t.effects, vec![Effect::StartRequest]);
        assert_eq!(agent.context().conversation.len(), 2); // system + user
    }

    #[test]
    fn messages_queued_during_sending() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("first")));

        let t = agent.handle(Event::UserMessage(user_msg("second")));

        assert_eq!(agent.state(), &State::Sending);
        assert!(t.effects.is_empty());
        assert_eq!(agent.context().queued_messages.len(), 1);
    }

    #[test]
    fn sending_to_streaming_on_stream_start() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("hello")));

        let t = agent.handle(Event::StreamStart);

        assert_eq!(agent.state(), &State::Streaming);
        assert!(t.effects.is_empty());
    }

    #[test]
    fn streaming_emits_chunks() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("hello")));
        agent.handle(Event::StreamStart);

        let t = agent.handle(Event::StreamChunk(StreamChunk {
            content: "Hi there!".to_string(),
        }));

        assert_eq!(agent.state(), &State::Streaming);
        assert_eq!(
            t.effects,
            vec![Effect::EmitChunk {
                content: "Hi there!".to_string()
            }]
        );
        assert_eq!(agent.context().current_response, "Hi there!");
    }

    #[test]
    fn stream_end_no_tools_goes_idle() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("hello")));
        agent.handle(Event::StreamStart);
        agent.handle(Event::StreamChunk(StreamChunk {
            content: "Hi!".to_string(),
        }));

        let t = agent.handle(Event::StreamEnd { tool_calls: vec![] });

        assert_eq!(agent.state(), &State::Idle);
        assert_eq!(t.effects, vec![Effect::ResponseComplete]);
        assert_eq!(agent.context().conversation.len(), 3); // system + user + assistant
    }

    #[test]
    fn stream_end_with_queued_message_goes_sending() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("first")));
        agent.handle(Event::UserMessage(user_msg("second"))); // queued
        agent.handle(Event::StreamStart);
        agent.handle(Event::StreamChunk(StreamChunk {
            content: "Response".to_string(),
        }));

        let t = agent.handle(Event::StreamEnd { tool_calls: vec![] });

        assert_eq!(agent.state(), &State::Sending);
        assert_eq!(
            t.effects,
            vec![Effect::ResponseComplete, Effect::StartRequest]
        );
        assert!(agent.context().queued_messages.is_empty());
        assert_eq!(agent.context().conversation.len(), 4); // system + first + response + second
    }

    #[test]
    fn stream_end_with_tools_goes_to_pending_tools() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("what time is it?")));
        agent.handle(Event::StreamStart);
        agent.handle(Event::StreamChunk(StreamChunk {
            content: "Let me check.".to_string(),
        }));

        let tc = make_tool_call("call_123", "get_time");
        let t = agent.handle(Event::StreamEnd {
            tool_calls: vec![tc.clone()],
        });

        assert_eq!(agent.state(), &State::PendingTools);
        assert_eq!(
            t.effects,
            vec![
                Effect::ResponseComplete,
                Effect::ExecuteTools { calls: vec![tc] }
            ]
        );
        assert_eq!(agent.context().pending_tools.len(), 1);
    }

    #[test]
    fn tool_result_completes_and_starts_request() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("what time is it?")));
        agent.handle(Event::StreamStart);
        agent.handle(Event::StreamEnd {
            tool_calls: vec![make_tool_call("call_123", "get_time")],
        });

        let t = agent.handle(Event::ToolResult(ToolResult {
            call_id: "call_123".to_string(),
            output: ToolOutput::Success("12:34 PM".to_string()),
        }));

        assert_eq!(agent.state(), &State::Sending);
        assert_eq!(
            t.effects,
            vec![Effect::ToolResultReceived, Effect::StartRequest]
        );
        assert!(agent.context().pending_tools.is_empty());
        // Tool results are now pushed to conversation as ToolResponse messages
        assert!(agent.context().tool_results.is_empty());
        // Conversation: system, user msg, assistant msg (with tool call), tool response
        assert_eq!(agent.context().conversation.len(), 4);
    }

    #[test]
    fn multiple_tools_waits_for_all() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("query")));
        agent.handle(Event::StreamStart);
        agent.handle(Event::StreamEnd {
            tool_calls: vec![
                make_tool_call("call_1", "tool_a"),
                make_tool_call("call_2", "tool_b"),
            ],
        });

        // First result - still waiting, but emits ToolResultReceived for durability
        let t = agent.handle(Event::ToolResult(ToolResult {
            call_id: "call_1".to_string(),
            output: ToolOutput::Success("result_a".to_string()),
        }));

        assert_eq!(agent.state(), &State::PendingTools);
        assert_eq!(t.effects, vec![Effect::ToolResultReceived]);

        // Second result - now complete
        let t = agent.handle(Event::ToolResult(ToolResult {
            call_id: "call_2".to_string(),
            output: ToolOutput::Success("result_b".to_string()),
        }));

        assert_eq!(agent.state(), &State::Sending);
        assert_eq!(
            t.effects,
            vec![Effect::ToolResultReceived, Effect::StartRequest]
        );
    }

    #[test]
    fn cancel_from_streaming() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("hello")));
        agent.handle(Event::StreamStart);

        let t = agent.handle(Event::Cancel);

        assert_eq!(agent.state(), &State::Idle);
        assert_eq!(t.effects, vec![Effect::Cancelled]);
    }

    #[test]
    fn request_failed_goes_idle() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("hello")));

        let t = agent.handle(Event::RequestFailed {
            error: "network error".to_string(),
        });

        assert_eq!(agent.state(), &State::Idle);
        assert_eq!(
            t.effects,
            vec![Effect::Error {
                message: "network error".to_string()
            }]
        );
    }

    #[test]
    fn assistant_message_contains_tool_calls() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("what time is it?")));
        agent.handle(Event::StreamStart);
        agent.handle(Event::StreamChunk(StreamChunk {
            content: "Let me check.".to_string(),
        }));

        let tc = make_tool_call("call_123", "get_time");
        agent.handle(Event::StreamEnd {
            tool_calls: vec![tc.clone()],
        });

        // Check the assistant message in conversation has both text and tool call
        // conversation[0] = system, [1] = user, [2] = assistant
        let assistant_msg = &agent.context().conversation[2];
        assert!(matches!(assistant_msg.role, ChatRole::Assistant));
        let parts = assistant_msg.content.clone().into_parts();
        assert_eq!(parts.len(), 2);
        assert!(matches!(&parts[0], ContentPart::Text(t) if t == "Let me check."));
        assert!(
            matches!(&parts[1], ContentPart::ToolCall(c) if c.call_id == tc.call_id && c.fn_name == tc.fn_name)
        );
    }

    #[test]
    fn queued_messages_stay_queued_until_tools_complete() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("first")));
        agent.handle(Event::UserMessage(user_msg("second"))); // queued
        agent.handle(Event::StreamStart);
        agent.handle(Event::StreamChunk(StreamChunk {
            content: "Response".to_string(),
        }));

        let tc = make_tool_call("call_123", "some_tool");
        agent.handle(Event::StreamEnd {
            tool_calls: vec![tc],
        });

        // Messages stay queued until tools complete
        assert_eq!(agent.state(), &State::PendingTools);
        assert_eq!(agent.context().queued_messages.len(), 1);
        // Conversation: system, first msg, assistant msg
        assert_eq!(agent.context().conversation.len(), 3);

        // Tool completes - now queued messages are drained
        agent.handle(Event::ToolResult(ToolResult {
            call_id: "call_123".to_string(),
            output: ToolOutput::Success("result".to_string()),
        }));

        assert_eq!(agent.state(), &State::Sending);
        assert!(agent.context().queued_messages.is_empty());
        // Conversation: system, first msg, assistant msg, tool response, second msg
        assert_eq!(agent.context().conversation.len(), 5);
        // Verify the fifth message is the queued "second"
        let fifth_msg = &agent.context().conversation[4];
        assert!(matches!(fifth_msg.role, ChatRole::User));
    }

    #[test]
    fn user_message_during_pending_tools_gets_queued() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("query")));
        agent.handle(Event::StreamStart);
        agent.handle(Event::StreamEnd {
            tool_calls: vec![make_tool_call("call_1", "tool_a")],
        });

        // In PendingTools awaiting tools
        assert_eq!(agent.state(), &State::PendingTools);

        // User sends new message while waiting for tools
        let t = agent.handle(Event::UserMessage(user_msg("continue")));

        // Message should be queued, not trigger a new request
        assert_eq!(agent.state(), &State::PendingTools);
        assert!(t.effects.is_empty());
        assert_eq!(agent.context().queued_messages.len(), 1);

        // Tool result completes - queued message is drained and request starts
        let t = agent.handle(Event::ToolResult(ToolResult {
            call_id: "call_1".to_string(),
            output: ToolOutput::Success("result_a".to_string()),
        }));

        assert_eq!(agent.state(), &State::Sending);
        assert_eq!(
            t.effects,
            vec![Effect::ToolResultReceived, Effect::StartRequest]
        );
        assert!(agent.context().queued_messages.is_empty());
        // Conversation: system, user msg, assistant msg, tool response, queued msg
        assert_eq!(agent.context().conversation.len(), 5);
    }

    #[test]
    fn cancel_pending_tools_generates_cancelled_responses() {
        let mut agent = Agent::new(ChatMessage::system("you are a helpful assistant"));
        agent.handle(Event::UserMessage(user_msg("query")));
        agent.handle(Event::StreamStart);
        agent.handle(Event::StreamEnd {
            tool_calls: vec![
                make_tool_call("call_1", "tool_a"),
                make_tool_call("call_2", "tool_b"),
            ],
        });

        assert_eq!(agent.state(), &State::PendingTools);
        assert_eq!(agent.context().pending_tools.len(), 2);

        // Cancel while tools are pending
        let t = agent.handle(Event::Cancel);

        assert_eq!(agent.state(), &State::Idle);
        assert_eq!(t.effects, vec![Effect::Cancelled]);
        assert!(agent.context().pending_tools.is_empty());
        // Tool results were pushed to conversation as error responses
        // Conversation: system, user msg, assistant msg, tool response, tool response
        assert_eq!(agent.context().conversation.len(), 5);
    }
}

```

## /backend/src/ai/manager.rs

```rs path="/backend/src/ai/manager.rs" 
use std::{collections::HashMap, sync::Arc};

use tauri::ipc::Channel;
use tokio::sync::{broadcast, mpsc, RwLock};
use uuid::Uuid;

use crate::{
    ai::{
        fsm::State as FsmState,
        session::{AISession, SessionEvent, SessionHandle},
        storage::{AISessionStorage, SerializedAISessionV1},
        types::{
            AIMessage, AIToolCall, BlockInfo, LLMToolsEvent, SessionConfig, SessionInfo,
            SessionKind,
        },
    },
    secret_cache::SecretCache,
};

#[derive(Debug, thiserror::Error)]
pub enum ManagerError {
    #[error("Session {0} not found")]
    SessionNotFound(Uuid),

    #[error("Storage error: {0}")]
    StorageError(#[from] crate::ai::storage::StorageError),

    #[error("Session error: {0}")]
    SessionError(#[from] crate::ai::session::AISessionError),
}

/// Data that needs to be replayed to the frontend when a session is restored.
struct PendingReplay {
    fsm_state: FsmState,
    history: Vec<AIMessage>,
    pending_tools: Vec<AIToolCall>,
}

/// Manages the creation and destruction of AI sessions.
pub struct AISessionManager {
    secret_cache: Arc<SecretCache>,
    storage: Arc<AISessionStorage>,

    sessions: Arc<RwLock<HashMap<Uuid, SessionHandle>>>,
    channels: Arc<RwLock<HashMap<Uuid, Channel<SessionEvent>>>>,

    pending_replays: Arc<RwLock<HashMap<Uuid, PendingReplay>>>,

    /// Tracks session info for list_sessions and LLMToolsEvent broadcasts.
    session_infos: Arc<RwLock<HashMap<Uuid, SessionInfo>>>,

    /// Broadcast channel for LLM Tools window events.
    llmtools_tx: broadcast::Sender<LLMToolsEvent>,
}

impl AISessionManager {
    pub fn new(secret_cache: Arc<SecretCache>, storage: Arc<AISessionStorage>) -> Self {
        // Create broadcast channel with reasonable capacity for LLM Tools subscribers
        let (llmtools_tx, _) = broadcast::channel(256);

        Self {
            secret_cache,
            storage,
            sessions: Arc::new(RwLock::new(HashMap::new())),
            channels: Arc::new(RwLock::new(HashMap::new())),
            pending_replays: Arc::new(RwLock::new(HashMap::new())),
            session_infos: Arc::new(RwLock::new(HashMap::new())),
            llmtools_tx,
        }
    }

    /// Get a session handle for a session ID.
    pub async fn get_handle(&self, session_id: Uuid) -> Option<SessionHandle> {
        self.sessions.read().await.get(&session_id).cloned()
    }

    /// Destroy a session and clean up resources.
    pub async fn destroy(&self, session_id: Uuid) {
        self.sessions.write().await.remove(&session_id);
        self.channels.write().await.remove(&session_id);
        self.pending_replays.write().await.remove(&session_id);
        self.session_infos.write().await.remove(&session_id);

        // Broadcast session destroyed event (ignore errors if no subscribers)
        let _ = self
            .llmtools_tx
            .send(LLMToolsEvent::SessionDestroyed { session_id });

        log::info!("Destroyed AI session {}", session_id);
    }

    /// Subscribe to events from a session.
    pub async fn subscribe(
        &self,
        session_id: Uuid,
        channel: Channel<SessionEvent>,
    ) -> Result<(), ManagerError> {
        // Verify session exists
        if !self.sessions.read().await.contains_key(&session_id) {
            return Err(ManagerError::SessionNotFound(session_id));
        }

        self.channels
            .write()
            .await
            .insert(session_id, channel.clone());

        // Send initial state and replay data
        let replay = self.pending_replays.write().await.remove(&session_id);
        self.send_initial_state(session_id, &channel, replay).await;

        log::debug!("Frontend subscribed to session {}", session_id);
        Ok(())
    }

    /// List all active sessions with their info.
    pub async fn list_sessions(&self) -> Vec<SessionInfo> {
        self.session_infos.read().await.values().cloned().collect()
    }

    /// Subscribe to LLM Tools events (session creation, destruction, and session events).
    pub fn subscribe_llmtools(&self) -> broadcast::Receiver<LLMToolsEvent> {
        self.llmtools_tx.subscribe()
    }

    pub async fn create_chat_session(
        &self,
        runbook_id: Uuid,
        block_infos: Vec<BlockInfo>,
        config: SessionConfig,
        restore_previous: bool,
    ) -> Result<SessionHandle, ManagerError> {
        let kind = SessionKind::AssistantChat {
            runbook_id,
            block_infos,
        };

        self.create_session(kind, config, restore_previous).await
    }

    pub async fn create_generator_session(
        &self,
        runbook_id: Uuid,
        block_infos: Vec<BlockInfo>,
        current_document: serde_json::Value,
        insert_after: Uuid,
        config: SessionConfig,
    ) -> Result<SessionHandle, ManagerError> {
        let kind = SessionKind::InlineBlockGeneration {
            runbook_id,
            block_infos,
            current_document,
            insert_after,
            is_initial_generation: true,
        };

        self.create_session(kind, config, false).await
    }

    async fn create_session(
        &self,
        kind: SessionKind,
        config: SessionConfig,
        restore_previous: bool,
    ) -> Result<SessionHandle, ManagerError> {
        let existing = if restore_previous && kind.persists_state() {
            self.storage
                .find_most_recent_for_runbook(&kind.runbook_id())
                .await?
        } else {
            None
        };

        let (output_tx, output_rx) = mpsc::channel::<SessionEvent>(32);

        let (session, handle, replay_data, session_info) = if let Some(saved) = existing {
            log::info!(
                "Restoring AI session {} for runbook {}",
                saved.id,
                kind.runbook_id()
            );

            let replay = self.extract_replay_data(&saved)?;
            let info = SessionInfo::from_session_kind(saved.id, &saved.kind);
            let (session, handle) = AISession::from_saved(
                saved,
                output_tx,
                self.secret_cache.clone(),
                self.storage.clone(),
            )?;

            (session, handle, Some(replay), info)
        } else {
            log::info!("Creating new AI session for runbook {}", kind.runbook_id());

            // Capture session info before kind is moved
            let info = SessionInfo::from_session_kind(Uuid::new_v4(), &kind);
            let (session, handle) = AISession::new(
                kind,
                config,
                output_tx,
                self.secret_cache.clone(),
                self.storage.clone(),
            )?;

            // Update info with actual session id
            let info = SessionInfo {
                id: session.id(),
                ..info
            };

            (session, handle, None, info)
        };

        let session_id = session.id();

        if let Some(replay) = replay_data {
            if !replay.history.is_empty() || !replay.pending_tools.is_empty() {
                self.pending_replays
                    .write()
                    .await
                    .insert(session_id, replay);
            }
        }

        // Store session info for list_sessions
        self.session_infos
            .write()
            .await
            .insert(session_id, session_info.clone());

        self.sessions
            .write()
            .await
            .insert(session_id, handle.clone());

        tokio::spawn(session.run());

        self.spawn_event_forwarder(session_id, output_rx);

        // Broadcast session created event (ignore errors if no subscribers)
        let _ = self
            .llmtools_tx
            .send(LLMToolsEvent::SessionCreated { info: session_info });

        Ok(handle)
    }

    fn extract_replay_data(
        &self,
        saved: &SerializedAISessionV1,
    ) -> Result<PendingReplay, ManagerError> {
        let replay = PendingReplay {
            fsm_state: saved.agent_state.clone(),
            history: saved
                .agent_context
                .conversation
                .iter()
                .map(|msg| AIMessage::from(msg.clone()))
                .collect(),
            pending_tools: saved
                .agent_context
                .pending_tools
                .values()
                .cloned()
                .map(AIToolCall::from)
                .collect(),
        };

        Ok(replay)
    }

    fn spawn_event_forwarder(&self, session_id: Uuid, mut output_rx: mpsc::Receiver<SessionEvent>) {
        let channels = self.channels.clone();
        let sessions = self.sessions.clone();
        let session_infos = self.session_infos.clone();
        let llmtools_tx = self.llmtools_tx.clone();

        tokio::spawn(async move {
            while let Some(event) = output_rx.recv().await {
                // Broadcast to LLM Tools window (ignore errors if no subscribers)
                let _ = llmtools_tx.send(LLMToolsEvent::SessionEvent {
                    session_id,
                    event: event.clone(),
                });

                // Forward to the frontend channel for this specific session
                let channels = channels.read().await;
                if let Some(channel) = channels.get(&session_id) {
                    if let Err(e) = channel.send(event) {
                        log::error!("Failed to send event to frontend: {}", e);
                        break;
                    }
                }
            }

            // Session ended, clean up
            log::debug!("Session {session_id} output channel closed, cleaning up");
            sessions.write().await.remove(&session_id);
            channels.write().await.remove(&session_id);
            session_infos.write().await.remove(&session_id);

            // Broadcast session destroyed event (ignore errors if no subscribers)
            let _ = llmtools_tx.send(LLMToolsEvent::SessionDestroyed { session_id });
        });
    }

    async fn send_initial_state(
        &self,
        session_id: Uuid,
        channel: &Channel<SessionEvent>,
        replay: Option<PendingReplay>,
    ) {
        let fsm_state = replay
            .as_ref()
            .map(|r| r.fsm_state.clone())
            .unwrap_or(FsmState::Idle);

        let history = replay
            .as_ref()
            .map(|r| r.history.clone())
            .unwrap_or_default();

        let pending_tool_calls = replay.map(|r| r.pending_tools.clone()).unwrap_or_default();

        log::debug!(
            "Sending state {:?}, {} history messages, and {} pending tool calls for session {}",
            fsm_state,
            history.len(),
            pending_tool_calls.len(),
            session_id
        );

        if let Err(e) = channel.send(SessionEvent::StateChanged { state: fsm_state }) {
            log::error!("Failed to send state to frontend: {}", e);
        }

        if let Err(e) = channel.send(SessionEvent::History {
            messages: history,
            pending_tool_calls,
        }) {
            log::error!("Failed to send history to frontend: {}", e);
        }
    }
}

```

## /backend/src/ai/mod.rs

```rs path="/backend/src/ai/mod.rs" 
mod client;
pub mod fsm;
pub mod manager;
pub mod prompts;
pub mod session;
pub mod storage;
pub mod tools;
pub mod types;

```

## /backend/src/ai/prompts.rs

```rs path="/backend/src/ai/prompts.rs" 
use minijinja::{Environment, UndefinedBehavior};
use serde::{Serialize, Serializer};
use uuid::Uuid;

use crate::ai::types::BlockInfo;

pub struct AIPrompts;

const SYS_PROMPT_SOURCE: &str = include_str!("system_prompt.minijinja.txt");

#[derive(Debug, thiserror::Error)]
pub enum PromptError {
    #[error("Failed to process system prompt template: {0}")]
    SystemPromptTemplateError(#[from] minijinja::Error),
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemPromptContext {
    prompt_type: SystemPromptType,
    block_infos: Vec<BlockInfo>,
    current_document: Option<serde_json::Value>,
    insert_after: Option<Uuid>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub enum SystemPromptType {
    Assistant,
    #[serde(serialize_with = "serialize_generation_type")]
    Generator(GenerationType),
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub enum GenerationType {
    Generate,
    Edit,
}

fn serialize_generation_type<S>(
    generation_type: &GenerationType,
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    serializer.serialize_str(match generation_type {
        GenerationType::Generate => "generation.generate",
        GenerationType::Edit => "generation.edit",
    })
}

impl AIPrompts {
    pub fn assistant_system_prompt(block_infos: Vec<BlockInfo>) -> Result<String, PromptError> {
        let mut env = Environment::new();
        env.set_trim_blocks(true);
        env.set_undefined_behavior(UndefinedBehavior::Strict);

        let context = SystemPromptContext {
            prompt_type: SystemPromptType::Assistant,
            block_infos,
            current_document: None,
            insert_after: None,
        };

        env.render_str(SYS_PROMPT_SOURCE, &context)
            .map_err(PromptError::SystemPromptTemplateError)
    }

    pub fn generator_system_prompt(
        generation_type: GenerationType,
        block_infos: Vec<BlockInfo>,
        current_document: serde_json::Value,
        insert_after: Uuid,
    ) -> Result<String, PromptError> {
        let mut env = Environment::new();
        env.set_trim_blocks(true);
        env.set_undefined_behavior(UndefinedBehavior::Strict);

        let context = SystemPromptContext {
            prompt_type: SystemPromptType::Generator(generation_type),
            block_infos,
            current_document: Some(current_document),
            insert_after: Some(insert_after),
        };

        env.render_str(SYS_PROMPT_SOURCE, &context)
            .map_err(PromptError::SystemPromptTemplateError)
    }
}

```

## /backend/src/ai/session.rs

```rs path="/backend/src/ai/session.rs" 
//! AI Session - the driver that wraps the Agent FSM and executes effects.

use std::sync::Arc;

use futures_util::stream::StreamExt;
use genai::chat::{
    CacheControl, ChatMessage, ChatOptions, ChatRequest, ChatStreamEvent, MessageOptions, ToolCall,
};
use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, watch, RwLock};
use ts_rs::TS;
use uuid::Uuid;

use crate::ai::client::AtuinAIClient;
use crate::ai::fsm::State;
use crate::ai::prompts::PromptError;
use crate::ai::types::{ChargeTarget, SessionConfig, SessionKind};
use crate::secret_cache::{SecretCache, SecretCacheError};

use super::fsm::{Agent, Effect, Event, StreamChunk, ToolOutput, ToolResult};
use super::storage::{AISessionStorage, SerializedAISessionV1};
use super::types::{AIMessage, AIToolCall, ModelSelection};

#[derive(Debug, thiserror::Error)]
pub enum AISessionError {
    #[error("Failed to get credential: {0}")]
    CredentialError(#[from] SecretCacheError),

    #[error("Failed to start request: {0}")]
    RequestError(#[from] genai::Error),

    #[error("Failed to generate system prompt: {0}")]
    SystemPromptError(PromptError),

    #[error("Session event channel closed")]
    ChannelClosed,
}

/// Events emitted by the session to the frontend.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(export)]
pub enum SessionEvent {
    /// State changed
    StateChanged { state: State },
    /// Stream started
    StreamStarted,
    /// Content chunk received
    Chunk { content: String },
    /// Response complete (no more chunks)
    ResponseComplete,
    /// Tools need to be executed
    ToolsRequested { calls: Vec<AIToolCall> },
    /// An error occurred
    Error { message: String },
    /// Operation was cancelled
    Cancelled,
    /// Full conversation history (sent when subscribing to a session)
    /// Includes pending tool calls if session was restored in PendingTools state
    History {
        messages: Vec<AIMessage>,
        #[serde(rename = "pendingToolCalls")]
        #[ts(rename = "pendingToolCalls")]
        pending_tool_calls: Vec<AIToolCall>,
    },
    /// Blocks were generated via submit_blocks tool (for InlineBlockGeneration sessions).
    /// The session remains in PendingTools state until the frontend sends a tool result
    /// (either via edit request or acceptance/cancellation).
    BlocksGenerated {
        blocks: Vec<serde_json::Value>,
        #[serde(rename = "toolCallId")]
        #[ts(rename = "toolCallId")]
        tool_call_id: String,
    },
}

/// Handle for sending events into the session from external sources.
#[derive(Clone)]
pub struct SessionHandle {
    pub id: Uuid,
    config: Arc<RwLock<SessionConfig>>,
    #[allow(dead_code)] // this will be used to convert between session types in the future
    kind: Arc<RwLock<SessionKind>>,
    event_tx: mpsc::Sender<Event>,
}

impl SessionHandle {
    /// Change the model of the session.
    pub async fn change_model(&self, model: ModelSelection) -> Result<(), AISessionError> {
        let mut config = self.config.write().await;
        config.model = model;
        Ok(())
    }

    /// Change the charge target of the session.
    pub async fn change_charge_target(
        &self,
        charge_target: ChargeTarget,
    ) -> Result<(), AISessionError> {
        let mut config = self.config.write().await;
        config.charge_target = charge_target;
        Ok(())
    }

    /// Change the active user of the session.
    pub async fn change_user(&self, user: String) -> Result<(), AISessionError> {
        let mut config = self.config.write().await;
        config.desktop_username = user;
        Ok(())
    }

    /// Send a user message to the session.
    pub async fn send_user_message(&self, content: String) -> Result<(), AISessionError> {
        let msg = ChatMessage::user(content);
        self.event_tx
            .send(Event::UserMessage(msg))
            .await
            .map_err(|_| AISessionError::ChannelClosed)
    }

    /// Send a tool result to the session.
    pub async fn send_tool_result(
        &self,
        call_id: String,
        success: bool,
        output: String,
    ) -> Result<(), AISessionError> {
        let result = ToolResult {
            call_id,
            output: if success {
                ToolOutput::Success(output)
            } else {
                ToolOutput::Error(output)
            },
        };
        self.event_tx
            .send(Event::ToolResult(result))
            .await
            .map_err(|_| AISessionError::ChannelClosed)
    }

    /// Cancel the current operation.
    pub async fn cancel(&self) -> Result<(), AISessionError> {
        self.event_tx
            .send(Event::Cancel)
            .await
            .map_err(|_| AISessionError::ChannelClosed)
    }

    /// Send an edit request for InlineBlockGeneration sessions.
    /// This updates the system prompt to edit mode, responds to the pending submit_blocks call,
    /// and sends the user's edit prompt to continue generation.
    pub async fn send_edit_request(
        &self,
        edit_prompt: String,
        tool_call_id: String,
    ) -> Result<(), AISessionError> {
        // 1. Update kind to is_initial_generation: false
        {
            let mut kind = self.kind.write().await;
            if let SessionKind::InlineBlockGeneration {
                is_initial_generation,
                ..
            } = &mut *kind
            {
                *is_initial_generation = false;
            }
        }

        // 2. Generate new system prompt and send UpdateSystemPrompt event
        let new_system_prompt = {
            let kind = self.kind.read().await;
            kind.system_prompt()
                .map_err(AISessionError::SystemPromptError)?
        };
        let system_msg = ChatMessage::system(new_system_prompt).with_options(MessageOptions {
            cache_control: Some(CacheControl::Ephemeral),
        });
        self.event_tx
            .send(Event::UpdateSystemPrompt(system_msg))
            .await
            .map_err(|_| AISessionError::ChannelClosed)?;

        // 3. Send user message with edit prompt first (gets queued while in PendingTools)
        let user_msg = ChatMessage::user(edit_prompt);
        self.event_tx
            .send(Event::UserMessage(user_msg))
            .await
            .map_err(|_| AISessionError::ChannelClosed)?;

        // 4. Send tool result for submit_blocks - this completes PendingTools,
        //    drains the queued edit prompt, and triggers a single LLM request
        self.event_tx
            .send(Event::ToolResult(ToolResult {
                call_id: tool_call_id,
                output: ToolOutput::Success(
                    "Blocks shown to user. User has requested changes.".to_string(),
                ),
            }))
            .await
            .map_err(|_| AISessionError::ChannelClosed)?;

        Ok(())
    }
}

/// The AI session driver.
///
/// Wraps the Agent FSM and handles effect execution.
pub struct AISession {
    id: Uuid,
    config: Arc<RwLock<SessionConfig>>,
    kind: Arc<RwLock<SessionKind>>,
    client: AtuinAIClient,
    agent: Arc<RwLock<Agent>>,
    event_tx: mpsc::Sender<Event>,
    event_rx: mpsc::Receiver<Event>,
    output_tx: mpsc::Sender<SessionEvent>,
    secret_cache: Arc<SecretCache>,
    storage: Arc<AISessionStorage>,
    /// Cancellation signal for the stream processing task.
    cancel_tx: watch::Sender<bool>,
}

impl AISession {
    /// Create a new session, returning the session and a handle for sending events.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        kind: SessionKind,
        config: SessionConfig,
        output_tx: mpsc::Sender<SessionEvent>,
        secret_cache: Arc<SecretCache>,
        storage: Arc<AISessionStorage>,
    ) -> Result<(Self, SessionHandle), AISessionError> {
        let client = AtuinAIClient::new(secret_cache.clone());
        let (event_tx, event_rx) = mpsc::channel(32);
        let (cancel_tx, _cancel_rx) = watch::channel(false);

        let system_prompt = kind
            .system_prompt()
            .map_err(AISessionError::SystemPromptError)?;

        let config = Arc::new(RwLock::new(config));
        let kind = Arc::new(RwLock::new(kind));

        let session = Self {
            id: Uuid::new_v4(),
            config: config.clone(),
            kind: kind.clone(),
            client,
            agent: Arc::new(RwLock::new(Agent::new(ChatMessage::system(system_prompt)))),
            event_tx: event_tx.clone(),
            event_rx,
            output_tx,
            secret_cache,
            storage,
            cancel_tx,
        };

        let handle = SessionHandle {
            id: session.id,
            event_tx,
            config,
            kind,
        };

        Ok((session, handle))
    }

    /// Get the session ID.
    pub fn id(&self) -> Uuid {
        self.id
    }

    /// Create a session from saved state, returning the session and a handle for sending events.
    #[allow(clippy::too_many_arguments)]
    pub fn from_saved(
        saved: SerializedAISessionV1,
        output_tx: mpsc::Sender<SessionEvent>,
        secret_cache: Arc<SecretCache>,
        storage: Arc<AISessionStorage>,
    ) -> Result<(Self, SessionHandle), AISessionError> {
        let client = AtuinAIClient::new(secret_cache.clone());
        let (event_tx, event_rx) = mpsc::channel(32);
        let (cancel_tx, _cancel_rx) = watch::channel(false);

        // Restore agent with saved state and context
        log::debug!("Restoring session {} from storage", saved.id);

        let system_prompt = saved
            .kind
            .system_prompt()
            .map_err(AISessionError::SystemPromptError)?;

        let config = Arc::new(RwLock::new(saved.config));
        let kind = Arc::new(RwLock::new(saved.kind));

        let agent = Agent::from_saved(
            saved.agent_state,
            saved.agent_context,
            ChatMessage::system(system_prompt),
        );

        let session = Self {
            id: saved.id,
            config: config.clone(),
            kind: kind.clone(),
            client,
            agent: Arc::new(RwLock::new(agent)),
            event_tx: event_tx.clone(),
            event_rx,
            output_tx,
            secret_cache,
            storage,
            cancel_tx,
        };

        let handle = SessionHandle {
            id: session.id,
            event_tx,
            config,
            kind,
        };

        Ok((session, handle))
    }

    /// Save the current session state to storage if the session is meant to be persisted.
    async fn save_if_persisted(&self) {
        if self.kind.read().await.persists_state() {
            self.save_state().await;
        }
    }

    /// Save the current session state to storage.
    async fn save_state(&self) {
        let (state, context) = {
            let agent = self.agent.read().await;
            (agent.state().clone(), agent.context().clone())
        };

        log::debug!("Saving session {} to storage", self.id);

        let config = self.config.read().await.clone();
        let kind = self.kind.read().await.clone();
        let runbook_id = kind.runbook_id();

        let serialized = SerializedAISessionV1 {
            id: self.id,
            config,
            kind,
            agent_state: state,
            agent_context: context,
        };

        if let Err(e) = self.storage.save(&runbook_id, &serialized).await {
            log::error!("Failed to save session state: {}", e);
        } else {
            log::debug!("Saved session {} state", self.id);
        }
    }

    /// Run the session event loop.
    ///
    /// This processes events and executes effects until the channel is closed.
    pub async fn run(mut self) {
        log::debug!("Starting session event loop for {}", self.id);

        // Save immediately so this session becomes the most recent for the runbook
        // This ensures "clear chat" followed by quit will restore a blank session
        self.save_if_persisted().await;

        while let Some(event) = self.event_rx.recv().await {
            log::trace!("Session {} received event: {:?}", self.id, event);

            // Feed event to FSM
            let transition = {
                let mut agent = self.agent.write().await;
                agent.handle(event)
            };

            // Execute effects
            for effect in transition.effects {
                if let Err(e) = self.execute_effect(effect).await {
                    log::error!("Session {} effect execution failed: {}", self.id, e);
                    let _ = self
                        .output_tx
                        .send(SessionEvent::Error {
                            message: e.to_string(),
                        })
                        .await;
                }
            }

            let _ = self
                .output_tx
                .send(SessionEvent::StateChanged {
                    state: self.agent.read().await.state().clone(),
                })
                .await;
        }

        log::debug!("Session {} event loop ended", self.id);
    }

    /// Execute a single effect.
    async fn execute_effect(&mut self, effect: Effect) -> Result<(), AISessionError> {
        match effect {
            Effect::StartRequest => {
                self.start_request().await?;
            }
            Effect::EmitChunk { content } => {
                if self.kind.read().await.emits_chunks() {
                    let _ = self.output_tx.send(SessionEvent::Chunk { content }).await;
                }
            }
            Effect::ExecuteTools { calls } => {
                // Check for submit_blocks tool call (used by InlineBlockGeneration sessions)
                if let Some(submit_call) = calls.iter().find(|c| c.fn_name == "submit_blocks") {
                    // Extract blocks from arguments and emit BlocksGenerated event
                    if let Some(blocks) = submit_call.fn_arguments.get("blocks") {
                        let blocks_vec = blocks.as_array().cloned().unwrap_or_default();
                        let _ = self
                            .output_tx
                            .send(SessionEvent::BlocksGenerated {
                                blocks: blocks_vec,
                                tool_call_id: submit_call.call_id.clone(),
                            })
                            .await;
                    }

                    // Filter out submit_blocks from the calls sent to frontend as ToolsRequested
                    // (submit_blocks is handled specially via BlocksGenerated)
                    let other_calls: Vec<AIToolCall> = calls
                        .into_iter()
                        .filter(|c| c.fn_name != "submit_blocks")
                        .map(|c| c.into())
                        .collect();

                    if !other_calls.is_empty() {
                        let _ = self
                            .output_tx
                            .send(SessionEvent::ToolsRequested { calls: other_calls })
                            .await;
                    }
                } else {
                    // Normal tool execution path
                    let ai_calls: Vec<AIToolCall> = calls.into_iter().map(|c| c.into()).collect();
                    let _ = self
                        .output_tx
                        .send(SessionEvent::ToolsRequested { calls: ai_calls })
                        .await;
                }
                // Save state when entering PendingTools
                self.save_if_persisted().await;
            }
            Effect::ToolResultReceived => {
                // Save state after each tool result for durability
                self.save_if_persisted().await;
            }
            Effect::ResponseComplete => {
                let _ = self.output_tx.send(SessionEvent::ResponseComplete).await;
                // Send updated history so frontends can update their conversation view
                // Note: We don't include pending_tool_calls here because ToolsRequested
                // already handles that, and including them would cause duplicates
                let messages = {
                    let agent = self.agent.read().await;
                    agent
                        .context()
                        .conversation
                        .iter()
                        .map(|m| AIMessage::from(m.clone()))
                        .collect()
                };
                let _ = self
                    .output_tx
                    .send(SessionEvent::History {
                        messages,
                        pending_tool_calls: vec![],
                    })
                    .await;
                // Save state on response complete
                self.save_if_persisted().await;
            }
            Effect::Error { message } => {
                let _ = self.output_tx.send(SessionEvent::Error { message }).await;
            }
            Effect::Cancelled => {
                // Signal the stream processing task to stop
                let _ = self.cancel_tx.send(true);
                let _ = self.output_tx.send(SessionEvent::Cancelled).await;
            }
        }
        Ok(())
    }

    /// Start a new request to the model.
    async fn start_request(&self) -> Result<(), AISessionError> {
        log::debug!("Starting request for session {}", self.id);

        // Build the request from conversation history (FSM uses ChatMessage directly)
        let messages = {
            let agent = self.agent.read().await;
            agent.context().conversation.clone()
        };

        let config = self.config.read().await.clone();
        let kind = self.kind.read().await.clone();
        let model_str = config.model.to_string();

        let system_prompt = kind
            .system_prompt()
            .map_err(AISessionError::SystemPromptError)?;
        let tools = kind.tools();

        let chat_request = ChatRequest::new(messages)
            .with_system(system_prompt)
            .with_tools(tools);

        let mut chat_options = ChatOptions::default().with_capture_tool_calls(true);

        // If we're using the Atuin Hub provider, add proprietary headers for auth and charge tracking
        if let ModelSelection::AtuinHub { .. } = &config.model {
            let secret = self
                .secret_cache
                .get("sh.atuin.runbooks.api", &config.desktop_username)
                .await?
                .ok_or(AISessionError::CredentialError(
                    SecretCacheError::LookupFailed {
                        service: "sh.atuin.runbooks.api".to_string(),
                        user: config.desktop_username.clone(),
                        context: "No Atuin Hub API key found".to_string(),
                    },
                ))?;

            let action = match kind {
                SessionKind::InlineBlockGeneration {
                    is_initial_generation,
                    ..
                } => {
                    if is_initial_generation {
                        "generate"
                    } else {
                        "edit"
                    }
                }
                SessionKind::AssistantChat { .. } => "proxy",
            };

            let api_key_header = "x-atuin-hub-api-key".to_string();
            let api_charge_header = "x-atuin-charge-to".to_string();
            let api_action_header = "x-atuin-action".to_string();

            let extra_headers = vec![
                (api_key_header, secret),
                (api_charge_header, config.charge_target.to_string()),
                (api_action_header, action.to_string()),
            ];

            chat_options = chat_options.with_extra_headers(extra_headers);
        }

        drop(config);
        drop(kind);

        log::debug!(
            "Executing chat stream for session {} with model {model_str}",
            self.id
        );
        let stream = self
            .client
            .exec_chat_stream(&model_str, chat_request, Some(&chat_options))
            .await?;

        // Spawn task to process the stream
        let event_tx = self.event_tx.clone();
        let session_id = self.id;
        // Reset cancellation signal before starting new stream
        let _ = self.cancel_tx.send(false);
        let cancel_rx = self.cancel_tx.subscribe();

        tokio::spawn(async move {
            log::debug!("Processing stream for session {}", session_id);
            Self::process_stream(session_id, stream, event_tx, cancel_rx).await;
        });

        Ok(())
    }

    /// Process the streaming response, feeding events back to the FSM.
    async fn process_stream(
        session_id: Uuid,
        stream_response: genai::chat::ChatStreamResponse,
        event_tx: mpsc::Sender<Event>,
        mut cancel_rx: watch::Receiver<bool>,
    ) {
        let mut stream = stream_response.stream;
        let mut tool_calls: Vec<ToolCall> = Vec::new();

        // Send StreamStart
        if event_tx.send(Event::StreamStart).await.is_err() {
            log::warn!("Session {} channel closed during stream", session_id);
            return;
        }

        loop {
            tokio::select! {
                // Check for cancellation
                _ = cancel_rx.changed() => {
                    if *cancel_rx.borrow() {
                        log::debug!("Session {} stream cancelled", session_id);
                        // Don't send StreamEnd - the FSM already handled the Cancel event
                        return;
                    }
                }
                // Process stream events
                maybe_result = stream.next() => {
                    let Some(result) = maybe_result else {
                        // Stream ended naturally
                        break;
                    };

                    match result {
                        Err(e) => {
                            log::error!("Session {} stream error: {}", session_id, e);
                            let mut message = e.to_string();

                            if let genai::Error::WebStream { error, .. } = e {
                                if let Some(genai::Error::HttpError { status, body, .. }) = error.downcast_ref::<genai::Error>() {
                                    if let Ok(json) = serde_json::from_str::<serde_json::Value>(body) {
                                        if let Some(msg) = json.get("error").and_then(|m| m.as_str()) {
                                            message = msg.to_string();
                                        }

                                        if let Some(details) = json.get("details").and_then(|d| d.as_object()) {
                                            for (k, v) in details.iter() {
                                                message += &format!("\n  {}: {}", k, v);
                                            }
                                        }
                                    } else {
                                        message = format!("HTTP error {}: {}", status, body);
                                    }
                                }
                            }

                            let _ = event_tx
                                .send(Event::RequestFailed {
                                    error: message,
                                })
                                .await;
                            return;
                        }
                        Ok(ChatStreamEvent::Start) => {
                            log::trace!("Session {} received stream start", session_id);
                            // Already sent StreamStart above
                        }
                        Ok(ChatStreamEvent::Chunk(chunk)) => {
                            log::trace!("Session {} received chunk", session_id);
                            let _ = event_tx
                                .send(Event::StreamChunk(StreamChunk {
                                    content: chunk.content,
                                }))
                                .await;
                        }
                        Ok(ChatStreamEvent::ThoughtSignatureChunk(_)) => {
                            log::trace!("Session {} received thought signature chunk", session_id,);
                        }
                        Ok(ChatStreamEvent::ToolCallChunk(_tc_chunk)) => {
                            // Tool call chunks are accumulated by genai internally
                            // We'll get the complete tool calls in the End event
                            log::trace!("Session {} received tool call chunk", session_id);
                        }
                        Ok(ChatStreamEvent::ReasoningChunk(_)) => {
                            log::trace!("Session {} received reasoning chunk", session_id);
                            // Ignore reasoning chunks for now
                        }
                        Ok(ChatStreamEvent::End(end)) => {
                            log::trace!("Session {} received stream end", session_id);
                            // Extract tool calls from captured content
                            if let Some(content) = end.captured_content {
                                for part in content.into_parts() {
                                    if let genai::chat::ContentPart::ToolCall(tc) = part {
                                        tool_calls.push(tc);
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }

        // Send StreamEnd with any tool calls
        let _ = event_tx.send(Event::StreamEnd { tool_calls }).await;
    }
}

```

## /backend/src/ai/storage.rs

```rs path="/backend/src/ai/storage.rs" 
use serde::{Deserialize, Serialize};
use sqlx::sqlite::SqliteRow;
use sqlx::{FromRow, Row, SqlitePool};
use uuid::Uuid;

use crate::ai::fsm;
use crate::ai::types::{ChargeTarget, ModelSelection, SessionConfig, SessionKind};

#[derive(Debug, thiserror::Error)]
pub enum StorageError {
    #[error("Serialization error: {0}")]
    SerializationError(#[from] serde_json::Error),

    #[error("Database error: {0}")]
    DatabaseError(#[from] sqlx::Error),
}

/// The original version of the serialization format.
/// It has a few quirks:
///
/// - `id` and `runbook_id` were mistakenly stored as JSON strings
/// - `updated_at` was not stored in the session data
///
/// We take care of these during DB loading and conversion to the V1 format, below.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedAISessionV0 {
    #[serde(skip)]
    pub id: Uuid,
    #[serde(skip)]
    pub runbook_id: Uuid,
    pub model: ModelSelection,
    pub agent_state: fsm::State,
    pub agent_context: fsm::Context,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedAISessionV1 {
    pub id: Uuid,
    pub config: SessionConfig,
    pub kind: SessionKind,
    pub agent_state: fsm::State,
    pub agent_context: fsm::Context,
}

impl From<SerializedAISessionV0> for SerializedAISessionV1 {
    fn from(session: SerializedAISessionV0) -> Self {
        Self {
            id: session.id,
            config: SessionConfig {
                model: session.model,
                desktop_username: "".to_string(),
                charge_target: ChargeTarget::User,
            },
            kind: SessionKind::AssistantChat {
                runbook_id: session.runbook_id,
                block_infos: vec![],
            },
            agent_state: session.agent_state,
            agent_context: session.agent_context,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "version", content = "data")]
pub enum SerializedAISession {
    V0(SerializedAISessionV0),
    V1(SerializedAISessionV1),
}

impl SerializedAISession {
    pub fn to_latest(&self) -> SerializedAISessionV1 {
        match self {
            SerializedAISession::V0(session) => SerializedAISessionV1::from(session.clone()),
            SerializedAISession::V1(session) => session.clone(),
        }
    }
}

impl FromRow<'_, SqliteRow> for SerializedAISession {
    fn from_row(row: &SqliteRow) -> Result<Self, sqlx::Error> {
        let version: i64 = row.get("version");

        match version {
            0 => {
                // Version 0 of the serialization format mistakenly stored the id and runbook_id as JSON.
                let id = json_str_to_uuid(&row.get::<String, _>("id"))?;
                let runbook_id = json_str_to_uuid(&row.get::<String, _>("runbook_id"))?;

                let session_json: String = row.get("session");
                let mut session: SerializedAISessionV0 = serde_json::from_str(&session_json)
                    .map_err(|e| sqlx::Error::ColumnDecode {
                        index: "session".to_string(),
                        source: Box::new(e),
                    })?;
                session.id = id;
                session.runbook_id = runbook_id;
                Ok(SerializedAISession::V0(session))
            }
            1 => {
                let session_json: String = row.get("session");
                let session: SerializedAISessionV1 =
                    serde_json::from_str(&session_json).map_err(|e| sqlx::Error::ColumnDecode {
                        index: "session".to_string(),
                        source: Box::new(e),
                    })?;
                Ok(SerializedAISession::V1(session))
            }
            _ => Err(sqlx::Error::ColumnDecode {
                index: "version".to_string(),
                source: Box::new(std::io::Error::other("Invalid version")),
            }),
        }
    }
}

pub struct AISessionStorage {
    pool: SqlitePool,
}

impl AISessionStorage {
    pub fn new(pool: SqlitePool) -> Self {
        Self { pool }
    }

    /// Save or update a session
    pub async fn save(
        &self,
        runbook_id: &Uuid,
        session: &SerializedAISessionV1,
    ) -> Result<(), StorageError> {
        sqlx::query(
            "INSERT OR REPLACE INTO ai_sessions (version, id, runbook_id, session, updated_at) VALUES (?, ?, ?, ?, ?)"
        )
            .bind(1)
            .bind(session.id.to_string())
            .bind(runbook_id.to_string())
            .bind(serde_json::to_string(&session)?)
            .bind(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64)
            .execute(&self.pool)
            .await?;

        Ok(())
    }

    /// Find the most recent session for a runbook
    pub async fn find_most_recent_for_runbook(
        &self,
        runbook_id: &Uuid,
    ) -> Result<Option<SerializedAISessionV1>, StorageError> {
        // Version 0 of the serialization format mistakenly stored the runbook_id as a JSON string.
        let session: Option<SerializedAISession> = sqlx::query_as(
            "SELECT * FROM ai_sessions WHERE runbook_id = ? OR runbook_id = ? ORDER BY updated_at DESC LIMIT 1",
        )
        .bind(serde_json::to_string(runbook_id)?)
        .bind(runbook_id.to_string())
        .fetch_optional(&self.pool)
        .await?;

        Ok(session.map(|s| s.to_latest()))
    }
}

fn json_str_to_uuid(json_str: &str) -> Result<Uuid, sqlx::Error> {
    let id_str =
        serde_json::from_str::<String>(json_str).map_err(|e| sqlx::Error::ColumnDecode {
            index: "id".to_string(),
            source: Box::new(e),
        })?;
    Uuid::parse_str(&id_str).map_err(|e| sqlx::Error::ColumnDecode {
        index: "id".to_string(),
        source: Box::new(e),
    })
}

```

## /backend/src/ai/system_prompt.minijinja.txt

{% if promptType == "assistant" %}
You are a runbook assistant for Atuin Desktop. You help users create, edit, and understand runbooks.
Respond conversationally but concisely. You have access to tools to read and modify the runbook.
Use these tools any time the user references the runbook document and you need to access or modify it.
If the user mentions content that you can't find in the user message, check the runbook document
to see if it's there.
{% endif %}{% if promptType == "generator.generate" or promptType == "generator.edit" %}
You are a block generator for Atuin Desktop. You help users generate new blocks based on the context
around the user's current block. You have access to tools to read and insert blocks into the runbook.
Use the `get_runbook_document` to read the current runbook document, and `submit_blocks` to specify
the blocks to insert into the runbook. If needed, you can use the `get_blocks_docs` tool to get the
detailed documentation for one or more blocks before you use the `submit_blocks` tool.

{%- if promptType == "generator.edit" %}
The user has requested a change to the blocks you generated most recently. You should generate
new blocks based on the information the user has provided in the conversation.
{% endif -%}

**IMPORTANT**: You should end your final turn with a single tool call to `submit_blocks`.
After you use the `submit_blocks` tool, the interaction with the user will end, unless
they choose to make edits to the generated blocks. Only send tool uses; any other text will be ignored.
{% endif %}

When creating blocks, prefer to create smaller, self-contained blocks that can be easily composed
with other blocks. If a block is too complex, break it down into smaller blocks. Similarly,
two scripts blocks that each contain one closely related command could be combined into a single
script block. If a script could more flexibly be implemented with blocks, prefer using blocks.
For example, "create a temporary directory and clone atuinsh/atuin" *could* be implemented as a
single script block with `mktemp` and `git clone`, but it is more useful to create three blocks:
a script to create the directory and set it to a variable, a directory block to set the current directory,
and a final script block to clone the repository.

Many block types have extra functionality or pitfalls that are not covered in the block summaries below.
Before operating on these blocks, debugging behavior of these blocks, or inserting new instances of blocks,
you MUST run the `get_blocks_docs` tool to get the documentation for the block types you intend to use or
think might be useful.

{% if promptType == "assistant" %}
Runbooks are meant to be a mix of documentation and automation. Use heading, paragraph, and list blocks to
add documentation to the runbook, but don't be too wordy. If the user already has documentation in the runbook,
prefer keeping it in the document and adding new blocks as needed, unless the user makes a request that
requires a change to the documentation. One such exception is the runbook heading; you should change this
from 'Untitled', especially when generating a new runbook. Runbooks are rarely completely blank, so you
should check the runbook content before operating on it.
{% endif %}

Custom block types support templating, but built-in BlockNote block types like paragraph and heading do not.
When formatting text, use the BlockNote content format for styling.

{% if promptType == "assistant" %}
Prefer generating or updating blocks in smaller, related batches rather than all at once, as doing so causes the user's UI
to hang while you generate the tool usage information.
{% endif %}

{% if blockInfos %}
## CUSTOM BLOCK SUMMARY:

{% for blockInfo in blockInfos %}
### {{ blockInfo.typeName }}
Known to users as: {{ blockInfo.friendlyName }}
{{ blockInfo.summary }}

{% endfor %}
{% endif %}

Use Script blocks for automation, capturing output, or running non-interactive flows.
Use Terminal blocks for interactive commands, debugging, or loading a user's shell configuration.

In addition to these blocks, you have access to the built-in BlockNote blocks like headings, paragraphs, and lists.
Be sure to use BlockNote 'content' properties and BlockNote's styling features to format text, instead of markdown.

## COMMON BLOCK PITFALLS - see block specific documentation from `get_blocks_docs` tool for more info:
- Script blocks don't load shell config (aliases, custom PATH won't work)
- Terminal blocks need 'exit' or for the user to cancel the block to continue serial execution
- Local-var and local-directory can't be set programmatically - these blocks do not take any props and the user must set them manually in the UI
- Terminal (run) blocks use 'type' for the interpreter, while Script blocks use 'interpreter' for the interpreter
- Script blocks can save stdout to template variables via its 'outputVariable' prop, while Terminal blocks cannot
  and must use the $ATUIN_OUTPUT_VARS file, explained below.
- Terminal and Script blocks default to outputVisible=true (terminal output is shown to user)
- Dropdown blocks do NOT pause serial execution - they use whatever value is currently selected, even if none
- SSH Connect sets remote context for subsequent Terminal/Script blocks until another SSH Connect or Host block
- HTTP, SQL, Kubernetes, Prometheus blocks always run locally regardless of SSH context

## RUNBOOK CAPABILITIES:

{% raw %}
TEMPLATING (MiniJinja):
- Variables: {{ var.name }} - reference template variables
- Filters: {{ var.value | shellquote }} - escape for safe shell use
            {{ var.name | default(\"fallback\") }} - use fallback if undefined
- Conditionals: {% if var.foo %}...{% endif %}
- Loops: {% for item in var.list %}{{ item }}{% endfor %}
- Block outputs: {{ doc.named['block_name'].output.field }} - access other blocks' results
  Blocks with output: HTTP, Script, Terminal, SQL (all), Kubernetes, Prometheus. Use `get_blocks_docs` for fields.
{% endraw %}

VARIABLE TYPES:
- 'var' blocks: Synced across all collaborators, stored with runbook
- 'local-var' blocks: Private to individual user (good for credentials)
- 'env' blocks: Set environment variables for downstream blocks

TEMPLATE VARIABLES:
- A few blocks specifically support writing to template variables
- Script and Terminal blocks can write template variables to the $ATUIN_OUTPUT_VARS file:
  - Simple format for single-line values: echo \"name=value\" >> $ATUIN_OUTPUT_VARS
  - Heredoc format for multiline values:
    {
        echo \"notes<<EOF\"
        echo \"This is line 1\"
        echo \"This is line 2\"
        echo \"EOF\"
    } >> $ATUIN_OUTPUT_VARS
- Output variables are not made available to the runbook until the Script or Terminal block exits successfully - for a Terminal block, this requires an explicit 'exit' or for the user to cancel the block.

## SERIAL EXECUTION:
Atuin Desktop offers a feature called 'serial execution' which runs the blocks in a runbook sequentially, from top to bottom.
Once a block completes *successfully*, the next block in the runbook will start. If a block fails or is cancelled, the serial execution will stop.
Note that Terminal blocks require an explicit 'exit' or for the user to cancel the block to continue serial execution; cancellation does not stop the execution flow.
Serial execution is available in the top right of the runbook editor, and can be triggered by clicking the play button.
To programmatically pause serial execution, use the 'pause' block.

## BEST PRACTICES:
- Prefer template variables over shell variables - they're visible in UI and persist
- The Dropdown block is very powerful, and a great way to control automation flow dynamically.
- Use 'var' blocks for values users might want to change
- Give blocks that support names descriptive names so outputs can be referenced
- Use outputVariable to pass simple data between blocks
- Use ATUIN_OUTPUT_VARS or block outputs to pass more complex data between blocks
- Use the 'shellquote' MiniJinja filter to escape variables for safe shell use


## /backend/src/ai/tools.rs

```rs path="/backend/src/ai/tools.rs" 
use genai::chat::Tool;
use indoc::indoc;
use serde_json::json;

pub struct AITools;

impl AITools {
    pub fn get_runboook_document() -> Tool {
        Tool::new("get_runbook_document")
            .with_description(indoc! {"
                Get the current runbook document. Returns the document as a BlockNote JSON object
                with custom blocks. Use this to read the runbook content before making edits or
                answering questions about it.
            "})
            .with_schema(json!({
                "type": "object",
                "properties": {},
                "required": [],
            }))
    }

    pub fn get_block_docs(block_types: &[String]) -> Tool {
        Tool::new("get_block_docs")
            .with_description(indoc! {"
                Get documentation for specific block types. Use this to ensure you're generating blocks
                with the correct syntax and parameters, or to understand a block's capabilities.
                You can specify multiple block types to get documentation for.
            "})
            .with_schema(json!({
                "type": "object",
                "properties": {
                    "block_types": {
                        "type": "array",
                        "items": {
                            "type": "string",
                            "enum": block_types,
                        },
                        "description": "The block types to get documentation for.",
                    },
                },
                "required": ["block_types"],
            }))
    }

    pub fn get_default_shell() -> Tool {
        Tool::new("get_default_shell")
            .with_description(indoc! {"
                Get the default shell for the user. Use this to determine the shell to use for new script or terminal blocks.
            "})
            .with_schema(json!({
                "type": "object",
                "properties": {},
                "required": [],
            }))
    }

    pub fn insert_blocks(_block_types: &[String]) -> Tool {
        Tool::new("insert_blocks")
            .with_description(indoc! {"
                Insert one or more blocks into the runbook at a specified position.
                Use this to add new content to the runbook. Each block object is defined
                by a 'type' property, which is the name of the block type, and a 'props' property,
                which is an object containing the properties of the block as specified in the block documentation.

                In addition to custom blocks, you can insert any of the built-in BlockNote blocks by using their
                name as the 'type' property and their associated properties as the 'props' property.

                Before you use this tool, you should use the 'get_block_docs' tool for the
                block types you're planning on using to ensure you're using the correct block types and properties.

                The 'blocks' array should be a JavaScript array and not a serialized JSON string.
            "})
            .with_schema(json!({
                "type": "object",
                "properties": {
                    "blocks": {
                        "type": "array",
                        "description": "Array of block objects to insert. Each block object must have a 'type' property and a 'props' property.",
                        "items": {
                            "type": "object",
                            "properties": {
                                "type": {
                                    "type": "string",
                                },
                                "props": {
                                    "type": "object"
                                }
                            }

                        }
                    },
                    "position": {
                        "type": "string",
                        "enum": ["before", "after", "end"],
                        "description": "Where to insert: 'before' or 'after' the reference block, or 'end' of document"
                    },
                    "reference_block_id": {
                        "type": "string",
                        "description": "ID of the block to insert before/after. Required unless position is 'end'"
                    },
                },
                "required": ["blocks", "position"]
            }))
    }

    pub fn update_block() -> Tool {
        Tool::new("update_block")
            .with_description(indoc! {"
                Update an existing block in the runbook. Replaces the block's properties
                and/or content while preserving its ID. Use this to make minor changes to
                a single block's properties or content.

                Before you use this tool, you should use the 'get_block_docs' tool for the
                block types you're planning on using to ensure you're using the correct block types and properties.

                When updating blocks, take care to update its name as well, if it has one and the content has meaningfully changed.
            "})
            .with_schema(json!({
                "type": "object",
                "properties": {
                    "block_id": {
                        "type": "string",
                        "description": "ID of the block to update"
                    },
                    "props": {
                        "type": "object",
                        "description": "New properties for the block"
                    },
                    "content": {
                        "type": "array",
                        "description": "New content for the block (for heading, paragraph, and other BlockNote blocks)",
                        "items": { "type": "object" }
                    }
                },
                "required": ["block_id"]
            }))
    }

    pub fn replace_blocks() -> Tool {
        Tool::new("replace_blocks")
            .with_description(indoc! {"
                Replace one or more existing blocks in the runbook with new blocks.
                The blocks at the specified IDs will be removed and the new blocks
                will be inserted in their place. You can delete blocks by specifying
                their IDs in the 'block_ids' array and not including any blocks in the
                'new_blocks' array.

                Before you use this tool, you should use the 'get_block_docs' tool for the
                block types you're planning on using to ensure you're using the correct block types and properties.
            "})
            .with_schema(json!({
                "type": "object",
                "properties": {
                    "block_ids": {
                        "type": "array",
                        "description": "IDs of the blocks to be replaced",
                        "items": { "type": "string" }
                    },
                    "new_blocks": {
                        "type": "array",
                        "description": "Array of new block objects to insert in place of the removed blocks. Each block object must have a 'type' property and a 'props' property.",
                        "items": {
                            "type": "object",
                            "properties": {
                                "type": {
                                    "type": "string",
                                },
                                "props": {
                                    "type": "object"
                                }
                            }

                        }
                    }
                },
                "required": ["block_ids", "new_blocks"]
            }))
    }

    pub fn submit_blocks() -> Tool {
        Tool::new("submit_blocks")
            .with_description(indoc! {"
                Submit the generated blocks to be inserted into the runbook.
                This tool should be called exactly once at the end of your response
                with all the blocks you want to insert. After calling this tool,
                the interaction ends unless the user requests edits.

                Each block object must have a 'type' property (the block type name)
                and a 'props' property (the block's properties as specified in the block documentation).
            "})
            .with_schema(json!({
                "type": "object",
                "properties": {
                    "blocks": {
                        "type": "array",
                        "description": "Array of block objects to insert. Each must have 'type' and 'props'.",
                        "items": {
                            "type": "object",
                            "properties": {
                                "type": {
                                    "type": "string",
                                    "description": "The block type name"
                                },
                                "props": {
                                    "type": "object",
                                    "description": "The block's properties"
                                }
                            },
                            "required": ["type", "props"]
                        }
                    }
                },
                "required": ["blocks"]
            }))
    }
}

```

## /backend/src/ai/types.rs

```rs path="/backend/src/ai/types.rs" 
//! AI types for Atuin Desktop.
//! These types implement serializable and TS exportable versions of the types from the genai crate.

use std::{
    fmt::{self, Display},
    ops::Deref,
    sync::Arc,
};

use serde::{Deserialize, Serialize};
use serde_json::Value;
use ts_rs::TS;
use uuid::Uuid;

use crate::ai::{
    prompts::{AIPrompts, GenerationType, PromptError},
    session::SessionEvent,
    tools::AITools,
};

#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq, Eq)]
#[serde(tag = "type", content = "data", rename_all = "camelCase")]
#[ts(export)]
pub enum ModelSelection {
    AtuinHub { model: String, uri: Option<String> },
    Claude { model: String },
    OpenAI { model: String, uri: Option<String> },
    Ollama { model: String, uri: Option<String> },
}

impl fmt::Display for ModelSelection {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ModelSelection::AtuinHub { model, uri } => match uri {
                Some(uri) => write!(f, "atuinhub::{model}::{}", uri.deref()),
                None => write!(f, "atuinhub::{model}::default"),
            },
            ModelSelection::Claude { model } => write!(f, "claude::{model}::default"),
            ModelSelection::OpenAI { model, uri } => match uri {
                Some(uri) => write!(f, "openai::{model}::{}", uri.deref()),
                None => write!(f, "openai::{model}::default"),
            },
            ModelSelection::Ollama { model, uri } => match uri {
                Some(uri) => write!(f, "ollama::{model}::{}", uri.deref()),
                None => write!(f, "ollama::{model}::http://localhost:11434"),
            },
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct AIMessage {
    pub role: AIMessageRole,
    pub content: AIMessageContent,
}

impl From<genai::chat::ChatMessage> for AIMessage {
    fn from(message: genai::chat::ChatMessage) -> Self {
        Self {
            role: message.role.into(),
            content: message.content.into(),
        }
    }
}

impl From<AIMessageContentPart> for AIMessage {
    fn from(part: AIMessageContentPart) -> Self {
        Self {
            role: AIMessageRole::User,
            content: AIMessageContent::from_parts(vec![part]),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub enum AIMessageRole {
    System,
    User,
    Assistant,
    Tool,
}

impl From<genai::chat::ChatRole> for AIMessageRole {
    fn from(role: genai::chat::ChatRole) -> Self {
        match role {
            genai::chat::ChatRole::System => AIMessageRole::System,
            genai::chat::ChatRole::User => AIMessageRole::User,
            genai::chat::ChatRole::Assistant => AIMessageRole::Assistant,
            genai::chat::ChatRole::Tool => AIMessageRole::Tool,
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct AIMessageContent {
    parts: Vec<AIMessageContentPart>,
}

impl AIMessageContent {
    pub fn from_parts(parts: Vec<AIMessageContentPart>) -> Self {
        Self { parts }
    }
}

impl From<genai::chat::MessageContent> for AIMessageContent {
    fn from(content: genai::chat::MessageContent) -> Self {
        Self {
            parts: content
                .into_parts()
                .into_iter()
                .map(|part| part.into())
                .collect(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(tag = "type", content = "data", rename_all = "camelCase")]
#[ts(export)]
pub enum AIMessageContentPart {
    Text(String),
    Binary(AIBinary),
    ToolCall(AIToolCall),
    ToolResponse(AIToolResponse),
    ThoughtSignature(String),
}

impl From<genai::chat::ContentPart> for AIMessageContentPart {
    fn from(part: genai::chat::ContentPart) -> Self {
        match part {
            genai::chat::ContentPart::Text(text) => AIMessageContentPart::Text(text.to_string()),
            genai::chat::ContentPart::Binary(binary) => AIMessageContentPart::Binary(binary.into()),
            genai::chat::ContentPart::ToolCall(tool_call) => {
                AIMessageContentPart::ToolCall(tool_call.into())
            }
            genai::chat::ContentPart::ToolResponse(tool_response) => {
                AIMessageContentPart::ToolResponse(tool_response.into())
            }
            genai::chat::ContentPart::ThoughtSignature(thought_signature) => {
                AIMessageContentPart::ThoughtSignature(thought_signature)
            }
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct AIBinary {
    content_type: String,
    source: AIBinarySource,
    name: Option<String>,
}

impl From<genai::chat::Binary> for AIBinary {
    fn from(binary: genai::chat::Binary) -> Self {
        Self {
            content_type: binary.content_type,
            source: binary.source.into(),
            name: binary.name,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(tag = "type", content = "data", rename_all = "camelCase")]
#[ts(export)]
pub enum AIBinarySource {
    Url(String),
    Base64(Arc<str>),
}

impl From<genai::chat::BinarySource> for AIBinarySource {
    fn from(source: genai::chat::BinarySource) -> Self {
        match source {
            genai::chat::BinarySource::Url(url) => AIBinarySource::Url(url),
            genai::chat::BinarySource::Base64(base64) => AIBinarySource::Base64(base64),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct AIToolCall {
    pub id: String,
    pub name: String,
    pub args: Value,
}

impl From<genai::chat::ToolCall> for AIToolCall {
    fn from(tool_call: genai::chat::ToolCall) -> Self {
        Self {
            id: tool_call.call_id,
            name: tool_call.fn_name,
            args: tool_call.fn_arguments,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct AIToolResponse {
    call_id: String,
    result: String,
}

impl From<genai::chat::ToolResponse> for AIToolResponse {
    fn from(tool_response: genai::chat::ToolResponse) -> Self {
        Self {
            call_id: tool_response.call_id,
            result: tool_response.content,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SessionConfig {
    pub model: ModelSelection,
    pub desktop_username: String,
    pub charge_target: ChargeTarget,
}

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub enum ChargeTarget {
    User,
    Org(String),
}

impl Display for ChargeTarget {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ChargeTarget::User => write!(f, "user"),
            ChargeTarget::Org(org) => write!(f, "org:{}", org),
        }
    }
}

/// The type of the session, which determines the behavior of the session.
/// Any data necessary for the session should be included here.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(tag = "type", content = "data", rename_all = "camelCase")]
#[ts(export)]
pub enum SessionKind {
    /// An AI assistant chat session, with streaming output and tool execution.
    AssistantChat {
        runbook_id: Uuid,
        block_infos: Vec<BlockInfo>,
    },

    InlineBlockGeneration {
        runbook_id: Uuid,
        block_infos: Vec<BlockInfo>,
        current_document: serde_json::Value,
        /// The ID of the block after which to insert the new blocks;
        /// this is the block where the user pressed Cmd/Ctrl+Enter.
        insert_after: Uuid,
        is_initial_generation: bool,
    },
}

impl SessionKind {
    pub fn runbook_id(&self) -> Uuid {
        match self {
            SessionKind::AssistantChat { runbook_id, .. } => *runbook_id,
            SessionKind::InlineBlockGeneration { runbook_id, .. } => *runbook_id,
        }
    }

    pub fn emits_chunks(&self) -> bool {
        matches!(self, SessionKind::AssistantChat { .. })
    }

    pub fn persists_state(&self) -> bool {
        matches!(self, SessionKind::AssistantChat { .. })
    }

    pub fn system_prompt(&self) -> Result<String, PromptError> {
        match self {
            SessionKind::AssistantChat { block_infos, .. } => {
                AIPrompts::assistant_system_prompt(block_infos.clone())
            }
            SessionKind::InlineBlockGeneration {
                block_infos,
                current_document,
                insert_after,
                is_initial_generation,
                ..
            } => {
                let generation_type = if *is_initial_generation {
                    GenerationType::Generate
                } else {
                    GenerationType::Edit
                };
                AIPrompts::generator_system_prompt(
                    generation_type,
                    block_infos.clone(),
                    current_document.clone(),
                    *insert_after,
                )
            }
        }
    }

    pub fn tools(&self) -> Vec<genai::chat::Tool> {
        match self {
            SessionKind::AssistantChat { block_infos, .. } => {
                let block_types = block_infos
                    .iter()
                    .map(|b| b.type_name.clone())
                    .collect::<Vec<String>>();

                vec![
                    AITools::get_runboook_document(),
                    AITools::get_block_docs(&block_types),
                    AITools::get_default_shell(),
                    AITools::insert_blocks(&block_types),
                    AITools::update_block(),
                    AITools::replace_blocks(),
                ]
            }
            SessionKind::InlineBlockGeneration { block_infos, .. } => {
                let block_types = block_infos
                    .iter()
                    .map(|b| b.type_name.clone())
                    .collect::<Vec<String>>();

                vec![
                    AITools::get_runboook_document(),
                    AITools::get_block_docs(&block_types),
                    AITools::get_default_shell(),
                    AITools::submit_blocks(),
                ]
            }
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct BlockInfo {
    pub type_name: String,
    pub friendly_name: String,
    pub summary: String,
    pub docs: String,
}

/// Summary information about an active session for the LLM Tools window.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SessionInfo {
    pub id: Uuid,
    pub runbook_id: Uuid,
    pub kind: String,
}

impl SessionInfo {
    pub fn from_session_kind(id: Uuid, kind: &SessionKind) -> Self {
        let kind_str = match kind {
            SessionKind::AssistantChat { .. } => "assistantChat",
            SessionKind::InlineBlockGeneration { .. } => "inlineBlockGeneration",
        };
        Self {
            id,
            runbook_id: kind.runbook_id(),
            kind: kind_str.to_string(),
        }
    }
}

/// Events broadcast to the LLM Tools window.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(export)]
pub enum LLMToolsEvent {
    SessionCreated {
        info: SessionInfo,
    },
    SessionDestroyed {
        session_id: Uuid,
    },
    SessionEvent {
        session_id: Uuid,
        event: SessionEvent,
    },
}

```

## /backend/src/blocks/mod.rs

```rs path="/backend/src/blocks/mod.rs" 
pub mod postgres;
pub mod sqlite_context_storage;

```

## /backend/src/blocks/postgres/command.rs

```rs path="/backend/src/blocks/postgres/command.rs" 
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use sqlx::{Column, Connection, PgConnection, Row};

#[derive(Debug, Default, Serialize, Deserialize)]
struct PostgresColumn {
    name: String,
    type_: String,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct PostgresQueryResult {
    columns: Vec<PostgresColumn>,
    rows: Vec<Vec<JsonValue>>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct PostgresExecuteResult {
    rows_affected: u64,
}

// At present, we pass in a URI and connect to it here. In future, I'd love
// to maintain a connection pool and not constantly reconnect. But for now,
// this is fine.
//
// It might also be nice to be able to create named database configs, and just
// reference them in blocks. I do think it's important to support ad-hoc connections
// though, so let's do those first.
#[tauri::command]
pub async fn postgres_query(
    uri: String,
    query: String,
    values: Option<Vec<JsonValue>>,
) -> Result<PostgresQueryResult, String> {
    let mut conn = PgConnection::connect(uri.as_str())
        .await
        .map_err(|e| e.to_string())?;

    let mut query = sqlx::query(&query);
    for value in values.unwrap_or_default() {
        if value.is_null() {
            query = query.bind(None::<JsonValue>);
        } else if value.is_string() {
            query = query.bind(value.as_str().unwrap().to_owned())
        } else {
            query = query.bind(value);
        }
    }
    let rows = query
        .fetch_all(&mut conn)
        .await
        .map_err(|e| e.to_string())?;

    if rows.is_empty() {
        return Ok(PostgresQueryResult::default());
    }

    let columns = rows[0]
        .columns()
        .iter()
        .map(|c| PostgresColumn {
            name: c.name().to_owned(),
            type_: c.type_info().to_string(),
        })
        .collect();

    let mut values = Vec::new();

    for row in rows {
        let mut value = Vec::default();

        for (i, _) in row.columns().iter().enumerate() {
            let v = row.try_get_raw(i).map_err(|e| e.to_string())?;
            let v = super::decode::to_json(v).map_err(|e| e.to_string())?;

            value.push(v);
        }

        values.push(value);
    }

    Ok(PostgresQueryResult {
        rows: values,
        columns,
    })
}

#[tauri::command]
pub async fn postgres_execute(
    uri: String,
    query: String,
    values: Option<Vec<JsonValue>>,
) -> Result<PostgresExecuteResult, String> {
    let mut conn = PgConnection::connect(uri.as_str())
        .await
        .map_err(|e| e.to_string())?;

    let mut query = sqlx::query(&query);
    for value in values.unwrap_or_default() {
        if value.is_null() {
            query = query.bind(None::<JsonValue>);
        } else if value.is_string() {
            query = query.bind(value.as_str().unwrap().to_owned())
        } else {
            query = query.bind(value);
        }
    }
    let res = query.execute(&mut conn).await.map_err(|e| e.to_string())?;

    Ok(PostgresExecuteResult {
        rows_affected: res.rows_affected(),
    })
}

```

## /backend/src/blocks/postgres/decode.rs

```rs path="/backend/src/blocks/postgres/decode.rs" 
use eyre::{eyre, Result};
use serde_json::Value as JsonValue;
use sqlx::postgres::types::{Oid, PgInterval};
use sqlx::{
    postgres::{PgTypeKind, PgValueRef},
    Decode, Postgres, TypeInfo, Value, ValueRef,
};
use time::{Date, OffsetDateTime, PrimitiveDateTime, Time};

pub(crate) fn to_json(v: PgValueRef) -> Result<JsonValue> {
    if v.is_null() {
        return Ok(JsonValue::Null);
    }

    let res = match v.type_info().name() {
        // Weirdly we get "CHAR" as the type, quoted, sometimes.
        "CHAR" | "VARCHAR" | "TEXT" | "NAME" | "citext" | "\"CHAR\"" => {
            match ValueRef::to_owned(&v).try_decode() {
                Ok(v) => JsonValue::String(v),
                _ => JsonValue::Null,
            }
        }
        "FLOAT4" => match ValueRef::to_owned(&v).try_decode::<f32>() {
            Ok(v) => JsonValue::from(v),
            _ => JsonValue::Null,
        },
        "FLOAT8" => match ValueRef::to_owned(&v).try_decode::<f64>() {
            Ok(v) => JsonValue::from(v),
            _ => JsonValue::Null,
        },
        "INT2" => match ValueRef::to_owned(&v).try_decode::<i16>() {
            Ok(v) => JsonValue::Number(v.into()),
            _ => JsonValue::Null,
        },
        "INT4" => match ValueRef::to_owned(&v).try_decode::<i32>() {
            Ok(v) => JsonValue::Number(v.into()),
            _ => JsonValue::Null,
        },
        "INT8" => match ValueRef::to_owned(&v).try_decode::<i64>() {
            Ok(v) => JsonValue::Number(v.into()),
            _ => JsonValue::Null,
        },
        "BOOL" => match ValueRef::to_owned(&v).try_decode() {
            Ok(v) => JsonValue::Bool(v),
            _ => JsonValue::Null,
        },
        "DATE" => match ValueRef::to_owned(&v).try_decode::<Date>() {
            Ok(v) => JsonValue::String(v.to_string()),
            _ => JsonValue::Null,
        },
        "TIME" => match ValueRef::to_owned(&v).try_decode::<Time>() {
            Ok(v) => JsonValue::String(v.to_string()),
            _ => JsonValue::Null,
        },
        "TIMESTAMP" => match ValueRef::to_owned(&v).try_decode::<PrimitiveDateTime>() {
            Ok(v) => JsonValue::String(v.to_string()),
            _ => JsonValue::Null,
        },
        "TIMESTAMPTZ" => match ValueRef::to_owned(&v).try_decode::<OffsetDateTime>() {
            Ok(v) => JsonValue::String(v.to_string()),
            _ => JsonValue::Null,
        },
        "JSON" | "JSONB" => ValueRef::to_owned(&v).try_decode().unwrap_or_default(),
        "BYTEA" => match ValueRef::to_owned(&v).try_decode::<Vec<u8>>() {
            Ok(v) => JsonValue::Array(v.into_iter().map(|n| JsonValue::Number(n.into())).collect()),
            _ => JsonValue::Null,
        },
        "VOID" => JsonValue::Null,
        "UUID" => match ValueRef::to_owned(&v).try_decode::<uuid::Uuid>() {
            Ok(v) => JsonValue::String(v.to_string()),
            _ => JsonValue::Null,
        },
        "INTERVAL" => {
            match ValueRef::to_owned(&v).try_decode::<PgInterval>() {
                Ok(v) => {
                    // TODO: Figure out how many seconds are in a month? wtf postgres?
                    // Ok note to future me, but it looks like postgres just assumes a month has 30
                    // days? bruh?
                    // https://github.com/postgres/postgres/blob/9ca67658d19e6c258eb4021a326ed7d38b3ab75f/src/include/datatype/timestamp.h#L116
                    let _months = v.months;

                    let days_as_secs = i64::from(v.days)
                        .checked_mul(24 * 60 * 60)
                        .expect("pginterval day overflow"); // 24 hours * 60 minutes * 60 seconds

                    // Convert microseconds to seconds and nanoseconds
                    let micros_as_secs = v.microseconds.div_euclid(1_000_000);
                    let remaining_nanos = (v.microseconds.rem_euclid(1_000_000) * 1000) as u32;

                    // Combine all seconds
                    let total_secs = days_as_secs
                        .checked_add(micros_as_secs)
                        .expect("pginterval day/second overflow");

                    let duration = std::time::Duration::new(total_secs as u64, remaining_nanos);

                    JsonValue::String(humantime::format_duration(duration).to_string())
                }
                _ => JsonValue::Null,
            }
        }
        "OID" => match ValueRef::to_owned(&v).try_decode::<Oid>() {
            Ok(v) => JsonValue::Number(v.0.into()),
            _ => JsonValue::Null,
        },
        "NUMERIC" => {
            // Numeric is a bigdecimal, ie very high precision. It is likely used for things like financial data.
            // If we cast it to an integer or a float, we lose precision.
            // Seeing a we are using JSON (kinda), we do not have built-in bigdecimal support. So, for now at least,
            // we just convert it to a string.
            match ValueRef::to_owned(&v).try_decode::<bigdecimal::BigDecimal>() {
                Ok(v) => JsonValue::from(v.to_string()),
                _ => JsonValue::Null,
            }
        }
        _ => match v.type_info().kind() {
            PgTypeKind::Enum(_) => {
                if let Ok(v) = <&str as Decode<Postgres>>::decode(v.clone()) {
                    JsonValue::String(v.to_string())
                } else {
                    JsonValue::Null
                }
            }
            _ => {
                return Err(eyre!(
                    "Unsupported data type: {}",
                    v.type_info().name().to_string(),
                ))
            }
        },
    };

    Ok(res)
}

```

## /backend/src/blocks/postgres/mod.rs

```rs path="/backend/src/blocks/postgres/mod.rs" 
pub mod command;
pub mod decode;

```

## /backend/src/blocks/sqlite_context_storage.rs

```rs path="/backend/src/blocks/sqlite_context_storage.rs" 
use sqlx::{sqlite::SqliteRow, FromRow, Row, SqlitePool};
use uuid::Uuid;

use atuin_desktop_runtime::context::{BlockContext, BlockContextStorage};

struct BlockContextWrapper(BlockContext);

pub struct SqliteContextStorage {
    pool: SqlitePool,
}

impl SqliteContextStorage {
    pub async fn new(pool: SqlitePool) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
        Ok(Self { pool })
    }
}

#[async_trait::async_trait]
impl BlockContextStorage for SqliteContextStorage {
    async fn save(
        &self,
        document_id: &str,
        block_id: &Uuid,
        context: &BlockContext,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        sqlx::query(
            "INSERT INTO context (document_id, block_id, context) VALUES (?, ?, ?) \
                ON CONFLICT(document_id, block_id) DO UPDATE SET context = ?",
        )
        .bind(document_id)
        .bind(block_id.to_string())
        .bind(serde_json::to_string(context)?)
        .bind(serde_json::to_string(context)?)
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    async fn load(
        &self,
        document_id: &str,
        block_id: &Uuid,
    ) -> Result<Option<BlockContext>, Box<dyn std::error::Error + Send + Sync>> {
        let context: Option<BlockContextWrapper> =
            sqlx::query_as("SELECT context FROM context WHERE document_id = ? AND block_id = ?")
                .bind(document_id)
                .bind(block_id.to_string())
                .fetch_optional(&self.pool)
                .await?;

        Ok(context.map(|c| c.0))
    }

    async fn delete(
        &self,
        document_id: &str,
        block_id: &Uuid,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        sqlx::query("DELETE FROM context WHERE document_id = ? AND block_id = ?")
            .bind(document_id)
            .bind(block_id.to_string())
            .execute(&self.pool)
            .await?;
        Ok(())
    }

    async fn delete_for_document(
        &self,
        document_id: &str,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        sqlx::query("DELETE FROM context WHERE document_id = ?")
            .bind(document_id)
            .execute(&self.pool)
            .await?;
        Ok(())
    }
}

impl<'a> FromRow<'a, SqliteRow> for BlockContextWrapper {
    fn from_row(row: &SqliteRow) -> Result<Self, sqlx::Error> {
        let json_context = row.get::<String, _>("context");
        let context: BlockContext =
            serde_json::from_str(&json_context).map_err(|e| sqlx::Error::ColumnDecode {
                index: "context".to_string(),
                source: Box::new(e),
            })?;
        Ok(BlockContextWrapper(context))
    }
}

```

## /backend/src/commands/audio.rs

```rs path="/backend/src/commands/audio.rs" 
use rodio::{Decoder, OutputStream, Sink};
use serde::Serialize;
use std::fs::File;
use std::io::BufReader;
use tauri::{path::BaseDirectory, AppHandle, Manager, Runtime};

#[derive(Debug, Serialize)]
pub struct SoundInfo {
    /// The filename without extension (e.g., "glad_to_know")
    pub id: String,
    /// Human-readable name (e.g., "Glad To Know")
    pub name: String,
}

/// Convert a filename stem (with underscores) to a human-readable name
fn to_display_name(stem: &str) -> String {
    stem.split('_')
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                None => String::new(),
                Some(first) => first.to_uppercase().chain(chars).collect(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

#[tauri::command]
pub async fn list_sounds<R: Runtime>(app: AppHandle<R>) -> Result<Vec<SoundInfo>, String> {
    let sounds_path = app
        .path()
        .resolve("resources/sounds", BaseDirectory::Resource)
        .map_err(|e| e.to_string())?;

    let mut sounds = Vec::new();

    let entries = std::fs::read_dir(&sounds_path).map_err(|e| e.to_string())?;

    for entry in entries {
        let entry = entry.map_err(|e| e.to_string())?;
        let path = entry.path();

        // Only include .ogg files
        if path.extension().and_then(|s| s.to_str()) != Some("ogg") {
            continue;
        }

        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
            let name = to_display_name(stem);
            sounds.push(SoundInfo {
                id: stem.to_string(),
                name,
            });
        }
    }

    // Sort by name for consistent ordering
    sounds.sort_by(|a, b| a.name.cmp(&b.name));

    Ok(sounds)
}

#[tauri::command]
pub async fn play_sound<R: Runtime>(
    app: AppHandle<R>,
    sound_id: String,
    volume: f32,
) -> Result<(), String> {
    log::info!(
        "play_sound called with sound_id={}, volume={}",
        sound_id,
        volume
    );

    let sounds_dir = app
        .path()
        .resolve("resources/sounds", BaseDirectory::Resource)
        .map_err(|e| e.to_string())?;

    let sound_path = sounds_dir.join(format!("{}.ogg", sound_id));
    if !sound_path.exists() {
        return Err(format!("Sound not found: {}", sound_id));
    }

    // Clamp volume to valid range
    let volume = volume.clamp(0.0, 1.0);
    log::info!("Playing sound {} at volume {}", sound_id, volume);

    // Spawn audio playback in a separate thread to avoid blocking
    std::thread::spawn(move || {
        let file = match File::open(&sound_path) {
            Ok(f) => f,
            Err(e) => {
                log::warn!("Failed to open sound file {:?}: {}", sound_path, e);
                return;
            }
        };

        let reader = BufReader::new(file);

        let (_stream, stream_handle) = match OutputStream::try_default() {
            Ok(s) => s,
            Err(e) => {
                log::warn!("Failed to get audio output stream: {}", e);
                return;
            }
        };

        let sink = match Sink::try_new(&stream_handle) {
            Ok(s) => s,
            Err(e) => {
                log::warn!("Failed to create audio sink: {}", e);
                return;
            }
        };

        // Set volume before appending source
        sink.set_volume(volume);

        let source = match Decoder::new(reader) {
            Ok(s) => s,
            Err(e) => {
                log::warn!("Failed to decode sound file: {}", e);
                return;
            }
        };

        sink.append(source);
        sink.sleep_until_end();
    });

    Ok(())
}

```

## /backend/src/commands/dependency.rs

```rs path="/backend/src/commands/dependency.rs" 
use atuin_desktop_runtime::blocks::Block;
use atuin_desktop_runtime::workflow::DependencySpec;
use eyre::Result;

#[tauri::command]
pub async fn can_run(
    state: tauri::State<'_, crate::state::AtuinState>,
    spec: DependencySpec,
    block: Block,
) -> Result<bool, String> {
    let can_run = spec
        .can_run(&block, state.exec_log())
        .await
        .map_err(|e| e.to_string())?;
    Ok(can_run)
}

```

## /backend/src/commands/exec_log.rs

```rs path="/backend/src/commands/exec_log.rs" 
use serde::{Deserialize, Serialize};
use tauri::Emitter;

use atuin_desktop_runtime::blocks::Block;
use atuin_desktop_runtime::workflow::WorkflowEvent;

#[derive(Debug, Serialize, Deserialize, Clone)]
struct ExecLogCompletedEvent {
    pub block_id: String,
    pub start_time: u64,
    pub end_time: u64,
    pub output: String,
}

#[tauri::command]
pub async fn log_execution<R: tauri::Runtime>(
    app: tauri::AppHandle<R>,
    state: tauri::State<'_, crate::state::AtuinState>,
    block: Block,
    start_time: u64,
    end_time: u64,
    output: String,
) -> Result<(), String> {
    log::debug!("Logging execution for block: {block:?}");
    state
        .exec_log()
        .log_execution(block.clone(), start_time, end_time, output.clone())
        .await
        .map_err(|e| e.to_string())?;

    let event_sender = state.event_sender();
    event_sender
        .send(WorkflowEvent::BlockFinished { id: block.id() })
        .expect("Failed to send stop block event");

    app.emit(
        format!("exec_log_completed:{}", block.id()).as_str(),
        ExecLogCompletedEvent {
            block_id: block.id().to_string(),
            start_time,
            end_time,
            output,
        },
    )
    .map_err(|e| e.to_string())
}

```

## /backend/src/store.rs

```rs path="/backend/src/store.rs" 


```


The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.
Copied!