Repository: atuinsh/atuin Branch: main Commit: c636554333a0 Files: 418 Total size: 2.2 MB Directory structure: gitextract_6o_czke4/ ├── .cargo/ │ └── audit.toml ├── .codespellrc ├── .dockerignore ├── .gitattributes ├── .github/ │ ├── DISCUSSION_TEMPLATE/ │ │ └── support.yml │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ └── bug.yaml │ ├── dependabot.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── codespell.yml │ ├── docker.yaml │ ├── installer.yml │ ├── nix.yml │ ├── release.yml │ ├── rust.yml │ ├── shellcheck.yml │ └── update-nix-deps.yml ├── .gitignore ├── .mailmap ├── .rustfmt.toml ├── AGENTS.md ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── CONTRIBUTORS ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── atuin.nix ├── atuin.plugin.zsh ├── cliff.toml ├── crates/ │ ├── atuin/ │ │ ├── Cargo.toml │ │ ├── LICENSE │ │ ├── build.rs │ │ ├── src/ │ │ │ ├── command/ │ │ │ │ ├── client/ │ │ │ │ │ ├── account/ │ │ │ │ │ │ ├── change_password.rs │ │ │ │ │ │ ├── delete.rs │ │ │ │ │ │ ├── link.rs │ │ │ │ │ │ ├── login.rs │ │ │ │ │ │ ├── logout.rs │ │ │ │ │ │ └── register.rs │ │ │ │ │ ├── account.rs │ │ │ │ │ ├── daemon.rs │ │ │ │ │ ├── default_config.rs │ │ │ │ │ ├── doctor.rs │ │ │ │ │ ├── dotfiles/ │ │ │ │ │ │ ├── alias.rs │ │ │ │ │ │ └── var.rs │ │ │ │ │ ├── dotfiles.rs │ │ │ │ │ ├── history.rs │ │ │ │ │ ├── import.rs │ │ │ │ │ ├── info.rs │ │ │ │ │ ├── init/ │ │ │ │ │ │ ├── bash.rs │ │ │ │ │ │ ├── fish.rs │ │ │ │ │ │ ├── powershell.rs │ │ │ │ │ │ ├── xonsh.rs │ │ │ │ │ │ └── zsh.rs │ │ │ │ │ ├── init.rs │ │ │ │ │ ├── kv.rs │ │ │ │ │ ├── scripts.rs │ │ │ │ │ ├── search/ │ │ │ │ │ │ ├── cursor.rs │ │ │ │ │ │ ├── duration.rs │ │ │ │ │ │ ├── engines/ │ │ │ │ │ │ │ ├── daemon.rs │ │ │ │ │ │ │ ├── db.rs │ │ │ │ │ │ │ └── skim.rs │ │ │ │ │ │ ├── engines.rs │ │ │ │ │ │ ├── history_list.rs │ │ │ │ │ │ ├── inspector.rs │ │ │ │ │ │ ├── interactive.rs │ │ │ │ │ │ └── keybindings/ │ │ │ │ │ │ ├── actions.rs │ │ │ │ │ │ ├── conditions.rs │ │ │ │ │ │ ├── defaults.rs │ │ │ │ │ │ ├── key.rs │ │ │ │ │ │ ├── keymap.rs │ │ │ │ │ │ └── mod.rs │ │ │ │ │ ├── search.rs │ │ │ │ │ ├── setup.rs │ │ │ │ │ ├── stats.rs │ │ │ │ │ ├── store/ │ │ │ │ │ │ ├── pull.rs │ │ │ │ │ │ ├── purge.rs │ │ │ │ │ │ ├── push.rs │ │ │ │ │ │ ├── rebuild.rs │ │ │ │ │ │ ├── rekey.rs │ │ │ │ │ │ └── verify.rs │ │ │ │ │ ├── store.rs │ │ │ │ │ ├── sync/ │ │ │ │ │ │ └── status.rs │ │ │ │ │ ├── sync.rs │ │ │ │ │ └── wrapped.rs │ │ │ │ ├── client.rs │ │ │ │ ├── contributors.rs │ │ │ │ ├── external.rs │ │ │ │ ├── gen_completions.rs │ │ │ │ └── mod.rs │ │ │ ├── main.rs │ │ │ ├── shell/ │ │ │ │ ├── .gitattributes │ │ │ │ ├── atuin.bash │ │ │ │ ├── atuin.fish │ │ │ │ ├── atuin.nu │ │ │ │ ├── atuin.ps1 │ │ │ │ ├── atuin.xsh │ │ │ │ └── atuin.zsh │ │ │ └── sync.rs │ │ └── tests/ │ │ ├── common/ │ │ │ └── mod.rs │ │ ├── sync.rs │ │ └── users.rs │ ├── atuin-ai/ │ │ ├── Cargo.toml │ │ ├── render-tests.sh │ │ ├── replay-states.sh │ │ ├── src/ │ │ │ ├── commands/ │ │ │ │ ├── debug_render.rs │ │ │ │ ├── init.rs │ │ │ │ └── inline.rs │ │ │ ├── commands.rs │ │ │ ├── lib.rs │ │ │ └── tui/ │ │ │ ├── app.rs │ │ │ ├── component.rs │ │ │ ├── components.rs │ │ │ ├── event.rs │ │ │ ├── mod.rs │ │ │ ├── popup.rs │ │ │ ├── render.rs │ │ │ ├── spinner.rs │ │ │ ├── state.rs │ │ │ ├── terminal.rs │ │ │ └── view_model.rs │ │ └── test-renders.json │ ├── atuin-client/ │ │ ├── Cargo.toml │ │ ├── config.toml │ │ ├── meta-migrations/ │ │ │ └── 20260203030924_create_meta.sql │ │ ├── migrations/ │ │ │ ├── 20210422143411_create_history.sql │ │ │ ├── 20220505083406_create-events.sql │ │ │ ├── 20220806155627_interactive_search_index.sql │ │ │ ├── 20230315220114_drop-events.sql │ │ │ ├── 20230319185725_deleted_at.sql │ │ │ └── 20260224000100_history_author_intent.sql │ │ ├── record-migrations/ │ │ │ ├── 20230531212437_create-records.sql │ │ │ └── 20231127090831_create-store.sql │ │ ├── src/ │ │ │ ├── api_client.rs │ │ │ ├── auth.rs │ │ │ ├── database.rs │ │ │ ├── distro.rs │ │ │ ├── encryption.rs │ │ │ ├── history/ │ │ │ │ ├── builder.rs │ │ │ │ └── store.rs │ │ │ ├── history.rs │ │ │ ├── hub.rs │ │ │ ├── import/ │ │ │ │ ├── bash.rs │ │ │ │ ├── fish.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── nu.rs │ │ │ │ ├── nu_histdb.rs │ │ │ │ ├── powershell.rs │ │ │ │ ├── replxx.rs │ │ │ │ ├── resh.rs │ │ │ │ ├── xonsh.rs │ │ │ │ ├── xonsh_sqlite.rs │ │ │ │ ├── zsh.rs │ │ │ │ └── zsh_histdb.rs │ │ │ ├── lib.rs │ │ │ ├── login.rs │ │ │ ├── logout.rs │ │ │ ├── meta.rs │ │ │ ├── ordering.rs │ │ │ ├── plugin.rs │ │ │ ├── record/ │ │ │ │ ├── encryption.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── sqlite_store.rs │ │ │ │ ├── store.rs │ │ │ │ └── sync.rs │ │ │ ├── register.rs │ │ │ ├── secrets.rs │ │ │ ├── settings/ │ │ │ │ ├── dotfiles.rs │ │ │ │ ├── kv.rs │ │ │ │ ├── meta.rs │ │ │ │ ├── scripts.rs │ │ │ │ └── watcher.rs │ │ │ ├── settings.rs │ │ │ ├── sync.rs │ │ │ ├── theme.rs │ │ │ └── utils.rs │ │ └── tests/ │ │ └── data/ │ │ └── xonsh/ │ │ ├── xonsh-82eafbf5-9f43-489a-80d2-61c7dc6ef542.json │ │ └── xonsh-de16af90-9148-4461-8df3-5b5659c6420d.json │ ├── atuin-common/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── api.rs │ │ ├── calendar.rs │ │ ├── lib.rs │ │ ├── record.rs │ │ ├── shell.rs │ │ ├── tls.rs │ │ └── utils.rs │ ├── atuin-daemon/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── proto/ │ │ │ ├── control.proto │ │ │ ├── history.proto │ │ │ └── search.proto │ │ ├── src/ │ │ │ ├── client.rs │ │ │ ├── components/ │ │ │ │ ├── history.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── search.rs │ │ │ │ └── sync.rs │ │ │ ├── control/ │ │ │ │ ├── mod.rs │ │ │ │ └── service.rs │ │ │ ├── daemon.rs │ │ │ ├── events.rs │ │ │ ├── history/ │ │ │ │ └── mod.rs │ │ │ ├── lib.rs │ │ │ ├── search/ │ │ │ │ ├── index.rs │ │ │ │ └── mod.rs │ │ │ └── server.rs │ │ └── tests/ │ │ └── lifecycle.rs │ ├── atuin-dotfiles/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── lib.rs │ │ ├── shell/ │ │ │ ├── bash.rs │ │ │ ├── fish.rs │ │ │ ├── powershell.rs │ │ │ ├── xonsh.rs │ │ │ └── zsh.rs │ │ ├── shell.rs │ │ ├── store/ │ │ │ ├── alias.rs │ │ │ └── var.rs │ │ └── store.rs │ ├── atuin-hex/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── lib.rs │ │ └── osc133.rs │ ├── atuin-history/ │ │ ├── Cargo.toml │ │ ├── benches/ │ │ │ └── smart_sort.rs │ │ └── src/ │ │ ├── lib.rs │ │ ├── sort.rs │ │ └── stats.rs │ ├── atuin-kv/ │ │ ├── Cargo.toml │ │ ├── migrations/ │ │ │ ├── 20250501160746_create_kv_db.down.sql │ │ │ └── 20250501160746_create_kv_db.up.sql │ │ └── src/ │ │ ├── database.rs │ │ ├── lib.rs │ │ ├── store/ │ │ │ ├── entry.rs │ │ │ └── record.rs │ │ └── store.rs │ ├── atuin-nucleo/ │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── Cargo.toml │ │ ├── LICENSE │ │ ├── README.md │ │ ├── bench/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ └── main.rs │ │ ├── matcher/ │ │ │ ├── Cargo.toml │ │ │ ├── fuzz/ │ │ │ │ ├── .gitignore │ │ │ │ ├── Cargo.toml │ │ │ │ └── fuzz_targets/ │ │ │ │ └── fuzz_target_1.rs │ │ │ ├── fuzz.sh │ │ │ ├── generate_case_fold_table.sh │ │ │ └── src/ │ │ │ ├── chars/ │ │ │ │ ├── case_fold.rs │ │ │ │ └── normalize.rs │ │ │ ├── chars.rs │ │ │ ├── config.rs │ │ │ ├── debug.rs │ │ │ ├── exact.rs │ │ │ ├── fuzzy_greedy.rs │ │ │ ├── fuzzy_optimal.rs │ │ │ ├── lib.rs │ │ │ ├── matrix.rs │ │ │ ├── pattern/ │ │ │ │ └── tests.rs │ │ │ ├── pattern.rs │ │ │ ├── prefilter.rs │ │ │ ├── score.rs │ │ │ ├── tests.rs │ │ │ ├── utf32_str/ │ │ │ │ └── tests.rs │ │ │ └── utf32_str.rs │ │ ├── src/ │ │ │ ├── boxcar.rs │ │ │ ├── lib.rs │ │ │ ├── par_sort.rs │ │ │ ├── pattern/ │ │ │ │ └── tests.rs │ │ │ ├── pattern.rs │ │ │ ├── tests.rs │ │ │ └── worker.rs │ │ ├── tarpaulin.toml │ │ └── typos.toml │ ├── atuin-scripts/ │ │ ├── Cargo.toml │ │ ├── migrations/ │ │ │ ├── 20250326160051_create_scripts.down.sql │ │ │ ├── 20250326160051_create_scripts.up.sql │ │ │ ├── 20250402170430_unique_names.down.sql │ │ │ └── 20250402170430_unique_names.up.sql │ │ └── src/ │ │ ├── database.rs │ │ ├── execution.rs │ │ ├── lib.rs │ │ ├── settings.rs │ │ ├── store/ │ │ │ ├── record.rs │ │ │ └── script.rs │ │ └── store.rs │ ├── atuin-server/ │ │ ├── Cargo.toml │ │ ├── server.toml │ │ └── src/ │ │ ├── bin/ │ │ │ └── main.rs │ │ ├── handlers/ │ │ │ ├── health.rs │ │ │ ├── history.rs │ │ │ ├── mod.rs │ │ │ ├── record.rs │ │ │ ├── status.rs │ │ │ ├── user.rs │ │ │ └── v0/ │ │ │ ├── me.rs │ │ │ ├── mod.rs │ │ │ ├── record.rs │ │ │ └── store.rs │ │ ├── lib.rs │ │ ├── metrics.rs │ │ ├── router.rs │ │ ├── settings.rs │ │ └── utils.rs │ ├── atuin-server-database/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── calendar.rs │ │ ├── lib.rs │ │ └── models.rs │ ├── atuin-server-postgres/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── migrations/ │ │ │ ├── 20210425153745_create_history.sql │ │ │ ├── 20210425153757_create_users.sql │ │ │ ├── 20210425153800_create_sessions.sql │ │ │ ├── 20220419082412_add_count_trigger.sql │ │ │ ├── 20220421073605_fix_count_trigger_delete.sql │ │ │ ├── 20220421174016_larger-commands.sql │ │ │ ├── 20220426172813_user-created-at.sql │ │ │ ├── 20220505082442_create-events.sql │ │ │ ├── 20220610074049_history-length.sql │ │ │ ├── 20230315220537_drop-events.sql │ │ │ ├── 20230315224203_create-deleted.sql │ │ │ ├── 20230515221038_trigger-delete-only.sql │ │ │ ├── 20230623070418_records.sql │ │ │ ├── 20231202170508_create-store.sql │ │ │ ├── 20231203124112_create-store-idx.sql │ │ │ ├── 20240108124837_drop-some-defaults.sql │ │ │ ├── 20240614104159_idx-cache.sql │ │ │ ├── 20240621110731_user-verified.sql │ │ │ ├── 20240702094825_idx_cache_index.sql │ │ │ └── 20260127000000_remove-email-verification.sql │ │ └── src/ │ │ ├── lib.rs │ │ └── wrappers.rs │ └── atuin-server-sqlite/ │ ├── Cargo.toml │ ├── build.rs │ ├── migrations/ │ │ ├── 20231203124112_create-store.sql │ │ ├── 20240108124830_create-history.sql │ │ ├── 20240108124831_create-sessions.sql │ │ ├── 20240621110730_create-users.sql │ │ ├── 20240621110731_create-user-verification-token.sql │ │ ├── 20240702094825_create-store-idx-cache.sql │ │ └── 20260127000000_remove-email-verification.sql │ └── src/ │ ├── lib.rs │ └── wrappers.rs ├── default.nix ├── deny.toml ├── depot.json ├── dist-workspace.toml ├── docker-compose.yml ├── docs/ │ ├── .gitignore │ ├── docs/ │ │ ├── ai/ │ │ │ ├── introduction.md │ │ │ └── settings.md │ │ ├── configuration/ │ │ │ ├── advanced-key-binding.md │ │ │ ├── config.md │ │ │ └── key-binding.md │ │ ├── faq.md │ │ ├── guide/ │ │ │ ├── advanced-usage.md │ │ │ ├── basic-usage.md │ │ │ ├── delete-history.md │ │ │ ├── dotfiles.md │ │ │ ├── getting-started.md │ │ │ ├── import.md │ │ │ ├── installation.md │ │ │ ├── shell-integration.md │ │ │ ├── sync.md │ │ │ └── theming.md │ │ ├── index.md │ │ ├── integrations.md │ │ ├── known-issues.md │ │ ├── reference/ │ │ │ ├── daemon.md │ │ │ ├── doctor.md │ │ │ ├── gen-completions.md │ │ │ ├── import.md │ │ │ ├── info.md │ │ │ ├── list.md │ │ │ ├── prune.md │ │ │ ├── search.md │ │ │ ├── stats.md │ │ │ └── sync.md │ │ ├── self-hosting/ │ │ │ ├── docker.md │ │ │ ├── kubernetes.md │ │ │ ├── server-setup.md │ │ │ ├── systemd.md │ │ │ └── usage.md │ │ ├── sync-v2.md │ │ └── uninstall.md │ ├── mkdocs.yml │ └── pyproject.toml ├── docs-i18n/ │ ├── .gitignore │ ├── ru/ │ │ ├── config_ru.md │ │ ├── import_ru.md │ │ ├── key-binding_ru.md │ │ ├── list_ru.md │ │ ├── search_ru.md │ │ ├── server_ru.md │ │ ├── shell-completions_ru.md │ │ ├── stats_ru.md │ │ └── sync_ru.md │ └── zh-CN/ │ ├── README.md │ ├── config.md │ ├── docker.md │ ├── import.md │ ├── k8s.md │ ├── key-binding.md │ ├── list.md │ ├── search.md │ ├── server.md │ ├── shell-completions.md │ ├── stats.md │ └── sync.md ├── flake.nix ├── install.sh ├── k8s/ │ ├── atuin.yaml │ ├── namespaces.yaml │ └── secrets.yaml ├── rust-toolchain.toml ├── scripts/ │ └── span-table.ts └── systemd/ ├── atuin-server.service └── atuin-server.sysusers ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cargo/audit.toml ================================================ [advisories] ignore = [ # This is a vuln on RSA. RSA is in our lockfile, but not in cargo-tree. # It is a issue with sqlx/cargo, and does not affect Atuin. # See: # - https://github.com/launchbadge/sqlx/issues/3211 # - https://github.com/rust-lang/cargo/issues/10801 "RUSTSEC-2023-0071" ] ================================================ FILE: .codespellrc ================================================ [codespell] # Ref: https://github.com/codespell-project/codespell#using-a-config-file skip = .git*,*.lock,.codespellrc,CODE_OF_CONDUCT.md,CONTRIBUTORS check-hidden = true # ignore-regex = ignore-words-list = crate,ratatui,inbetween,iterm,fo,brunch ================================================ FILE: .dockerignore ================================================ ./target Dockerfile ================================================ FILE: .gitattributes ================================================ *.sh eol=lf *.nix eol=lf *.zsh eol=lf *.sql eol=lf ================================================ FILE: .github/DISCUSSION_TEMPLATE/support.yml ================================================ body: - type: input attributes: label: Operating System description: What operating system are you using? placeholder: "Example: macOS Big Sur" validations: required: true - type: input attributes: label: Shell description: What shell are you using? placeholder: "Example: zsh 5.8.1" validations: required: true - type: dropdown attributes: label: Version description: What version of atuin are you running? multiple: false options: # how often will I forget to update this? a lot. - v17.0.0 (Default) - v16.0.0 - v15.0.0 - v14.0.1 - v14.0.0 - v13.0.1 - v13.0.0 - v12.0.0 - v11.0.0 - v0.10.0 - v0.9.1 - v0.9.0 - v0.8.1 - v0.8.0 - v0.7.2 - v0.7.1 - v0.7.0 - v0.6.4 - v0.6.3 default: 0 validations: required: true - type: checkboxes attributes: label: Self hosted description: Are you self hosting atuin server? options: - label: I am self hosting atuin server - type: checkboxes attributes: label: Search the issues description: Did you search the issues and discussions for your problem? options: - label: I checked that someone hasn't already asked about the same issue required: true - type: textarea attributes: label: Behaviour description: "Please describe the issue - what you expected to happen, what actually happened" - type: textarea attributes: label: Logs description: "If possible, please include logs from atuin, especially if you self host the server - ATUIN_LOG=debug" - type: textarea attributes: label: Extra information description: "Anything else you'd like to add?" - type: checkboxes attributes: label: Code of Conduct description: The Code of Conduct helps create a safe space for everyone. We require that everyone agrees to it. options: - label: I agree to follow this project's [Code of Conduct](https://github.com/atuinsh/atuin/blob/main/CODE_OF_CONDUCT.md) required: true ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [atuinsh] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug.yaml ================================================ name: Bug Report description: File a bug report title: "[Bug]: " labels: ["bug", "triage"] body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: textarea id: what-expected attributes: label: What did you expect to happen? placeholder: Tell us what you expected to see! validations: required: true - type: textarea id: what-happened attributes: label: What happened? placeholder: Tell us what you see! validations: required: true - type: textarea id: doctor validations: required: true attributes: label: Atuin doctor output description: Please run 'atuin doctor' and share the output. If it fails to run, share any errors. This requires Atuin >=v18.1.0 render: yaml - type: checkboxes id: terms attributes: label: Code of Conduct description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/atuinsh/atuin/blob/main/CODE_OF_CONDUCT.md) options: - label: I agree to follow this project's Code of Conduct required: true ================================================ FILE: .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://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "cargo" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" - package-ecosystem: "docker" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/pull_request_template.md ================================================ ## Checks - [ ] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle - [ ] I have checked that there are no existing pull requests for the same thing ================================================ FILE: .github/workflows/codespell.yml ================================================ # Codespell configuration is within .codespellrc --- name: Codespell on: push: branches: [main] pull_request: branches: [main] permissions: contents: read jobs: codespell: name: Check for spelling errors runs-on: depot-ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v6 - name: Codespell uses: codespell-project/actions-codespell@v2 with: # This is regenerated from commit history # we cannot rewrite commit history, and I'd rather not correct it # every time exclude_file: CHANGELOG.md ================================================ FILE: .github/workflows/docker.yaml ================================================ name: build-docker on: push: branches: [main] jobs: publish: concurrency: group: ${{ github.ref }}-docker cancel-in-progress: true permissions: packages: write contents: read id-token: write runs-on: depot-ubuntu-24.04 steps: - uses: actions/checkout@v6 - name: Get Repo Owner id: get_repo_owner run: echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" > $GITHUB_ENV - uses: depot/setup-action@v1 - name: Login to container Registry uses: docker/login-action@v3 with: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} registry: ghcr.io - name: Get short sha id: shortsha run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - name: Build and push uses: depot/build-push-action@v1 with: push: true platforms: linux/amd64,linux/arm64 file: ./Dockerfile context: . provenance: false build-args: | Version=dev GitCommit=${{ steps.shortsha.outputs.short_sha }} tags: | ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }} ================================================ FILE: .github/workflows/installer.yml ================================================ name: Install on: push: branches: [main] pull_request: paths: .github/workflows/installer.yml env: CARGO_TERM_COLOR: always jobs: install: strategy: matrix: os: [depot-ubuntu-24.04, macos-14] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - name: Install zsh for ubuntu if: matrix.os == 'depot-ubuntu-24.04' run: | sudo apt install zsh - name: Test install script on bash run: | /bin/bash -c "$(curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh)" [ -d "$HOME/.atuin" ] && source $HOME/.atuin/bin/env atuin --help - name: Test install script on zsh shell: zsh {0} run: | /bin/bash -c "$(curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh)" [ -d "$HOME/.atuin" ] && source $HOME/.atuin/bin/env atuin --help ================================================ FILE: .github/workflows/nix.yml ================================================ # Verify the Nix build is working # Failures will usually occur due to an out of date Rust version # That can be updated to the latest version in nixpkgs-unstable with `nix flake update` name: Nix on: push: branches: [ main ] paths-ignore: - 'ui/**' pull_request: branches: [ main ] paths-ignore: - 'ui/**' jobs: check: runs-on: depot-ubuntu-24.04 steps: - uses: actions/checkout@v6 - uses: cachix/install-nix-action@v31 - name: Run nix flake check run: nix flake check --print-build-logs build-test: runs-on: depot-ubuntu-24.04 steps: - uses: actions/checkout@v6 - uses: cachix/install-nix-action@v31 - name: Run nix build run: nix build --print-build-logs ================================================ FILE: .github/workflows/release.yml ================================================ # This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist # # Copyright 2022-2024, axodotdev # SPDX-License-Identifier: MIT or Apache-2.0 # # CI that: # # * checks for a Git Tag that looks like a release # * builds artifacts with dist (archives, installers, hashes) # * uploads those artifacts to temporary workflow zip # * on success, uploads the artifacts to a GitHub Release # # Note that the GitHub Release will be created with a generated # title/body based on your changelogs. name: Release permissions: "contents": "write" # This task will run whenever you push a git tag that looks like a version # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION # must be a Cargo-style SemVer Version (must have at least major.minor.patch). # # If PACKAGE_NAME is specified, then the announcement will be for that # package (erroring out if it doesn't have the given version or isn't dist-able). # # If PACKAGE_NAME isn't specified, then the announcement will be for all # (dist-able) packages in the workspace with that version (this mode is # intended for workspaces with only one dist-able package, or with all dist-able # packages versioned/released in lockstep). # # If you push multiple tags at once, separate instances of this workflow will # spin up, creating an independent announcement for each one. However, GitHub # will hard limit this to 3 tags per commit, as it will assume more tags is a # mistake. # # If there's a prerelease-style suffix to the version, then the release(s) # will be marked as a prerelease. on: pull_request: push: tags: - '**[0-9]+.[0-9]+.[0-9]+*' jobs: # Run 'dist plan' (or host) to determine what tasks we need to do plan: runs-on: "ubuntu-22.04" outputs: val: ${{ steps.plan.outputs.manifest }} tag: ${{ !github.event.pull_request && github.ref_name || '' }} tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} publishing: ${{ !github.event.pull_request }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v6 with: persist-credentials: false submodules: recursive - name: Install dist # we specify bash to get pipefail; it guards against the `curl` command # failing. otherwise `sh` won't catch that `curl` returned non-0 shell: bash run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" - name: Cache dist uses: actions/upload-artifact@v6 with: name: cargo-dist-cache path: ~/.cargo/bin/dist # sure would be cool if github gave us proper conditionals... # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible # functionality based on whether this is a pull_request, and whether it's from a fork. # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* # but also really annoying to build CI around when it needs secrets to work right.) - id: plan run: | dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json echo "dist ran successfully" cat plan-dist-manifest.json echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" uses: actions/upload-artifact@v6 with: name: artifacts-plan-dist-manifest path: plan-dist-manifest.json # Build and packages all the platform-specific things build-local-artifacts: name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) # Let the initial task tell us to not run (currently very blunt) needs: - plan if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} strategy: fail-fast: false # Target platforms/runners are computed by dist in create-release. # Each member of the matrix has the following arguments: # # - runner: the github runner # - dist-args: cli flags to pass to dist # - install-dist: expression to run to install dist on the runner # # Typically there will be: # - 1 "global" task that builds universal installers # - N "local" tasks that build each platform's binaries and platform-specific installers matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} runs-on: ${{ matrix.runner }} container: ${{ matrix.container && matrix.container.image || null }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json permissions: "attestations": "write" "contents": "read" "id-token": "write" steps: - name: enable windows longpaths run: | git config --global core.longpaths true - uses: actions/checkout@v6 with: persist-credentials: false submodules: recursive - name: Install Rust non-interactively if not already installed if: ${{ matrix.container }} run: | if ! command -v cargo > /dev/null 2>&1; then curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y echo "$HOME/.cargo/bin" >> $GITHUB_PATH fi - name: Install dist run: ${{ matrix.install_dist.run }} # Get the dist-manifest - name: Fetch local artifacts uses: actions/download-artifact@v7 with: pattern: artifacts-* path: target/distrib/ merge-multiple: true - name: Install dependencies run: | ${{ matrix.packages_install }} - name: Build artifacts run: | # Actually do builds and make zips and whatnot dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json echo "dist ran successfully" - name: Attest uses: actions/attest-build-provenance@v3 with: subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*" - id: cargo-dist name: Post-build # We force bash here just because github makes it really hard to get values up # to "real" actions without writing to env-vars, and writing to env-vars has # inconsistent syntax between shell and powershell. shell: bash run: | # Parse out what we just built and upload it to scratch storage echo "paths<> "$GITHUB_OUTPUT" dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" uses: actions/upload-artifact@v6 with: name: artifacts-build-local-${{ join(matrix.targets, '_') }} path: | ${{ steps.cargo-dist.outputs.paths }} ${{ env.BUILD_MANIFEST_NAME }} # Build and package all the platform-agnostic(ish) things build-global-artifacts: needs: - plan - build-local-artifacts runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json steps: - uses: actions/checkout@v6 with: persist-credentials: false submodules: recursive - name: Install cached dist uses: actions/download-artifact@v7 with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Get all the local artifacts for the global tasks to use (for e.g. checksums) - name: Fetch local artifacts uses: actions/download-artifact@v7 with: pattern: artifacts-* path: target/distrib/ merge-multiple: true - id: cargo-dist shell: bash run: | dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json echo "dist ran successfully" # Parse out what we just built and upload it to scratch storage echo "paths<> "$GITHUB_OUTPUT" jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" uses: actions/upload-artifact@v6 with: name: artifacts-build-global path: | ${{ steps.cargo-dist.outputs.paths }} ${{ env.BUILD_MANIFEST_NAME }} # Determines if we should publish/announce host: needs: - plan - build-local-artifacts - build-global-artifacts # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} runs-on: "ubuntu-22.04" outputs: val: ${{ steps.host.outputs.manifest }} steps: - uses: actions/checkout@v6 with: persist-credentials: false submodules: recursive - name: Install cached dist uses: actions/download-artifact@v7 with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Fetch artifacts from scratch-storage - name: Fetch artifacts uses: actions/download-artifact@v7 with: pattern: artifacts-* path: target/distrib/ merge-multiple: true - id: host shell: bash run: | dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json echo "artifacts uploaded and released successfully" cat dist-manifest.json echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" uses: actions/upload-artifact@v6 with: # Overwrite the previous copy name: artifacts-dist-manifest path: dist-manifest.json # Create a GitHub Release while uploading all files to it - name: "Download GitHub Artifacts" uses: actions/download-artifact@v7 with: pattern: artifacts-* path: artifacts merge-multiple: true - name: Cleanup run: | # Remove the granular manifests rm -f artifacts/*-dist-manifest.json - name: Create GitHub Release env: PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" RELEASE_COMMIT: "${{ github.sha }}" run: | # Write and read notes from a file to avoid quoting breaking things echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* announce: needs: - plan - host # use "always() && ..." to allow us to wait for all publish jobs while # still allowing individual publish jobs to skip themselves (for prereleases). # "host" however must run to completion, no skipping allowed! if: ${{ always() && needs.host.result == 'success' }} runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v6 with: persist-credentials: false submodules: recursive ================================================ FILE: .github/workflows/rust.yml ================================================ name: Rust on: push: branches: [main] paths-ignore: - "ui/**" pull_request: branches: [main] paths-ignore: - "ui/**" env: CARGO_TERM_COLOR: always jobs: build: strategy: matrix: os: [depot-ubuntu-24.04, macos-14, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - name: Install rust uses: dtolnay/rust-toolchain@master with: toolchain: 1.94.0 - uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git target key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} - name: Run cargo build common run: cargo build -p atuin-common --locked --release - name: Run cargo build client run: cargo build -p atuin-client --locked --release - name: Run cargo build server run: cargo build -p atuin-server --locked --release - name: Run cargo build main run: cargo build --all --locked --release cross-compile: strategy: matrix: # There was an attempt to make cross-compiles also work on FreeBSD, but that failed with: # # warning: libelf.so.2, needed by <...>/libkvm.so, not found (try using -rpath or -rpath-link) target: [x86_64-unknown-illumos] runs-on: depot-ubuntu-24.04 steps: - uses: actions/checkout@v6 - name: Install cross uses: taiki-e/install-action@v2 with: tool: cross - uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git target key: ${{ matrix.target }}-cross-compile-${{ hashFiles('**/Cargo.lock') }} - name: Run cross build common run: cross build -p atuin-common --locked --target ${{ matrix.target }} - name: Run cross build client run: cross build -p atuin-client --locked --target ${{ matrix.target }} - name: Run cross build server run: cross build -p atuin-server --locked --target ${{ matrix.target }} - name: Run cross build main run: | cross build --all --locked --target ${{ matrix.target }} unit-test: strategy: matrix: os: [depot-ubuntu-24.04, macos-14, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - name: Install rust uses: dtolnay/rust-toolchain@master with: toolchain: 1.94.0 - uses: taiki-e/install-action@v2 name: Install nextest with: tool: cargo-nextest - uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git target key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }} - name: Run cargo test run: cargo nextest run --lib --bins check: strategy: matrix: os: [depot-ubuntu-24.04, macos-14, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - name: Install rust uses: dtolnay/rust-toolchain@master with: toolchain: 1.94.0 - uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git target key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }} - name: Run cargo check (all features) run: cargo check --all-features --workspace - name: Run cargo check (no features) run: cargo check --no-default-features --workspace - name: Run cargo check (sync) run: cargo check --no-default-features --features sync --workspace - name: Run cargo check (server) run: cargo check -p atuin-server - name: Run cargo check (client only) run: cargo check --no-default-features --features client --workspace integration-test: runs-on: depot-ubuntu-24.04 services: postgres: image: postgres env: POSTGRES_USER: atuin POSTGRES_PASSWORD: pass POSTGRES_DB: atuin ports: - 5432:5432 steps: - uses: actions/checkout@v6 - name: Install rust uses: dtolnay/rust-toolchain@master with: toolchain: 1.94.0 - uses: taiki-e/install-action@v2 name: Install nextest with: tool: cargo-nextest - uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git target key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }} - name: Run cargo test run: cargo nextest run --test '*' env: ATUIN_DB_URI: postgres://atuin:pass@localhost:5432/atuin clippy: runs-on: depot-ubuntu-24.04 steps: - uses: actions/checkout@v6 - name: Install latest rust uses: dtolnay/rust-toolchain@master with: toolchain: 1.94.0 components: clippy - uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git target key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }} - name: Run clippy run: cargo clippy -- -D warnings -D clippy::redundant_clone format: runs-on: depot-ubuntu-24.04 steps: - uses: actions/checkout@v6 - name: Install latest rust uses: dtolnay/rust-toolchain@master with: toolchain: 1.94.0 components: rustfmt - name: Format run: cargo fmt -- --check ================================================ FILE: .github/workflows/shellcheck.yml ================================================ name: Shellcheck on: push: branches: [ main ] pull_request: branches: [ main ] jobs: shellcheck: runs-on: depot-ubuntu-24.04 steps: - uses: actions/checkout@v6 - name: Run shellcheck uses: ludeeus/action-shellcheck@master env: SHELLCHECK_OPTS: "-e SC2148" ================================================ FILE: .github/workflows/update-nix-deps.yml ================================================ name: Update Nix Deps on: workflow_dispatch: # allows manual triggering schedule: - cron: '0 0 1 * *' # runs monthly on the first day of the month at 00:00 jobs: lockfile: runs-on: depot-ubuntu-24.04 if: github.repository == 'atuinsh/atuin' steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Nix uses: DeterminateSystems/nix-installer-action@main - name: Update flake.lock uses: DeterminateSystems/update-flake-lock@main with: pr-title: "chore(deps): update flake.lock" pr-labels: | dependencies ================================================ FILE: .gitignore ================================================ .DS_Store /target */target .env .idea/ .vscode/ result publish.sh .envrc .planning/ ui/backend/target ui/backend/gen sqlite-server.db* ================================================ FILE: .mailmap ================================================ networkException Violet Shreve Chris Rose Conrad Ludgate Cristian Le Dennis Trautwein Ellie Huxtable Ellie Huxtable Frank Hamand Jakob Schrettenbrunner Nemo157 Richard de Boer Sandro TymanWasTaken ================================================ FILE: .rustfmt.toml ================================================ reorder_imports = true # uncomment once stable #imports_granularity = "crate" #group_imports = "StdExternalCrate" ================================================ FILE: AGENTS.md ================================================ # Atuin Shell history tool. Replaces your shell's built-in history with a SQLite database, adds context (cwd, exit code, duration, hostname), and optionally syncs across machines with end-to-end encryption. ## Workspace crates ``` atuin CLI binary + TUI (clap, ratatui, crossterm) atuin-client Client library: local DB, encryption, sync, settings atuin-common Shared types, API models, utils atuin-daemon Background gRPC daemon (tonic) for shell hooks atuin-dotfiles Alias/var sync via record store atuin-history Sorting algorithms, stats atuin-kv Key-value store (synced) atuin-scripts Script management (minijinja) atuin-server HTTP sync server (axum) - lib + standalone binary atuin-server-database Database trait for server atuin-server-postgres Postgres implementation (sqlx) atuin-server-sqlite SQLite implementation (sqlx) ``` ## Two sync protocols - **V1 (legacy)**: Syncs history entries directly. Being phased out. Toggleable via `sync_v1_enabled`. - **V2 (current)**: Record store abstraction. All data types (history, KV, aliases, vars, scripts) share the same sync infrastructure using tagged records. Envelope-encrypted with PASETO V4 and per-record CEKs. ## Encryption - **V1**: XSalsa20Poly1305 (secretbox). Key at `~/.local/share/atuin/key`. - **V2**: PASETO V4 Local (XChaCha20-Poly1305 + Blake2b). Envelope encryption: each record gets a random CEK wrapped with the master key. Record metadata (id, idx, version, tag, host) is authenticated as implicit assertions. ## Databases - **Client**: SQLite everywhere. Separate DBs for history, record store, KV, scripts. All use sqlx + WAL mode. - **Server**: Postgres (primary) or SQLite. Auto-detected from URI prefix. - Migrations live alongside each crate. Never modify existing migrations, only add new ones. ## Hot paths `history start`, `history end`, and `init` skip database initialization for latency. Don't add DB calls to these without good reason. ## Conventions - Rust 2024 edition, toolchain 1.93.1. - Errors: `eyre::Result` in binaries, `thiserror` for typed errors in libraries. - Async: tokio. Client uses `current_thread`; server uses `multi_thread`. - `#![deny(unsafe_code)]` on client/common, `#![forbid(unsafe_code)]` on server. - Clippy: `pedantic` + `nursery` on main crate. CI enforces `-D warnings -D clippy::redundant_clone`. - Format: `cargo fmt`. Only non-default: `reorder_imports = true`. - IDs: UUIDv7 (time-ordered), newtype wrappers (`HistoryId`, `RecordId`, `HostId`). - Serialization: MessagePack for encrypted payloads, JSON for API, TOML for config. - Storage traits: `Database` (client), `Store` (record store), `Database` (server) -- all `async_trait`. - History builders: `HistoryImported`, `HistoryCaptured`, `HistoryFromDb` with compile-time field validation. - Feature flags: `client`, `sync`, `daemon`, `clipboard`, `check-update`. ## Testing - Unit tests inline with `#[cfg(test)]`, async via `#[tokio::test]`. - Integration tests in `crates/atuin/tests/` need Postgres (`ATUIN_DB_URI` env var). - Use `":memory:"` SQLite for unit tests needing a database. - Runner: `cargo nextest`. - Benchmarks: `divan` in `atuin-history`. ## Build and check ```sh cargo build cargo test cargo clippy -- -D warnings cargo fmt --check ``` ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. ## [unreleased] ### Bug Fixes - Nushell 0.111; future Nushell 0.112 support ([#3266](https://github.com/atuinsh/atuin/issues/3266)) ### Features - Call atuin setup from install script ([#3265](https://github.com/atuinsh/atuin/issues/3265)) - Allow headless account ops against Hub server ([#3280](https://github.com/atuinsh/atuin/issues/3280)) - Add custom filtering and scoring mechanisms ### Miscellaneous Tasks - *(ci)* Migrate to depot runners ([#3279](https://github.com/atuinsh/atuin/issues/3279)) - *(ci)* Use depot to build docker images too ([#3281](https://github.com/atuinsh/atuin/issues/3281)) - Update changelog - Update permissions in Docker workflow ([#3283](https://github.com/atuinsh/atuin/issues/3283)) - Change CHANGELOG format to be easier to parse - Symlink changelog so dist can pick it up - Vendor nucleo-ext + fork, so we can depend on our changes properly ([#3284](https://github.com/atuinsh/atuin/issues/3284)) ## 18.13.2 ### Miscellaneous Tasks - *(release)* Building windows aarch64 was overly optimistic - Update changelog ## 18.13.1 ### Miscellaneous Tasks - *(release)* Update dist, remove custom runners - Update changelog ## 18.13.0 ### Bug Fixes - *(deps)* Add use-dev-tty to crossterm in atuin-ai ([#3185](https://github.com/atuinsh/atuin/issues/3185)) - *(docs)* Update Postgres volume path in Docker as required by pg18 ([#3174](https://github.com/atuinsh/atuin/issues/3174)) - Systemd Exec for separate server binary ([#3176](https://github.com/atuinsh/atuin/issues/3176)) - Multiline commands with fish ([#3179](https://github.com/atuinsh/atuin/issues/3179)) - Silent DB failures e.g. when disk is full ([#3183](https://github.com/atuinsh/atuin/issues/3183)) - Forward $PATH to tmux popup in zsh ([#3198](https://github.com/atuinsh/atuin/issues/3198)) - Dramatically decrease daemon memory usage ([#3211](https://github.com/atuinsh/atuin/issues/3211)) - Regen cargo dist - Clear script database before rebuild to prevent unique constraint violation ([#3232](https://github.com/atuinsh/atuin/issues/3232)) - Support Nushell 0.111 ([#3249](https://github.com/atuinsh/atuin/issues/3249)) - Ctrl-c not exiting ai ([#3256](https://github.com/atuinsh/atuin/issues/3256)) ### Documentation - Update config.md to remove NuShell support note ([#3190](https://github.com/atuinsh/atuin/issues/3190)) - Document `search.filters` ([#3195](https://github.com/atuinsh/atuin/issues/3195)) - Clean up doc references for sqlite-based self-hosting ([#3216](https://github.com/atuinsh/atuin/issues/3216)) - Document daemon-fuzzy search mode ([#3254](https://github.com/atuinsh/atuin/issues/3254)) ### Features - *(docs)* Add Shell Integration and Interoperability docs ([#3163](https://github.com/atuinsh/atuin/issues/3163)) - `switch-context` ([#3149](https://github.com/atuinsh/atuin/issues/3149)) - Add Hub authentication for future sync + extra features ([#3010](https://github.com/atuinsh/atuin/issues/3010)) - Add Atuin AI inline CLI MVP ([#3178](https://github.com/atuinsh/atuin/issues/3178)) - Add autostart and pid management to daemon ([#3180](https://github.com/atuinsh/atuin/issues/3180)) - Generate commands or ask questions with `atuin ai` ([#3199](https://github.com/atuinsh/atuin/issues/3199)) - Add history author/intent metadata and v1 record version ([#3205](https://github.com/atuinsh/atuin/issues/3205)) - In-memory search index with atuin daemon ([#3201](https://github.com/atuinsh/atuin/issues/3201)) - Update script for smoother setup ([#3230](https://github.com/atuinsh/atuin/issues/3230)) - Initial draft of atuin-shell ([#3206](https://github.com/atuinsh/atuin/issues/3206)) - Allow setting multipliers for frequency, recency, and frecency scores ([#3235](https://github.com/atuinsh/atuin/issues/3235)) - Allow running `atuin search -i` as subcommand ([#3208](https://github.com/atuinsh/atuin/issues/3208)) - Use pty proxy for rendering tui popups without clearing the terminal ([#3234](https://github.com/atuinsh/atuin/issues/3234)) - Allow authenticating with Atuin Hub ([#3237](https://github.com/atuinsh/atuin/issues/3237)) - Initialize Atuin AI by default with `atuin init` ([#3255](https://github.com/atuinsh/atuin/issues/3255)) - Add `atuin setup` ([#3257](https://github.com/atuinsh/atuin/issues/3257)) ### Miscellaneous Tasks - Update changelog - Update changelog - Update changelog - Use workspace versions ([#3210](https://github.com/atuinsh/atuin/issues/3210)) - Move atuin ai subcommand into core binary ([#3212](https://github.com/atuinsh/atuin/issues/3212)) - Update changelog - Update to Rust 1.94 ([#3247](https://github.com/atuinsh/atuin/issues/3247)) - Strip symbols in dist profile to reduce binary size - Upgrade thiserror 1.x to 2.x to deduplicate dependency - Upgrade axum 0.7 to 0.8 to deduplicate with tonic's axum - Update changelog - Update changelog - Update changelog - Update changelog ## 18.12.1 ### Bug Fixes - *(shell)* Fix ATUIN_SESSION errors in tmux popup ([#3170](https://github.com/atuinsh/atuin/issues/3170)) - *(tui)* Enter in vim normal mode, shift-tab keybind ([#3158](https://github.com/atuinsh/atuin/issues/3158)) - Server start commands for Docker. ([#3160](https://github.com/atuinsh/atuin/issues/3160)) ### Features - Expand keybinding system with vim motions, media keys, and inspector improvements ([#3161](https://github.com/atuinsh/atuin/issues/3161)) - Add original-input-empty keybind condition ([#3171](https://github.com/atuinsh/atuin/issues/3171)) ### Miscellaneous Tasks - Update changelog ## 18.12.0 ### Bug Fixes - *(powershell)* Preserve `$LASTEXITCODE` ([#3120](https://github.com/atuinsh/atuin/issues/3120)) - *(powershell)* Display search stderr ([#3146](https://github.com/atuinsh/atuin/issues/3146)) - *(search)* Allow hyphen-prefixed query args like `---` ([#3129](https://github.com/atuinsh/atuin/issues/3129)) - *(tui)* Space and F1-F24 keys not handled properly by new keybind system ([#3138](https://github.com/atuinsh/atuin/issues/3138)) - *(ui)* Don't draw a leading space for command - *(ui)* Time column can take up to 9 cells - *(ui)* Align cursor with the expand column (usually the command) - *(ui)* Align cursor when expand column is in the middle ([#3103](https://github.com/atuinsh/atuin/issues/3103)) - Zsh import multiline issue ([#2799](https://github.com/atuinsh/atuin/issues/2799)) - Do not hit sync v1 endpoints for status - Do not hit sync v1 endpoints for status ([#3102](https://github.com/atuinsh/atuin/issues/3102)) - Do not set ATUIN_SESSION if it is already set ([#3107](https://github.com/atuinsh/atuin/issues/3107)) - Custom data dir test on windows ([#3109](https://github.com/atuinsh/atuin/issues/3109)) - New session on shlvl change ([#3111](https://github.com/atuinsh/atuin/issues/3111)) - Larger exit column width on Windows ([#3119](https://github.com/atuinsh/atuin/issues/3119)) - Halt sync loop if server returns an empty page ([#3122](https://github.com/atuinsh/atuin/issues/3122)) - Use directories crate for home dir resolution ([#3125](https://github.com/atuinsh/atuin/issues/3125)) - Tab behaving like enter, eprintln ([#3135](https://github.com/atuinsh/atuin/issues/3135)) - Issue with shift and modifier keys ([#3143](https://github.com/atuinsh/atuin/issues/3143)) - Remove invalid IF EXISTS from sqlite drop column migration ([#3145](https://github.com/atuinsh/atuin/issues/3145)) ### Documentation - *(CONTRIBUTING)* Update links ([#3117](https://github.com/atuinsh/atuin/issues/3117)) - *(README)* Update links ([#3116](https://github.com/atuinsh/atuin/issues/3116)) - *(config)* Clarify scope of directory filter_mode ([#3082](https://github.com/atuinsh/atuin/issues/3082)) - *(configuration)* Describe new utility "atuin-bind" for Bash ([#3064](https://github.com/atuinsh/atuin/issues/3064)) - *(installation)* Add mise alternative installation method ([#3066](https://github.com/atuinsh/atuin/issues/3066)) - Various improvements to the `atuin import` docs ([#3062](https://github.com/atuinsh/atuin/issues/3062)) - Disambiguate 'setup' (noun) vs. 'set up' (verb) ([#3061](https://github.com/atuinsh/atuin/issues/3061)) - Fix punctuation and grammar in basic usage guide ([#3063](https://github.com/atuinsh/atuin/issues/3063)) - Expand and clarify usage of the history prune command ([#3084](https://github.com/atuinsh/atuin/issues/3084)) - Small edit to themes website file ([#3069](https://github.com/atuinsh/atuin/issues/3069)) - Config/ with initial uid:gid - Add PowerShell install instructions - Add PowerShell and Windows install instructions ([#3096](https://github.com/atuinsh/atuin/issues/3096)) - Update the `[keys]` docs ([#3114](https://github.com/atuinsh/atuin/issues/3114)) - Add history deletion guide ([#3130](https://github.com/atuinsh/atuin/issues/3130)) - Update how to use Docker to self-host ([#3148](https://github.com/atuinsh/atuin/issues/3148)) - Add IRC contact information to README ### Features - *(dotfiles)* Add sort and filter options to alias/var list ([#3131](https://github.com/atuinsh/atuin/issues/3131)) - *(theme)* Note new default theme name and syntax ([#3080](https://github.com/atuinsh/atuin/issues/3080)) - *(tui)* Add clear-to-start/end actions ([#3141](https://github.com/atuinsh/atuin/issues/3141)) - *(ui)* Highlight fulltext search as fulltext search instead of fuzzy search - *(ui)* Highlight fulltext search as fulltext search instead of fuzzy search ([#3098](https://github.com/atuinsh/atuin/issues/3098)) - *(ultracompact)* Adds setting for ultracompact mode ([#3079](https://github.com/atuinsh/atuin/issues/3079)) - Add custom column support ([#3089](https://github.com/atuinsh/atuin/issues/3089)) - Left arrow/backspace on empty to start edit ([#3090](https://github.com/atuinsh/atuin/issues/3090)) - Add more vim movement bindings for navigation ([#3041](https://github.com/atuinsh/atuin/issues/3041)) - Support setting a custom data dir in config ([#3105](https://github.com/atuinsh/atuin/issues/3105)) - Remove user verification functionality ([#3108](https://github.com/atuinsh/atuin/issues/3108)) - Add option to use tmux display-popup ([#3058](https://github.com/atuinsh/atuin/issues/3058)) - Move atuin-server to its own binary ([#3112](https://github.com/atuinsh/atuin/issues/3112)) - Add a parameter to the sync to specify the download/upload page ([#2408](https://github.com/atuinsh/atuin/issues/2408)) - Replace several files with a sqlite db ([#3128](https://github.com/atuinsh/atuin/issues/3128)) - Add new custom keybinding system for search TUI ([#3127](https://github.com/atuinsh/atuin/issues/3127)) ### Miscellaneous Tasks - Remove total_history from api index response ([#3094](https://github.com/atuinsh/atuin/issues/3094)) - **BREAKING**: remove total_history from api index response ([#3094](https://github.com/atuinsh/atuin/issues/3094)) - Update to rust 1.93 - Update to rust 1.93 ([#3101](https://github.com/atuinsh/atuin/issues/3101)) - Update changelog - Update agents.md ([#3126](https://github.com/atuinsh/atuin/issues/3126)) - Update changelog - Update changelog - Update changelog ### Theming - Explain how to set ANSI codes directly ([#3065](https://github.com/atuinsh/atuin/issues/3065)) ### Faq - Add alternative projects ([#3076](https://github.com/atuinsh/atuin/issues/3076)) ## 18.11.0 ### Bug Fixes - *(bash)* Fix issues with intermediate key sequences in the vi editing mode ([#2977](https://github.com/atuinsh/atuin/issues/2977)) - *(bash)* Work around a keybinding bug of Bash 5.1 ([#2975](https://github.com/atuinsh/atuin/issues/2975)) - *(bash/blesh)* Suppress error message for auto-complete source ([#2976](https://github.com/atuinsh/atuin/issues/2976)) - *(powershell)* Run `atuin history end` in the background ([#3034](https://github.com/atuinsh/atuin/issues/3034)) - *(powershell)* Add error safety and cleanup ([#3040](https://github.com/atuinsh/atuin/issues/3040)) - Highlight the correct place when multibyte characters are involved ([#2965](https://github.com/atuinsh/atuin/issues/2965)) - Prevent interactive search crash when update check fails ([#3016](https://github.com/atuinsh/atuin/issues/3016)) - Move thorough search through search.filters w/ workspaces ([#2703](https://github.com/atuinsh/atuin/issues/2703)) ### Documentation - Migrate docs from separate repo to `docs` subfolder ([#3018](https://github.com/atuinsh/atuin/issues/3018)) ### Features - Support additional history filenames in replxx importer ([#3005](https://github.com/atuinsh/atuin/issues/3005)) - Add colors to --help/-h ([#3000](https://github.com/atuinsh/atuin/issues/3000)) - Add support for read replicas to postgres ([#3029](https://github.com/atuinsh/atuin/issues/3029)) - Allow disabling sync v1 ([#3030](https://github.com/atuinsh/atuin/issues/3030)) - Consider atuin dotfile aliases when calculating atuin wrapped ([#3048](https://github.com/atuinsh/atuin/issues/3048)) - Add session and uuid column support to history list ([#3049](https://github.com/atuinsh/atuin/issues/3049)) ### Miscellaneous Tasks - *(nix)* Prevent deprecation warning on evaluation ([#3006](https://github.com/atuinsh/atuin/issues/3006)) - Update changelog - Adjust update wording ([#2974](https://github.com/atuinsh/atuin/issues/2974)) - Add Windows builds, second try ([#2966](https://github.com/atuinsh/atuin/issues/2966)) - Update to rust 1.91 ([#2981](https://github.com/atuinsh/atuin/issues/2981)) - Add Atuin Desktop information to install script - Remove trailing whitespace ([#2985](https://github.com/atuinsh/atuin/issues/2985)) - Fix typo ([#2994](https://github.com/atuinsh/atuin/issues/2994)) - Clarify docstring of the enter_accept config key ([#3003](https://github.com/atuinsh/atuin/issues/3003)) - Fix github action syntax for variables ([#2998](https://github.com/atuinsh/atuin/issues/2998)) - Add AGENTS.md - Update changelog - Remove x86_64 mac from build targets ([#3052](https://github.com/atuinsh/atuin/issues/3052)) ### Build - *(nix)* Update rust toolchain hash ([#2990](https://github.com/atuinsh/atuin/issues/2990)) ## 18.10.0 ### Bug Fixes - Stats ngram window size cli parsing ([#2946](https://github.com/atuinsh/atuin/issues/2946)) ### Features - *(bash)* Use Readline's accept-line for enter_accept ([#2953](https://github.com/atuinsh/atuin/issues/2953)) - Add commit to displayed version info ([#2922](https://github.com/atuinsh/atuin/issues/2922)) - Add import from PowerShell history ([#2864](https://github.com/atuinsh/atuin/issues/2864)) - Interactive Inspector ([#2319](https://github.com/atuinsh/atuin/issues/2319)) - Nu ≥ 0.106.0 support commandline accept ([#2957](https://github.com/atuinsh/atuin/issues/2957)) ### Miscellaneous Tasks - Update rusty_paseto and rusty_paserk ([#2942](https://github.com/atuinsh/atuin/issues/2942)) - Update changelog ## 18.9.0 ### Bug Fixes - *(dotfiles)* Properly escape spaces/quotes in vars - Clippy issues on Windows ([#2856](https://github.com/atuinsh/atuin/issues/2856)) - Honor timezone in inspector stats ([#2853](https://github.com/atuinsh/atuin/issues/2853)) - Make status exit 1 if not logged in ([#2843](https://github.com/atuinsh/atuin/issues/2843)) - Match logic of theme directory with settings directory, so ATUIN_CONFIG_DIR is respected ([#2707](https://github.com/atuinsh/atuin/issues/2707)) - Expand path for daemon.socket_path ([#2870](https://github.com/atuinsh/atuin/issues/2870)) - Use fullscreen if `inline_height` is too large ([#2888](https://github.com/atuinsh/atuin/issues/2888)) - Clean up new rustc and clippy warnings on Rust 1.89 - `cargo update` and changes needed to accomodate it - Run `cargo fmt` - Clippy warnings I don't have on my version of clippy - Add forgotten `rust-toolchain.toml` to match changes (oops) - Update version in Cargo.toml + github workflows - Clippy warnings - Dissociate command_chaining from enter_accept - Remove __atuin_chain_command__ prefix - Docker compose link ([#2914](https://github.com/atuinsh/atuin/issues/2914)) - Fish up binding ([#2902](https://github.com/atuinsh/atuin/issues/2902)) ### Features - *(stats)* Add dotnet to default common subcommands - *(tui)* Select entries using number in vim-normal mode. closes #2368 ([#2893](https://github.com/atuinsh/atuin/issues/2893)) - *(tui)* Add show_numeric_shortcuts config to hide 1-9 shortcuts ([#2766](https://github.com/atuinsh/atuin/issues/2766)) - Highlight matches in interactive search ([#2653](https://github.com/atuinsh/atuin/issues/2653)) - Add session-preload filter mode to include global history from before session start - Add various acceptance keys ([#2928](https://github.com/atuinsh/atuin/issues/2928)) - More accurately filter secret tokens ([#2932](https://github.com/atuinsh/atuin/issues/2932)) - Add shell pipelines to command chaining ([#2938](https://github.com/atuinsh/atuin/issues/2938)) ### Miscellaneous Tasks - Update changelog - Remove legacy Apple SDK frameworks ([#2885](https://github.com/atuinsh/atuin/issues/2885)) - Update dist workflows - Update to Rust 1.90 ([#2916](https://github.com/atuinsh/atuin/issues/2916)) ### Refactor - Shell environment variables ### Build - Update flake.nix with new sha256 ## 18.8.0 ### Bug Fixes - *(build)* Enable sqlite feature for sqlite server ([#2848](https://github.com/atuinsh/atuin/issues/2848)) - Make login exit 1 if already logged in ([#2832](https://github.com/atuinsh/atuin/issues/2832)) - Use transaction for idx consistency checking ([#2840](https://github.com/atuinsh/atuin/issues/2840)) - Ensure the idx cache is cleaned on deletion, only insert if records are inserted ([#2841](https://github.com/atuinsh/atuin/issues/2841)) ### Features - Command chaining ([#2834](https://github.com/atuinsh/atuin/issues/2834)) - Add info for 'official' plugins ([#2835](https://github.com/atuinsh/atuin/issues/2835)) - Support multi part commands ([#2836](https://github.com/atuinsh/atuin/issues/2836)) ([#2837](https://github.com/atuinsh/atuin/issues/2837)) - Add inline_height_shell_up_key_binding option ([#2817](https://github.com/atuinsh/atuin/issues/2817)) - Add IDX_CACHE_ROLLOUT ([#2850](https://github.com/atuinsh/atuin/issues/2850)) ### Miscellaneous Tasks - Update to rust 1.88 ([#2815](https://github.com/atuinsh/atuin/issues/2815)) ### Nushell - Fix `get -i` deprecation ([#2829](https://github.com/atuinsh/atuin/issues/2829)) ## 18.7.1 ### Bug Fixes - Add check for postgresql prefix ([#2825](https://github.com/atuinsh/atuin/issues/2825)) ### Miscellaneous Tasks - Update changelog ## 18.7.0 ### Bug Fixes - *(api)* Allow trailing slashes in sync_address ([#2760](https://github.com/atuinsh/atuin/issues/2760)) - *(doctor)* Mention the required ble.sh version ([#2774](https://github.com/atuinsh/atuin/issues/2774)) - *(search)* Prevent panic on malformed format strings ([#2776](https://github.com/atuinsh/atuin/issues/2776)) ([#2777](https://github.com/atuinsh/atuin/issues/2777)) - Clarify that HISTFILE, if used, must be exported ([#2758](https://github.com/atuinsh/atuin/issues/2758)) - Don't print errors in `zsh_autosuggest` helper ([#2780](https://github.com/atuinsh/atuin/issues/2780)) - `atuin.nu` enchancements ([#2778](https://github.com/atuinsh/atuin/issues/2778)) - Refuse "--dupkeep 0" ([#2807](https://github.com/atuinsh/atuin/issues/2807)) ### Features - Add sqlite server support for self-hosting ([#2770](https://github.com/atuinsh/atuin/issues/2770)) ### Miscellaneous Tasks - *(ci)* Install toolchain that matches rust-toolchain.toml ([#2759](https://github.com/atuinsh/atuin/issues/2759)) - Allow setting script DB path ([#2750](https://github.com/atuinsh/atuin/issues/2750)) ## 18.6.1 ### Bug Fixes - Selection vs render issue ([#2706](https://github.com/atuinsh/atuin/issues/2706)) ### Features - *(stats)* Add jj to default common subcommands ([#2708](https://github.com/atuinsh/atuin/issues/2708)) - Delete duplicate history ([#2697](https://github.com/atuinsh/atuin/issues/2697)) - Sort `atuin store status` output ([#2719](https://github.com/atuinsh/atuin/issues/2719)) - Implement KV as a write-through cache ([#2732](https://github.com/atuinsh/atuin/issues/2732)) ### Miscellaneous Tasks - Use native github arm64 runner ([#2690](https://github.com/atuinsh/atuin/issues/2690)) - Fix typos ([#2668](https://github.com/atuinsh/atuin/issues/2668)) ## 18.5.0 ### Bug Fixes - *(1289)* Clear terminal area if inline ([#2600](https://github.com/atuinsh/atuin/issues/2600)) - *(bash)* Fix preexec of child Bash session started by enter_accept ([#2558](https://github.com/atuinsh/atuin/issues/2558)) - *(build)* Change atuin-daemon build script .proto paths ([#2638](https://github.com/atuinsh/atuin/issues/2638)) - *(kv)* Filter deleted keys from `kv list` ([#2665](https://github.com/atuinsh/atuin/issues/2665)) - *(stats)* Ignore leading environment variables when calculating stats ([#2659](https://github.com/atuinsh/atuin/issues/2659)) - *(wrapped)* Fix crash when history is empty ([#2508](https://github.com/atuinsh/atuin/issues/2508)) - *(zsh)* Fix an error introduced earilier with support for bracketed paste mode ([#2651](https://github.com/atuinsh/atuin/issues/2651)) - *(zsh)* Avoid calling user-defined widgets when searching for history position ([#2670](https://github.com/atuinsh/atuin/issues/2670)) - Add .histfile as file to look for when doing atuin import zsh ([#2588](https://github.com/atuinsh/atuin/issues/2588)) - Panic when invoking delete on empty tui ([#2584](https://github.com/atuinsh/atuin/issues/2584)) - Sql files checksums ([#2601](https://github.com/atuinsh/atuin/issues/2601)) - Up binding with fish 4.0 ([#2613](https://github.com/atuinsh/atuin/issues/2613)) ([#2616](https://github.com/atuinsh/atuin/issues/2616)) - Don't save empty commands ([#2605](https://github.com/atuinsh/atuin/issues/2605)) - Improve broken symlink error handling ([#2589](https://github.com/atuinsh/atuin/issues/2589)) - Multiline command does not honour max_preview_height ([#2624](https://github.com/atuinsh/atuin/issues/2624)) - Typeerror in client sync code ([#2647](https://github.com/atuinsh/atuin/issues/2647)) - Add redundant clones to clippy and cleanup instances of it ([#2654](https://github.com/atuinsh/atuin/issues/2654)) - Allow -ve values for timezone ([#2609](https://github.com/atuinsh/atuin/issues/2609)) - Fish up binding bug ([#2677](https://github.com/atuinsh/atuin/issues/2677)) - Switch to astral cargo-dist ([#2687](https://github.com/atuinsh/atuin/issues/2687)) ### Documentation - Update logo and badges in README for zh-CN ([#2392](https://github.com/atuinsh/atuin/issues/2392)) ### Features - *(client)* Update AWS secrets env var handling checks ([#2501](https://github.com/atuinsh/atuin/issues/2501)) - *(health)* Add health check endpoint at `/healthz` ([#2549](https://github.com/atuinsh/atuin/issues/2549)) - *(kv)* Add support for 'atuin kv delete' ([#2660](https://github.com/atuinsh/atuin/issues/2660)) - *(wrapped)* Add more pkg managers ([#2503](https://github.com/atuinsh/atuin/issues/2503)) - *(zsh)* Try to go to the position in zsh's history ([#1469](https://github.com/atuinsh/atuin/issues/1469)) - *(zsh)* Re-enable bracketed paste ([#2646](https://github.com/atuinsh/atuin/issues/2646)) - Add the --print0 option to search ([#2562](https://github.com/atuinsh/atuin/issues/2562)) - Make new arrow key behavior configurable ([#2606](https://github.com/atuinsh/atuin/issues/2606)) - Use readline binding for ctrl-a when it is not the prefix ([#2626](https://github.com/atuinsh/atuin/issues/2626)) - Option to include duplicate commands when printing history commands ([#2407](https://github.com/atuinsh/atuin/issues/2407)) - Binaries as subcommands ([#2661](https://github.com/atuinsh/atuin/issues/2661)) - Support storing, syncing and executing scripts ([#2644](https://github.com/atuinsh/atuin/issues/2644)) - Add 'atuin scripts rm' and 'atuin scripts ls' aliases; allow reading from stdin ([#2680](https://github.com/atuinsh/atuin/issues/2680)) ### Miscellaneous Tasks - Remove unneeded dependencies ([#2523](https://github.com/atuinsh/atuin/issues/2523)) - Update rust toolchain to 1.85 ([#2618](https://github.com/atuinsh/atuin/issues/2618)) - Align daemon and client sync freq ([#2628](https://github.com/atuinsh/atuin/issues/2628)) - Migrate to rust 2024 ([#2635](https://github.com/atuinsh/atuin/issues/2635)) - Show host and user in inspector ([#2634](https://github.com/atuinsh/atuin/issues/2634)) - Update to rust 1.85.1 ([#2642](https://github.com/atuinsh/atuin/issues/2642)) - Update to rust 1.86 ([#2666](https://github.com/atuinsh/atuin/issues/2666)) ### Performance - Cache `SECRET_PATTERNS`'s `RegexSet` ([#2570](https://github.com/atuinsh/atuin/issues/2570)) ### Styling - Avoid calling `unwrap()` when we don't have to ([#2519](https://github.com/atuinsh/atuin/issues/2519)) ### Build - *(nix)* Bump `flake.lock` ([#2637](https://github.com/atuinsh/atuin/issues/2637)) ### Flake.lock - Update ([#2463](https://github.com/atuinsh/atuin/issues/2463)) ## 18.4.0 ### Bug Fixes - *(crate)* Add missing description ([#2106](https://github.com/atuinsh/atuin/issues/2106)) - *(crate)* Add description to daemon crate ([#2107](https://github.com/atuinsh/atuin/issues/2107)) - *(daemon)* Add context to error when unable to connect ([#2394](https://github.com/atuinsh/atuin/issues/2394)) - *(deps)* Pin tiny_bip to 1.0.0 until breaking change resolved ([#2412](https://github.com/atuinsh/atuin/issues/2412)) - *(docker)* Update Dockerfile ([#2369](https://github.com/atuinsh/atuin/issues/2369)) - *(gui)* Update deps ([#2116](https://github.com/atuinsh/atuin/issues/2116)) - *(gui)* Add support for checking if the cli is installed on windows ([#2162](https://github.com/atuinsh/atuin/issues/2162)) - *(gui)* WeekInfo call on Edge ([#2252](https://github.com/atuinsh/atuin/issues/2252)) - *(gui)* Add \r for windows (shouldn't effect unix bc they should ignore it) ([#2253](https://github.com/atuinsh/atuin/issues/2253)) - *(gui)* Terminal resize overflow ([#2285](https://github.com/atuinsh/atuin/issues/2285)) - *(gui)* Kill child on block stop ([#2288](https://github.com/atuinsh/atuin/issues/2288)) - *(gui)* Do not hardcode db path ([#2309](https://github.com/atuinsh/atuin/issues/2309)) - *(gui)* Double return on mac/linux ([#2311](https://github.com/atuinsh/atuin/issues/2311)) - *(gui)* Cursor positioning on new doc creation ([#2310](https://github.com/atuinsh/atuin/issues/2310)) - *(gui)* Random ts errors ([#2316](https://github.com/atuinsh/atuin/issues/2316)) - *(history)* Logic for store_failed=false ([#2284](https://github.com/atuinsh/atuin/issues/2284)) - *(mail)* Incorrect alias and error logs ([#2346](https://github.com/atuinsh/atuin/issues/2346)) - *(mail)* Enable correct tls features for postmark client ([#2347](https://github.com/atuinsh/atuin/issues/2347)) - *(theme)* Restore original colours ([#2339](https://github.com/atuinsh/atuin/issues/2339)) - *(themes)* Restore default theme, refactor ([#2294](https://github.com/atuinsh/atuin/issues/2294)) - *(tui)* Press ctrl-a twice should jump to beginning of line ([#2246](https://github.com/atuinsh/atuin/issues/2246)) - *(tui)* Don't panic when search result is empty and up is pressed ([#2395](https://github.com/atuinsh/atuin/issues/2395)) - Cargo binstall config ([#2112](https://github.com/atuinsh/atuin/issues/2112)) - Unitless sync_frequence = 0 not parsed by humantime ([#2154](https://github.com/atuinsh/atuin/issues/2154)) - Some --help comments didn't show properly ([#2176](https://github.com/atuinsh/atuin/issues/2176)) - Ensure we cleanup all tables when deleting ([#2191](https://github.com/atuinsh/atuin/issues/2191)) - Add idx cache unique index ([#2226](https://github.com/atuinsh/atuin/issues/2226)) - Idx cache inconsistency ([#2231](https://github.com/atuinsh/atuin/issues/2231)) - Ambiguous column name ([#2232](https://github.com/atuinsh/atuin/issues/2232)) - Atuin-daemon optional dependency ([#2306](https://github.com/atuinsh/atuin/issues/2306)) - Windows build error ([#2321](https://github.com/atuinsh/atuin/issues/2321)) - Codespell config still references the ui ([#2330](https://github.com/atuinsh/atuin/issues/2330)) - Remove dbg! macro ([#2355](https://github.com/atuinsh/atuin/issues/2355)) - Disable mail by default, resolve #2404 ([#2405](https://github.com/atuinsh/atuin/issues/2405)) - Time offset display in `atuin status` ([#2433](https://github.com/atuinsh/atuin/issues/2433)) - Disable the actuated mirror on the x86 docker builder ([#2443](https://github.com/atuinsh/atuin/issues/2443)) ### Documentation - *(README)* Fix broken link ([#2206](https://github.com/atuinsh/atuin/issues/2206)) - *(gui)* Update README ([#2283](https://github.com/atuinsh/atuin/issues/2283)) - Streamline readme ([#2203](https://github.com/atuinsh/atuin/issues/2203)) - Update quickstart install command ([#2205](https://github.com/atuinsh/atuin/issues/2205)) ### Features - *(bash/blesh)* Hook into BLE_ONLOAD to resolve loading order issue ([#2234](https://github.com/atuinsh/atuin/issues/2234)) - *(client)* Add filter mode enablement and ordering configuration ([#2430](https://github.com/atuinsh/atuin/issues/2430)) - *(daemon)* Follow XDG_RUNTIME_DIR if set ([#2171](https://github.com/atuinsh/atuin/issues/2171)) - *(gui)* Automatically install and setup the cli/shell ([#2139](https://github.com/atuinsh/atuin/issues/2139)) - *(gui)* Add activity calendar to the homepage ([#2160](https://github.com/atuinsh/atuin/issues/2160)) - *(gui)* Cache zustand store in localstorage ([#2168](https://github.com/atuinsh/atuin/issues/2168)) - *(gui)* Toast with prompt for cli install, rather than auto ([#2173](https://github.com/atuinsh/atuin/issues/2173)) - *(gui)* Runbooks that run ([#2233](https://github.com/atuinsh/atuin/issues/2233)) - *(gui)* Use fancy new side nav ([#2243](https://github.com/atuinsh/atuin/issues/2243)) - *(gui)* Add runbook list, ability to create and delete, sql storage ([#2282](https://github.com/atuinsh/atuin/issues/2282)) - *(gui)* Background terminals and more ([#2303](https://github.com/atuinsh/atuin/issues/2303)) - *(gui)* Clean up home page, fix a few bugs ([#2304](https://github.com/atuinsh/atuin/issues/2304)) - *(gui)* Allow interacting with the embedded terminal ([#2312](https://github.com/atuinsh/atuin/issues/2312)) - *(gui)* Directory block, re-org of some code ([#2314](https://github.com/atuinsh/atuin/issues/2314)) - *(gui)* Folder select dialogue for directory block ([#2315](https://github.com/atuinsh/atuin/issues/2315)) - *(history)* Filter out various environment variables containing potential secrets ([#2174](https://github.com/atuinsh/atuin/issues/2174)) - *(tui)* Configurable prefix character ([#2157](https://github.com/atuinsh/atuin/issues/2157)) - *(tui)* Customizable Themes ([#2236](https://github.com/atuinsh/atuin/issues/2236)) - *(tui)* Fixed preview height option ([#2286](https://github.com/atuinsh/atuin/issues/2286)) - Use cargo-dist installer from our install script ([#2108](https://github.com/atuinsh/atuin/issues/2108)) - Add user account verification ([#2190](https://github.com/atuinsh/atuin/issues/2190)) - Add GitLab PAT to secret patterns ([#2196](https://github.com/atuinsh/atuin/issues/2196)) - Add several other GitHub access token patterns ([#2200](https://github.com/atuinsh/atuin/issues/2200)) - Add npm, Netlify and Pulumi tokens to secret patterns ([#2210](https://github.com/atuinsh/atuin/issues/2210)) - Allow advertising a fake version to clients ([#2228](https://github.com/atuinsh/atuin/issues/2228)) - Monitor idx cache consistency before switching ([#2229](https://github.com/atuinsh/atuin/issues/2229)) - Ultracompact Mode (search-only) ([#2357](https://github.com/atuinsh/atuin/issues/2357)) - Right Arrow to modify selected command ([#2453](https://github.com/atuinsh/atuin/issues/2453)) - Provide additional clarity around key management ([#2467](https://github.com/atuinsh/atuin/issues/2467)) - Add `atuin wrapped` ([#2493](https://github.com/atuinsh/atuin/issues/2493)) ### Miscellaneous Tasks - *(build)* Compile protobufs with protox ([#2122](https://github.com/atuinsh/atuin/issues/2122)) - *(ci)* Do not run current ci for ui ([#2189](https://github.com/atuinsh/atuin/issues/2189)) - *(ci)* Codespell again ([#2332](https://github.com/atuinsh/atuin/issues/2332)) - *(deps-dev)* Bump @tauri-apps/cli in /ui ([#2135](https://github.com/atuinsh/atuin/issues/2135)) - *(deps-dev)* Bump vite from 5.2.13 to 5.3.1 in /ui ([#2150](https://github.com/atuinsh/atuin/issues/2150)) - *(deps-dev)* Bump @tauri-apps/cli in /ui ([#2277](https://github.com/atuinsh/atuin/issues/2277)) - *(deps-dev)* Bump tailwindcss from 3.4.4 to 3.4.6 in /ui ([#2301](https://github.com/atuinsh/atuin/issues/2301)) - *(install)* Use posix sh, not bash ([#2204](https://github.com/atuinsh/atuin/issues/2204)) - *(nix)* De-couple atuin nix build from nixpkgs rustc version ([#2123](https://github.com/atuinsh/atuin/issues/2123)) - Add installer e2e tests ([#2110](https://github.com/atuinsh/atuin/issues/2110)) - Remove unnecessary proto import ([#2120](https://github.com/atuinsh/atuin/issues/2120)) - Update to rust 1.78 - Add audit config, ignore RUSTSEC-2023-0071 ([#2126](https://github.com/atuinsh/atuin/issues/2126)) - Setup dependabot for the ui ([#2128](https://github.com/atuinsh/atuin/issues/2128)) - Cargo and pnpm update ([#2127](https://github.com/atuinsh/atuin/issues/2127)) - Update to rust 1.79 ([#2138](https://github.com/atuinsh/atuin/issues/2138)) - Update to cargo-dist 0.16, enable attestations ([#2156](https://github.com/atuinsh/atuin/issues/2156)) - Do not use package managers in installer ([#2201](https://github.com/atuinsh/atuin/issues/2201)) - Enable record sync by default ([#2255](https://github.com/atuinsh/atuin/issues/2255)) - Remove ui directory ([#2329](https://github.com/atuinsh/atuin/issues/2329)) - Update to rust 1.80 ([#2344](https://github.com/atuinsh/atuin/issues/2344)) - Update rust to `1.80.1` ([#2362](https://github.com/atuinsh/atuin/issues/2362)) - Enable inline height and compact by default ([#2249](https://github.com/atuinsh/atuin/issues/2249)) - Update to rust 1.82 ([#2432](https://github.com/atuinsh/atuin/issues/2432)) - Update cargo-dist ([#2471](https://github.com/atuinsh/atuin/issues/2471)) ### Performance - *(search)* Benchmark smart sort ([#2202](https://github.com/atuinsh/atuin/issues/2202)) - Create idx cache table ([#2140](https://github.com/atuinsh/atuin/issues/2140)) - Write to the idx cache ([#2225](https://github.com/atuinsh/atuin/issues/2225)) ### Testing - Add env ATUIN_TEST_LOCAL_TIMEOUT to control test timeout of SQLite ([#2337](https://github.com/atuinsh/atuin/issues/2337)) ### Flake.lock - Update ([#2213](https://github.com/atuinsh/atuin/issues/2213)) - Update ([#2378](https://github.com/atuinsh/atuin/issues/2378)) - Update ([#2402](https://github.com/atuinsh/atuin/issues/2402)) ## 18.3.0 ### Bug Fixes - *(bash)* Fix a workaround for bash-5.2 keybindings ([#2060](https://github.com/atuinsh/atuin/issues/2060)) - *(ci)* Release workflow ([#1978](https://github.com/atuinsh/atuin/issues/1978)) - *(client)* Better error reporting on login/registration ([#2076](https://github.com/atuinsh/atuin/issues/2076)) - *(config)* Add quotes for strategy value in comment ([#1993](https://github.com/atuinsh/atuin/issues/1993)) - *(daemon)* Do not try to sync if logged out ([#2037](https://github.com/atuinsh/atuin/issues/2037)) - *(deps)* Replace parse_duration with humantime ([#2074](https://github.com/atuinsh/atuin/issues/2074)) - *(dotfiles)* Alias import with init output ([#1970](https://github.com/atuinsh/atuin/issues/1970)) - *(dotfiles)* Fish alias import ([#1972](https://github.com/atuinsh/atuin/issues/1972)) - *(dotfiles)* More fish alias import ([#1974](https://github.com/atuinsh/atuin/issues/1974)) - *(dotfiles)* Unquote aliases before quoting ([#1976](https://github.com/atuinsh/atuin/issues/1976)) - *(dotfiles)* Allow clearing aliases, disable import ([#1995](https://github.com/atuinsh/atuin/issues/1995)) - *(stats)* Generation for commands starting with a pipe ([#2058](https://github.com/atuinsh/atuin/issues/2058)) - *(ui)* Handle being logged out gracefully ([#2052](https://github.com/atuinsh/atuin/issues/2052)) - *(ui)* Fix mistake in last pr ([#2053](https://github.com/atuinsh/atuin/issues/2053)) - Support not-mac for default shell ([#1960](https://github.com/atuinsh/atuin/issues/1960)) - Adapt help to `enter_accept` config ([#2001](https://github.com/atuinsh/atuin/issues/2001)) - Add protobuf compiler to docker image ([#2009](https://github.com/atuinsh/atuin/issues/2009)) - Add incremental rebuild to daemon loop ([#2010](https://github.com/atuinsh/atuin/issues/2010)) - Alias enable/enabled in settings ([#2021](https://github.com/atuinsh/atuin/issues/2021)) - Bogus error message wording ([#1283](https://github.com/atuinsh/atuin/issues/1283)) - Save sync time in daemon ([#2029](https://github.com/atuinsh/atuin/issues/2029)) - Redact password in database URI when logging ([#2032](https://github.com/atuinsh/atuin/issues/2032)) - Save sync time in daemon ([#2051](https://github.com/atuinsh/atuin/issues/2051)) - Replace serde_yaml::to_string with serde_json::to_string_yaml ([#2087](https://github.com/atuinsh/atuin/issues/2087)) ### Documentation - Fix "From source" `cd` command ([#1973](https://github.com/atuinsh/atuin/issues/1973)) - Add docs for store subcommand ([#2097](https://github.com/atuinsh/atuin/issues/2097)) ### Features - *(daemon)* Add support for daemon on windows ([#2014](https://github.com/atuinsh/atuin/issues/2014)) - *(doctor)* Detect active preexec framework ([#1955](https://github.com/atuinsh/atuin/issues/1955)) - *(doctor)* Report sqlite version ([#2075](https://github.com/atuinsh/atuin/issues/2075)) - *(dotfiles)* Support syncing shell/env vars ([#1977](https://github.com/atuinsh/atuin/issues/1977)) - *(gui)* Work on home page, sort state ([#1956](https://github.com/atuinsh/atuin/issues/1956)) - *(history)* Create atuin-history, add stats to it ([#1990](https://github.com/atuinsh/atuin/issues/1990)) - *(install)* Add Tuxedo OS ([#2018](https://github.com/atuinsh/atuin/issues/2018)) - *(server)* Add me endpoint ([#1954](https://github.com/atuinsh/atuin/issues/1954)) - *(ui)* Scroll history infinitely ([#1999](https://github.com/atuinsh/atuin/issues/1999)) - *(ui)* Add history explore ([#2022](https://github.com/atuinsh/atuin/issues/2022)) - *(ui)* Use correct username on welcome screen ([#2050](https://github.com/atuinsh/atuin/issues/2050)) - *(ui)* Add login/register dialog ([#2056](https://github.com/atuinsh/atuin/issues/2056)) - *(ui)* Setup single-instance ([#2093](https://github.com/atuinsh/atuin/issues/2093)) - *(ui/dotfiles)* Add vars ([#1989](https://github.com/atuinsh/atuin/issues/1989)) - Allow ignoring failed commands ([#1957](https://github.com/atuinsh/atuin/issues/1957)) - Show preview auto ([#1804](https://github.com/atuinsh/atuin/issues/1804)) - Add background daemon ([#2006](https://github.com/atuinsh/atuin/issues/2006)) - Support importing from replxx history files ([#2024](https://github.com/atuinsh/atuin/issues/2024)) - Support systemd socket activation for daemon ([#2039](https://github.com/atuinsh/atuin/issues/2039)) ### Miscellaneous Tasks - *(ci)* Don't run "Update Nix Deps" CI on forks ([#2070](https://github.com/atuinsh/atuin/issues/2070)) - *(codespell)* Ignore CODE_OF_CONDUCT ([#2044](https://github.com/atuinsh/atuin/issues/2044)) - *(install)* Log cargo and rustc version ([#2068](https://github.com/atuinsh/atuin/issues/2068)) - *(release)* V18.3.0-prerelease.1 ([#2090](https://github.com/atuinsh/atuin/issues/2090)) - Move crates into crates/ dir ([#1958](https://github.com/atuinsh/atuin/issues/1958)) - Fix atuin crate readme ([#1959](https://github.com/atuinsh/atuin/issues/1959)) - Add some more logging to handlers ([#1971](https://github.com/atuinsh/atuin/issues/1971)) - Add some more debug logs ([#1979](https://github.com/atuinsh/atuin/issues/1979)) - Clarify default config file ([#2026](https://github.com/atuinsh/atuin/issues/2026)) - Handle rate limited responses ([#2057](https://github.com/atuinsh/atuin/issues/2057)) - Add Systemd config for self-hosted server ([#1879](https://github.com/atuinsh/atuin/issues/1879)) - Switch to cargo dist for releases ([#2085](https://github.com/atuinsh/atuin/issues/2085)) - Update email, gitignore, tweak ui ([#2094](https://github.com/atuinsh/atuin/issues/2094)) - Show scope in changelog ([#2102](https://github.com/atuinsh/atuin/issues/2102)) ### Performance - *(nushell)* Use version.(major|minor|patch) if available ([#1963](https://github.com/atuinsh/atuin/issues/1963)) - Only open the database for commands if strictly required ([#2043](https://github.com/atuinsh/atuin/issues/2043)) ### Refactor - Preview_auto to use enum and different option ([#1991](https://github.com/atuinsh/atuin/issues/1991)) ## 18.2.0 ### Bug Fixes - *(bash)* Do not use "return" to cancel initialization ([#1928](https://github.com/atuinsh/atuin/issues/1928)) - *(crate)* Add missing description ([#1861](https://github.com/atuinsh/atuin/issues/1861)) - *(doctor)* Detect preexec plugin using env ATUIN_PREEXEC_BACKEND ([#1856](https://github.com/atuinsh/atuin/issues/1856)) - *(install)* Install script echo ([#1899](https://github.com/atuinsh/atuin/issues/1899)) - *(nu)* Update atuin.nu to resolve 0.92 deprecation ([#1913](https://github.com/atuinsh/atuin/issues/1913)) - *(search)* Allow empty search ([#1866](https://github.com/atuinsh/atuin/issues/1866)) - *(search)* Case insensitive hostname filtering ([#1883](https://github.com/atuinsh/atuin/issues/1883)) - Pass search query in via env ([#1865](https://github.com/atuinsh/atuin/issues/1865)) - Pass search query in via env for *Nushell* ([#1874](https://github.com/atuinsh/atuin/issues/1874)) - Report non-decodable errors correctly ([#1915](https://github.com/atuinsh/atuin/issues/1915)) - Use spawn_blocking for file access during async context ([#1936](https://github.com/atuinsh/atuin/issues/1936)) ### Documentation - *(bash-preexec)* Describe the limitation of missing commands ([#1937](https://github.com/atuinsh/atuin/issues/1937)) - Add security contact ([#1867](https://github.com/atuinsh/atuin/issues/1867)) - Add install instructions for cave/exherbo linux in README.md ([#1927](https://github.com/atuinsh/atuin/issues/1927)) - Add missing cli help text ([#1945](https://github.com/atuinsh/atuin/issues/1945)) ### Features - *(bash/blesh)* Use _ble_exec_time_ata for duration even in bash < 5 ([#1940](https://github.com/atuinsh/atuin/issues/1940)) - *(dotfiles)* Add alias import ([#1938](https://github.com/atuinsh/atuin/issues/1938)) - *(gui)* Add base structure ([#1935](https://github.com/atuinsh/atuin/issues/1935)) - *(install)* Update install.sh to support KDE Neon ([#1908](https://github.com/atuinsh/atuin/issues/1908)) - *(search)* Process [C-h] and [C-?] as representations of backspace ([#1857](https://github.com/atuinsh/atuin/issues/1857)) - *(search)* Allow specifying search query as an env var ([#1863](https://github.com/atuinsh/atuin/issues/1863)) - *(search)* Add better search scoring ([#1885](https://github.com/atuinsh/atuin/issues/1885)) - *(server)* Check PG version before running migrations ([#1868](https://github.com/atuinsh/atuin/issues/1868)) - Add atuin prefix binding ([#1875](https://github.com/atuinsh/atuin/issues/1875)) - Sync v2 default for new installs ([#1914](https://github.com/atuinsh/atuin/issues/1914)) - Add 'ctrl-a a' to jump to beginning of line ([#1917](https://github.com/atuinsh/atuin/issues/1917)) - Prevents stderr from going to the screen ([#1933](https://github.com/atuinsh/atuin/issues/1933)) ### Miscellaneous Tasks - *(ci)* Add codespell support (config, workflow) and make it fix some typos ([#1916](https://github.com/atuinsh/atuin/issues/1916)) - *(gui)* Cargo update ([#1943](https://github.com/atuinsh/atuin/issues/1943)) - Add issue form ([#1871](https://github.com/atuinsh/atuin/issues/1871)) - Require atuin doctor in issue form ([#1872](https://github.com/atuinsh/atuin/issues/1872)) - Add section to issue form ([#1873](https://github.com/atuinsh/atuin/issues/1873)) ### Performance - *(dotfiles)* Cache aliases and read straight from file ([#1918](https://github.com/atuinsh/atuin/issues/1918)) ## 18.1.0 ### Bug Fixes - *(bash)* Rework #1509 to recover from the preexec failure ([#1729](https://github.com/atuinsh/atuin/issues/1729)) - *(build)* Make atuin compile on non-win/mac/linux platforms ([#1825](https://github.com/atuinsh/atuin/issues/1825)) - *(client)* No panic on empty inspector ([#1768](https://github.com/atuinsh/atuin/issues/1768)) - *(doctor)* Use a different method to detect env vars ([#1819](https://github.com/atuinsh/atuin/issues/1819)) - *(dotfiles)* Use latest client ([#1859](https://github.com/atuinsh/atuin/issues/1859)) - *(import/zsh-histdb)* Missing or wrong fields ([#1740](https://github.com/atuinsh/atuin/issues/1740)) - *(nix)* Set meta.mainProgram in the package ([#1823](https://github.com/atuinsh/atuin/issues/1823)) - *(nushell)* Readd up-arrow keybinding, now with menu handling ([#1770](https://github.com/atuinsh/atuin/issues/1770)) - *(regex)* Disable regex error logs ([#1806](https://github.com/atuinsh/atuin/issues/1806)) - *(stats)* Enable multiple command stats to be shown using unicode_segmentation ([#1739](https://github.com/atuinsh/atuin/issues/1739)) - *(store-init)* Re-sync after running auto store init ([#1834](https://github.com/atuinsh/atuin/issues/1834)) - *(sync)* Check store length after sync, not before ([#1805](https://github.com/atuinsh/atuin/issues/1805)) - *(sync)* Record size limiter ([#1827](https://github.com/atuinsh/atuin/issues/1827)) - *(tz)* Attempt to fix timezone reading ([#1810](https://github.com/atuinsh/atuin/issues/1810)) - *(ui)* Don't preserve for empty space ([#1712](https://github.com/atuinsh/atuin/issues/1712)) - *(xonsh)* Add xonsh to auto import, respect $HISTFILE in xonsh import, and fix issue with up-arrow keybinding in xonsh ([#1711](https://github.com/atuinsh/atuin/issues/1711)) - Fish init ([#1725](https://github.com/atuinsh/atuin/issues/1725)) - Typo ([#1741](https://github.com/atuinsh/atuin/issues/1741)) - Check session file exists for status command ([#1756](https://github.com/atuinsh/atuin/issues/1756)) - Ensure sync time is saved for sync v2 ([#1758](https://github.com/atuinsh/atuin/issues/1758)) - Missing characters in preview ([#1803](https://github.com/atuinsh/atuin/issues/1803)) - Doctor shell wording ([#1858](https://github.com/atuinsh/atuin/issues/1858)) ### Documentation - Minor formatting updates to the default config.toml ([#1689](https://github.com/atuinsh/atuin/issues/1689)) - Update docker compose ([#1818](https://github.com/atuinsh/atuin/issues/1818)) - Use db name env variable also in uri ([#1840](https://github.com/atuinsh/atuin/issues/1840)) ### Features - *(client)* Add config option keys.scroll_exits ([#1744](https://github.com/atuinsh/atuin/issues/1744)) - *(dotfiles)* Add enable setting to dotfiles, disable by default ([#1829](https://github.com/atuinsh/atuin/issues/1829)) - *(nix)* Add update action ([#1779](https://github.com/atuinsh/atuin/issues/1779)) - *(nu)* Return early if history is disabled ([#1807](https://github.com/atuinsh/atuin/issues/1807)) - *(nushell)* Add nushell completion generation ([#1791](https://github.com/atuinsh/atuin/issues/1791)) - *(search)* Process Ctrl+m for kitty keyboard protocol ([#1720](https://github.com/atuinsh/atuin/issues/1720)) - *(stats)* Normalize formatting of default config, suggest nix ([#1764](https://github.com/atuinsh/atuin/issues/1764)) - *(stats)* Add linux sysadmin commands to common_subcommands ([#1784](https://github.com/atuinsh/atuin/issues/1784)) - *(ui)* Add config setting for showing tabs ([#1755](https://github.com/atuinsh/atuin/issues/1755)) - Use ATUIN_TEST_SQLITE_STORE_TIMEOUT to specify test timeout of SQLite store ([#1703](https://github.com/atuinsh/atuin/issues/1703)) - Add 'a', 'A', 'h', and 'l' bindings to vim-normal mode ([#1697](https://github.com/atuinsh/atuin/issues/1697)) - Add xonsh history import ([#1678](https://github.com/atuinsh/atuin/issues/1678)) - Add 'ignored_commands' option to stats ([#1722](https://github.com/atuinsh/atuin/issues/1722)) - Support syncing aliases ([#1721](https://github.com/atuinsh/atuin/issues/1721)) - Change fulltext to do multi substring match ([#1660](https://github.com/atuinsh/atuin/issues/1660)) - Add history prune subcommand ([#1743](https://github.com/atuinsh/atuin/issues/1743)) - Add alias feedback and list command ([#1747](https://github.com/atuinsh/atuin/issues/1747)) - Add PHP package manager "composer" to list of default common subcommands ([#1757](https://github.com/atuinsh/atuin/issues/1757)) - Add '/', '?', and 'I' bindings to vim-normal mode ([#1760](https://github.com/atuinsh/atuin/issues/1760)) - Add `CTRL+[` binding as `` alias ([#1787](https://github.com/atuinsh/atuin/issues/1787)) - Add atuin doctor ([#1796](https://github.com/atuinsh/atuin/issues/1796)) - Add checks for common setup issues ([#1799](https://github.com/atuinsh/atuin/issues/1799)) - Support regex with r/.../ syntax ([#1745](https://github.com/atuinsh/atuin/issues/1745)) - Guard against ancient versions of bash where this does not work. ([#1794](https://github.com/atuinsh/atuin/issues/1794)) - Add automatic history store init ([#1831](https://github.com/atuinsh/atuin/issues/1831)) - Adds info command to show env vars and config files ([#1841](https://github.com/atuinsh/atuin/issues/1841)) ### Miscellaneous Tasks - *(ci)* Add cross-compile job for illumos ([#1830](https://github.com/atuinsh/atuin/issues/1830)) - *(ci)* Setup nextest ([#1848](https://github.com/atuinsh/atuin/issues/1848)) - Do not show history table stats when using records ([#1835](https://github.com/atuinsh/atuin/issues/1835)) ### Performance - Optimize history init-store ([#1691](https://github.com/atuinsh/atuin/issues/1691)) ### Refactor - *(alias)* Clarify operation result for working with aliases ([#1748](https://github.com/atuinsh/atuin/issues/1748)) - *(nushell)* Update `commandline` syntax, closes #1733 ([#1735](https://github.com/atuinsh/atuin/issues/1735)) - Rename atuin-config to atuin-dotfiles ([#1817](https://github.com/atuinsh/atuin/issues/1817)) ## 18.0.1 ### Bug Fixes - Reorder the exit of enhanced keyboard mode ([#1694](https://github.com/atuinsh/atuin/issues/1694)) ## 18.0.0 ### Bug Fixes - *(bash)* Avoid unexpected `atuin history start` for keybindings ([#1509](https://github.com/atuinsh/atuin/issues/1509)) - *(bash)* Prevent input to be interpreted as options for blesh auto-complete ([#1511](https://github.com/atuinsh/atuin/issues/1511)) - *(bash)* Work around custom IFS ([#1514](https://github.com/atuinsh/atuin/issues/1514)) - *(bash)* Fix and improve the keybinding to `up` ([#1515](https://github.com/atuinsh/atuin/issues/1515)) - *(bash)* Work around bash < 4 and introduce initialization guards ([#1533](https://github.com/atuinsh/atuin/issues/1533)) - *(bash)* Strip control chars generated by `\[\]` in PS1 with bash-preexec ([#1620](https://github.com/atuinsh/atuin/issues/1620)) - *(bash/preexec)* Erase the prompt last line before Bash renders it - *(bash/preexec)* Erase the previous prompt before overwriting - *(bash/preexec)* Support termcap names for tput ([#1670](https://github.com/atuinsh/atuin/issues/1670)) - *(docs)* Update repo url in CONTRIBUTING.md ([#1594](https://github.com/atuinsh/atuin/issues/1594)) - *(fish)* Integration on older fishes ([#1563](https://github.com/atuinsh/atuin/issues/1563)) - *(perm)* Set umask 077 ([#1554](https://github.com/atuinsh/atuin/issues/1554)) - *(search)* Fix invisible tab title ([#1560](https://github.com/atuinsh/atuin/issues/1560)) - *(shell)* Fix incorrect timing of child shells ([#1510](https://github.com/atuinsh/atuin/issues/1510)) - *(sync)* Save sync time when it starts, not ends ([#1573](https://github.com/atuinsh/atuin/issues/1573)) - *(tests)* Add Settings::utc() for utc settings ([#1677](https://github.com/atuinsh/atuin/issues/1677)) - *(tui)* Dedupe was removing history ([#1610](https://github.com/atuinsh/atuin/issues/1610)) - *(windows)* Disables unix specific stuff for windows ([#1557](https://github.com/atuinsh/atuin/issues/1557)) - Prevent input to be interpreted as options for zsh autosuggestions ([#1506](https://github.com/atuinsh/atuin/issues/1506)) - Disable musl deb building ([#1525](https://github.com/atuinsh/atuin/issues/1525)) - Shorten text, use ctrl-o for inspector ([#1561](https://github.com/atuinsh/atuin/issues/1561)) - Print literal control characters to non terminals ([#1586](https://github.com/atuinsh/atuin/issues/1586)) - Escape control characters in command preview ([#1588](https://github.com/atuinsh/atuin/issues/1588)) - Use existing db querying for history list ([#1589](https://github.com/atuinsh/atuin/issues/1589)) - Add acquire timeout to sqlite database connection ([#1590](https://github.com/atuinsh/atuin/issues/1590)) - Only escape control characters when writing to terminal ([#1593](https://github.com/atuinsh/atuin/issues/1593)) - Check for format errors when printing history ([#1623](https://github.com/atuinsh/atuin/issues/1623)) - Skip padding time if it will overflow the allowed prefix length ([#1630](https://github.com/atuinsh/atuin/issues/1630)) - Never overwrite the key ([#1657](https://github.com/atuinsh/atuin/issues/1657)) - Set durability for sqlite to recommended settings ([#1667](https://github.com/atuinsh/atuin/issues/1667)) - Correct download list for incremental builds ([#1672](https://github.com/atuinsh/atuin/issues/1672)) ### Documentation - *(README)* Clarify prerequisites for Bash ([#1686](https://github.com/atuinsh/atuin/issues/1686)) - *(readme)* Add repology badge ([#1494](https://github.com/atuinsh/atuin/issues/1494)) - Add forum link to contributing ([#1498](https://github.com/atuinsh/atuin/issues/1498)) - Refer to image with multi-arch support ([#1513](https://github.com/atuinsh/atuin/issues/1513)) - Remove activity graph - Fix `Destination file already exists` in Nushell ([#1530](https://github.com/atuinsh/atuin/issues/1530)) - Clarify enter/tab usage ([#1538](https://github.com/atuinsh/atuin/issues/1538)) - Improve style ([#1537](https://github.com/atuinsh/atuin/issues/1537)) - Remove old docusaurus ([#1581](https://github.com/atuinsh/atuin/issues/1581)) - Mention environment variables for custom paths ([#1614](https://github.com/atuinsh/atuin/issues/1614)) - Create pull_request_template.md ([#1632](https://github.com/atuinsh/atuin/issues/1632)) - Update CONTRIBUTING.md ([#1633](https://github.com/atuinsh/atuin/issues/1633)) ### Features - *(bash)* Support high-resolution timing even without ble.sh ([#1534](https://github.com/atuinsh/atuin/issues/1534)) - *(search)* Introduce keymap-dependent vim-mode ([#1570](https://github.com/atuinsh/atuin/issues/1570)) - *(search)* Make cursor style configurable ([#1595](https://github.com/atuinsh/atuin/issues/1595)) - *(shell)* Bind the Atuin search to "/" in vi-normal mode ([#1629](https://github.com/atuinsh/atuin/issues/1629)) - **BREAKING**: bind the Atuin search to "/" in vi-normal mode ([#1629](https://github.com/atuinsh/atuin/issues/1629)) - *(ui)* Add redraw ([#1519](https://github.com/atuinsh/atuin/issues/1519)) - *(ui)* Vim mode ([#1553](https://github.com/atuinsh/atuin/issues/1553)) - *(ui)* When in vim-normal mode apply an alternative highlighting to the selected line ([#1574](https://github.com/atuinsh/atuin/issues/1574)) - *(zsh)* Update widget names ([#1631](https://github.com/atuinsh/atuin/issues/1631)) - Enable enhanced keyboard mode ([#1505](https://github.com/atuinsh/atuin/issues/1505)) - Rework record sync for improved reliability ([#1478](https://github.com/atuinsh/atuin/issues/1478)) - Include atuin login in secret patterns ([#1518](https://github.com/atuinsh/atuin/issues/1518)) - Make it clear what you are registering for ([#1523](https://github.com/atuinsh/atuin/issues/1523)) - Add extended help ([#1540](https://github.com/atuinsh/atuin/issues/1540)) - Add interactive command inspector ([#1296](https://github.com/atuinsh/atuin/issues/1296)) - Add better error handling for sync ([#1572](https://github.com/atuinsh/atuin/issues/1572)) - Add history rebuild ([#1575](https://github.com/atuinsh/atuin/issues/1575)) - Make deleting from the UI work with record store sync ([#1580](https://github.com/atuinsh/atuin/issues/1580)) - Add metrics counter for records downloaded ([#1584](https://github.com/atuinsh/atuin/issues/1584)) - Make store init idempotent ([#1609](https://github.com/atuinsh/atuin/issues/1609)) - Don't stop with invalid key ([#1612](https://github.com/atuinsh/atuin/issues/1612)) - Add registered and deleted metrics ([#1622](https://github.com/atuinsh/atuin/issues/1622)) - Make history list format configurable ([#1638](https://github.com/atuinsh/atuin/issues/1638)) - Add change-password command & support on server ([#1615](https://github.com/atuinsh/atuin/issues/1615)) - Automatically init history store when record sync is enabled ([#1634](https://github.com/atuinsh/atuin/issues/1634)) - Add store push ([#1649](https://github.com/atuinsh/atuin/issues/1649)) - Reencrypt/rekey local store ([#1662](https://github.com/atuinsh/atuin/issues/1662)) - Add prefers_reduced_motion flag ([#1645](https://github.com/atuinsh/atuin/issues/1645)) - Add verify command to local store - Add store purge command - Failure to decrypt history = failure to sync - Add `store push --force` - Add `store pull` - Disable auto record store init ([#1671](https://github.com/atuinsh/atuin/issues/1671)) - Add progress bars to sync and store init ([#1684](https://github.com/atuinsh/atuin/issues/1684)) ### Miscellaneous Tasks - *(ci)* Use github m1 for release builds ([#1658](https://github.com/atuinsh/atuin/issues/1658)) - *(ci)* Re-enable test cache, add separate check step ([#1663](https://github.com/atuinsh/atuin/issues/1663)) - *(ci)* Run rust build/test/check on 3 platforms ([#1675](https://github.com/atuinsh/atuin/issues/1675)) - Remove the teapot response ([#1496](https://github.com/atuinsh/atuin/issues/1496)) - Schema cleanup ([#1522](https://github.com/atuinsh/atuin/issues/1522)) - Update funding ([#1543](https://github.com/atuinsh/atuin/issues/1543)) - Make clipboard dep optional as a feature ([#1558](https://github.com/atuinsh/atuin/issues/1558)) - Add feature to allow always disable check update ([#1628](https://github.com/atuinsh/atuin/issues/1628)) - Use resolver 2, update editions + cargo ([#1635](https://github.com/atuinsh/atuin/issues/1635)) - Disable nix tests ([#1646](https://github.com/atuinsh/atuin/issues/1646)) - Set ATUIN_ variables for development in devshell ([#1653](https://github.com/atuinsh/atuin/issues/1653)) ### Refactor - *(search)* Refactor vim mode ([#1559](https://github.com/atuinsh/atuin/issues/1559)) - *(search)* Refactor handling of key inputs ([#1606](https://github.com/atuinsh/atuin/issues/1606)) - *(shell)* Refactor and localize `HISTORY => __atuin_output` ([#1535](https://github.com/atuinsh/atuin/issues/1535)) - Use enum instead of magic numbers ([#1499](https://github.com/atuinsh/atuin/issues/1499)) - String -> HistoryId ([#1512](https://github.com/atuinsh/atuin/issues/1512)) ### Styling - *(bash)* Use consistent coding style ([#1528](https://github.com/atuinsh/atuin/issues/1528)) ### Testing - Add multi-user integration tests ([#1648](https://github.com/atuinsh/atuin/issues/1648)) ### Stats - Misc improvements ([#1613](https://github.com/atuinsh/atuin/issues/1613)) ## 17.2.1 ### Bug Fixes - *(server)* Typo with default config ([#1493](https://github.com/atuinsh/atuin/issues/1493)) ## 17.2.0 ### Bug Fixes - *(bash)* Fix loss of the last output line with enter_accept ([#1463](https://github.com/atuinsh/atuin/issues/1463)) - *(bash)* Improve the support for `enter_accept` with `ble.sh` ([#1465](https://github.com/atuinsh/atuin/issues/1465)) - *(bash)* Fix small issues of `enter_accept` for the plain Bash ([#1467](https://github.com/atuinsh/atuin/issues/1467)) - *(bash)* Fix error by the use of ${PS1@P} in bash < 4.4 ([#1488](https://github.com/atuinsh/atuin/issues/1488)) - *(bash,zsh)* Fix quirks on search cancel ([#1483](https://github.com/atuinsh/atuin/issues/1483)) - *(clippy)* Ignore struct_field_names ([#1466](https://github.com/atuinsh/atuin/issues/1466)) - *(docs)* Fix typo ([#1439](https://github.com/atuinsh/atuin/issues/1439)) - *(docs)* Discord link expired - *(history)* Disallow deletion if the '--limit' flag is present ([#1436](https://github.com/atuinsh/atuin/issues/1436)) - *(import/zsh)* Zsh use a special format to escape some characters ([#1490](https://github.com/atuinsh/atuin/issues/1490)) - *(install)* Discord broken link - *(shell)* Respect ZSH's $ZDOTDIR environment variable ([#1441](https://github.com/atuinsh/atuin/issues/1441)) - *(stats)* Don't require all fields under [stats] ([#1437](https://github.com/atuinsh/atuin/issues/1437)) - *(stats)* Time now_local not working - *(zsh)* Zsh_autosuggest_strategy for no-unset environment ([#1486](https://github.com/atuinsh/atuin/issues/1486)) ### Documentation - *(readme)* Add actuated linkback - *(readme)* Fix light/dark mode logo - *(readme)* Use picture element for logo - Add link to forum - Align setup links in docs and readme ([#1446](https://github.com/atuinsh/atuin/issues/1446)) - Add Void Linux install instruction ([#1445](https://github.com/atuinsh/atuin/issues/1445)) - Add fish install script ([#1447](https://github.com/atuinsh/atuin/issues/1447)) - Correct link - Add docs for zsh-autosuggestion integration ([#1480](https://github.com/atuinsh/atuin/issues/1480)) - Remove stray character from README - Update logo ([#1481](https://github.com/atuinsh/atuin/issues/1481)) ### Features - *(bash)* Provide auto-complete source for ble.sh ([#1487](https://github.com/atuinsh/atuin/issues/1487)) - *(shell)* Support high-resolution duration if available ([#1484](https://github.com/atuinsh/atuin/issues/1484)) - Add semver checking to client requests ([#1456](https://github.com/atuinsh/atuin/issues/1456)) - Add TLS to atuin-server ([#1457](https://github.com/atuinsh/atuin/issues/1457)) - Integrate with zsh-autosuggestions ([#1479](https://github.com/atuinsh/atuin/issues/1479)) ### Miscellaneous Tasks - *(repo)* Remove issue config ([#1433](https://github.com/atuinsh/atuin/issues/1433)) - Remove issue template ([#1444](https://github.com/atuinsh/atuin/issues/1444)) ### Refactor - *(bash)* Factorize `__atuin_accept_line` ([#1476](https://github.com/atuinsh/atuin/issues/1476)) - *(bash)* Refactor and optimize `__atuin_accept_line` ([#1482](https://github.com/atuinsh/atuin/issues/1482)) ## 17.1.0 ### Bug Fixes - *(fish)* Clean up the fish script options ([#1370](https://github.com/atuinsh/atuin/issues/1370)) - *(fish)* Use fish builtins for `enter_accept` ([#1373](https://github.com/atuinsh/atuin/issues/1373)) - *(fish)* Accept multiline commands ([#1418](https://github.com/atuinsh/atuin/issues/1418)) - *(nix)* Add Appkit to the package build ([#1358](https://github.com/atuinsh/atuin/issues/1358)) - *(zsh)* Bind in the most popular modes ([#1360](https://github.com/atuinsh/atuin/issues/1360)) - *(zsh)* Only trigger up-arrow on first line ([#1359](https://github.com/atuinsh/atuin/issues/1359)) - Initial list of history in workspace mode ([#1356](https://github.com/atuinsh/atuin/issues/1356)) - Make `atuin account delete` void session + key ([#1393](https://github.com/atuinsh/atuin/issues/1393)) - New clippy lints ([#1395](https://github.com/atuinsh/atuin/issues/1395)) - Reenable enter_accept for bash ([#1408](https://github.com/atuinsh/atuin/issues/1408)) - Respect ZSH's $ZDOTDIR environment variable ([#942](https://github.com/atuinsh/atuin/issues/942)) ### Documentation - Update sync.md ([#1409](https://github.com/atuinsh/atuin/issues/1409)) - Update Arch Linux package URL in advanced-install.md ([#1407](https://github.com/atuinsh/atuin/issues/1407)) - New stats config ([#1412](https://github.com/atuinsh/atuin/issues/1412)) ### Features - *(nix)* Add a nixpkgs overlay ([#1357](https://github.com/atuinsh/atuin/issues/1357)) - Add metrics server and http metrics ([#1394](https://github.com/atuinsh/atuin/issues/1394)) - Add some metrics related to Atuin as an app ([#1399](https://github.com/atuinsh/atuin/issues/1399)) - Allow configuring stats prefix ([#1411](https://github.com/atuinsh/atuin/issues/1411)) - Allow spaces in stats prefixes ([#1414](https://github.com/atuinsh/atuin/issues/1414)) ### Miscellaneous Tasks - *(readme)* Add contributor image to README ([#1430](https://github.com/atuinsh/atuin/issues/1430)) - Update to sqlx 0.7.3 ([#1416](https://github.com/atuinsh/atuin/issues/1416)) - `cargo update` ([#1419](https://github.com/atuinsh/atuin/issues/1419)) - Update rusty_paseto and rusty_paserk ([#1420](https://github.com/atuinsh/atuin/issues/1420)) - Run dependabot weekly, not daily ([#1423](https://github.com/atuinsh/atuin/issues/1423)) - Don't group deps ([#1424](https://github.com/atuinsh/atuin/issues/1424)) - Setup git cliff ([#1431](https://github.com/atuinsh/atuin/issues/1431)) ## 17.0.1 ### Bug Fixes - *(bash)* Improve output of `enter_accept` ([#1342](https://github.com/atuinsh/atuin/issues/1342)) - *(enter_accept)* Clear old cmd snippet ([#1350](https://github.com/atuinsh/atuin/issues/1350)) - *(fish)* Improve output for `enter_accept` ([#1341](https://github.com/atuinsh/atuin/issues/1341)) ## 17.0.0 ### Bug Fixes - *(1220)* Workspace Filtermode not handled in skim engine ([#1273](https://github.com/atuinsh/atuin/issues/1273)) - *(nu)* Disable the up-arrow keybinding for Nushell ([#1329](https://github.com/atuinsh/atuin/issues/1329)) - *(nushell)* Ignore stderr messages ([#1320](https://github.com/atuinsh/atuin/issues/1320)) - *(ubuntu/arm*)* Detect non amd64 ubuntu and handle ([#1131](https://github.com/atuinsh/atuin/issues/1131)) ### Documentation - Update `workspace` config key to `workspaces` ([#1174](https://github.com/atuinsh/atuin/issues/1174)) - Document the available format options of History list command ([#1234](https://github.com/atuinsh/atuin/issues/1234)) ### Features - *(installer)* Try installing via paru for the AUR ([#1262](https://github.com/atuinsh/atuin/issues/1262)) - *(keyup)* Configure SearchMode for KeyUp invocation #1216 ([#1224](https://github.com/atuinsh/atuin/issues/1224)) - Mouse selection support ([#1209](https://github.com/atuinsh/atuin/issues/1209)) - Copy to clipboard ([#1249](https://github.com/atuinsh/atuin/issues/1249)) ### Refactor - Duplications reduced in order to align implementations of reading history files ([#1247](https://github.com/atuinsh/atuin/issues/1247)) ### Config.md - Invert mode detailed options ([#1225](https://github.com/atuinsh/atuin/issues/1225)) ## 16.0.0 ### Bug Fixes - *(docs)* List all presently documented commands ([#1140](https://github.com/atuinsh/atuin/issues/1140)) - *(docs)* Correct command overview paths ([#1145](https://github.com/atuinsh/atuin/issues/1145)) - *(server)* Teapot is a cup of coffee ([#1137](https://github.com/atuinsh/atuin/issues/1137)) - Adjust broken link to supported shells ([#1013](https://github.com/atuinsh/atuin/issues/1013)) - Fixes unix specific impl of shutdown_signal ([#1061](https://github.com/atuinsh/atuin/issues/1061)) - Nushell empty hooks ([#1138](https://github.com/atuinsh/atuin/issues/1138)) ### Features - Do not allow empty passwords durring account creation ([#1029](https://github.com/atuinsh/atuin/issues/1029)) ### Skim - Fix filtering aggregates ([#1114](https://github.com/atuinsh/atuin/issues/1114)) ## 15.0.0 ### Documentation - Fix broken links in README.md ([#920](https://github.com/atuinsh/atuin/issues/920)) - Fix "From source" `cd` command ([#937](https://github.com/atuinsh/atuin/issues/937)) ### Features - Add delete account option (attempt 2) ([#980](https://github.com/atuinsh/atuin/issues/980)) ### Miscellaneous Tasks - Uuhhhhhh crypto lol ([#805](https://github.com/atuinsh/atuin/issues/805)) - Fix participle "be ran" -> "be run" ([#939](https://github.com/atuinsh/atuin/issues/939)) ### Cwd_filter - Much like history_filter, only it applies to cwd ([#904](https://github.com/atuinsh/atuin/issues/904)) ## 14.0.0 ### Bug Fixes - *(client)* Always read session_path from settings ([#757](https://github.com/atuinsh/atuin/issues/757)) - *(installer)* Use case-insensitive comparison ([#776](https://github.com/atuinsh/atuin/issues/776)) - Many wins were broken :memo: ([#789](https://github.com/atuinsh/atuin/issues/789)) - Paste into terminal after switching modes ([#793](https://github.com/atuinsh/atuin/issues/793)) - Record negative exit codes ([#821](https://github.com/atuinsh/atuin/issues/821)) - Allow nix package to fetch dependencies from git ([#832](https://github.com/atuinsh/atuin/issues/832)) ### Documentation - *(README)* Fix activity graph link ([#753](https://github.com/atuinsh/atuin/issues/753)) ### Features - Add common default keybindings ([#719](https://github.com/atuinsh/atuin/issues/719)) - Add an inline view mode ([#648](https://github.com/atuinsh/atuin/issues/648)) - Add *Nushell* support ([#788](https://github.com/atuinsh/atuin/issues/788)) - Add github action to test the nix builds ([#833](https://github.com/atuinsh/atuin/issues/833)) ### Miscellaneous Tasks - Remove tui vendoring ([#804](https://github.com/atuinsh/atuin/issues/804)) - Use fork of skim ([#803](https://github.com/atuinsh/atuin/issues/803)) ### Nix - Add flake-compat ([#743](https://github.com/atuinsh/atuin/issues/743)) ## 13.0.0 ### Documentation - *(README)* Add static activity graph example ([#680](https://github.com/atuinsh/atuin/issues/680)) - Remove human short flag from docs, duplicate of help -h ([#663](https://github.com/atuinsh/atuin/issues/663)) - Fix typo in zh-CN/README.md ([#666](https://github.com/atuinsh/atuin/issues/666)) ### Features - *(history)* Add new flag to allow custom output format ([#662](https://github.com/atuinsh/atuin/issues/662)) ### Fish - Fix `atuin init` for the fish shell ([#699](https://github.com/atuinsh/atuin/issues/699)) ### Install.sh - Fallback to using cargo ([#639](https://github.com/atuinsh/atuin/issues/639)) ## 12.0.0 ### Documentation - Add more details about date parsing in the stats command ([#579](https://github.com/atuinsh/atuin/issues/579)) ## 0.10.0 ### Miscellaneous Tasks - Allow specifiying the limited of returned entries ([#364](https://github.com/atuinsh/atuin/issues/364)) ## 0.9.0 ### README - Add MacPorts installation instructions ([#302](https://github.com/atuinsh/atuin/issues/302)) ## 0.8.1 ### Bug Fixes - Get install.sh working on UbuntuWSL ([#260](https://github.com/atuinsh/atuin/issues/260)) ## 0.8.0 ### Bug Fixes - Resolve some issues with install.sh ([#188](https://github.com/atuinsh/atuin/issues/188)) ### Features - Login/register no longer blocking ([#216](https://github.com/atuinsh/atuin/issues/216)) ## 0.7.2 ### Bug Fixes - Dockerfile with correct glibc ([#198](https://github.com/atuinsh/atuin/issues/198)) ### Features - Allow input of credentials from stdin ([#185](https://github.com/atuinsh/atuin/issues/185)) ### Miscellaneous Tasks - Some new linting ([#201](https://github.com/atuinsh/atuin/issues/201)) - Supply pre-build docker image ([#199](https://github.com/atuinsh/atuin/issues/199)) - Add more eyre contexts ([#200](https://github.com/atuinsh/atuin/issues/200)) - Improve build times ([#213](https://github.com/atuinsh/atuin/issues/213)) ## 0.7.1 ### Features - Build individual crates ([#109](https://github.com/atuinsh/atuin/issues/109)) ## 0.6.3 ### Bug Fixes - Help text ### Features - Use directories project data dir ### Miscellaneous Tasks - Use structopt wrapper instead of building clap by hand ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at ellie@elliehuxtable.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Thank you so much for considering contributing to Atuin! We really appreciate it <3 Development dependencies 1. A rust toolchain ([rustup](https://rustup.rs) recommended) We commit to supporting the latest stable version of Rust - nothing more, nothing less, no nightly. Before working on anything, we suggest taking a copy of your Atuin data directory (`~/.local/share/atuin` on most \*nix platforms). If anything goes wrong, you can always restore it! While data directory backups are always a good idea, you can instruct Atuin to use custom path using the following environment variables: ```shell export ATUIN_RECORD_STORE_PATH=/tmp/atuin_records.db export ATUIN_DB_PATH=/tmp/atuin_dev.db export ATUIN_KV__DB_PATH=/tmp/atuin_kv.db export ATUIN_SCRIPTS__DB_PATH=/tmp/atuin_scripts.db ``` It is also recommended to update your `$PATH` so that the pre-exec scripts would use the locally built version: ```shell export PATH="./target/release:$PATH" ``` If you'd like to load a different configuration file, set `ATUIN_CONFIG_DIR` to a folder that contains your `config.toml` file: ```shell export ATUIN_CONFIG_DIR=/tmp/atuin-config/ ``` These variable exports can be added in a local `.envrc` file, read by [direnv](https://direnv.net/). ## PRs It can speed up the review cycle if you consent to maintainers pushing to your branch. This will only be in the case of small fixes or adjustments, and not anything large. If you feel OK with this, please check the box on the template! ## What to work on? Any issues labeled "bug" or "help wanted" would be fantastic, just drop a comment and feel free to ask for help! If there's anything you want to work on that isn't already an issue, either open a feature request or get in touch on the [forum](https://forum.atuin.sh)/Discord. ## Setup ``` git clone https://github.com/atuinsh/atuin cd atuin cargo build ``` ## Running When iterating on a feature, it's useful to use `cargo run` For example, if working on a search feature ``` cargo run -- search --a-new-flag ``` While iterating on the server, I find it helpful to run a new user on my system, with `sync_server` set to be `localhost`. ## Tests Our test coverage is currently not the best, but we are working on it! Generally tests live in the file next to the functionality they are testing, and are executed just with `cargo test`. ## Logging and Debugging ### Log Files Atuin writes logs to `~/.atuin/logs` unless configured otherwise. Log files are rotated daily and retained for 4 days by default: - `search.log.*` - Interactive search session logs - `daemon.log.*` - Background daemon logs ### Log Levels You can set the `ATUIN_LOG` environment variable to override log verbosity from the config file: ```shell ATUIN_LOG=debug atuin search # Enable debug logging ATUIN_LOG=trace atuin search # Enable trace logging (very verbose) ``` ### Span Timing (Performance Profiling) For performance analysis, you can capture detailed span timing data as JSON: ```shell ATUIN_SPAN=spans.json atuin search ``` This creates a JSON file with timing information for each instrumented span, including: - `time.busy` - Time actively executing code - `time.idle` - Time awaiting async operations (I/O, child tasks) The `scripts/span-table.ts` script analyzes these logs: ```shell # Summary view - shows all spans with timing stats bun scripts/span-table.ts spans.json # Detail view - shows individual calls for a specific span bun scripts/span-table.ts spans.json --detail daemon_search # Filter to specific spans bun scripts/span-table.ts spans.json --filter "search|hydrate" ``` This is useful for comparing performance between different search implementations or identifying bottlenecks. ## Migrations Be careful creating database migrations - once your database has migrated ahead of current stable, there is no going back ### Stickers We try to ship anyone contributing to Atuin a sticker! Only contributors get a shiny one. Fill out [this form](https://noteforms.com/forms/contributors-stickers) if you'd like one. ================================================ FILE: CONTRIBUTORS ================================================ 0x4A6F <0x4A6F@users.noreply.github.com> Aleks Bunin Alex Hamilton <1622250+Aehmlo@users.noreply.github.com> Alexandre GV. Aloxaf Alpha Chen Amos Bird Anderson <141751473+digital-cuttlefish@users.noreply.github.com> Andrew Aylett Andrew Lee <32912555+candrewlee14@users.noreply.github.com> Anish Pallati Austin Schey avinassh <640792+avinassh@users.noreply.github.com> Azzam S.A <17734314+azzamsa@users.noreply.github.com> b3nj5m1n <47924309+b3nj5m1n@users.noreply.github.com> Baptiste <32563450+BapRx@users.noreply.github.com> Ben J Benjamin Vergnaud <9599845+bvergnaud@users.noreply.github.com> Benjamin Weinstein-Raun Blair Noctis <4474501+nc7s@users.noreply.github.com> Brad Robel-Forrest Braelyn Boynton Brian Kung <2836167+briankung@users.noreply.github.com> Bruce Huang c-14 Caleb Maclennan Ch. (Chanwhi Choi) Chandra Kiran G chitao1234 <1139954766@qq.com> Chris Rose Conrad Ludgate CosmicHorror Cristian Le Cristian Le CULT PONY <67918945+cultpony@users.noreply.github.com> cyqsimon <28627918+cyqsimon@users.noreply.github.com> Dagan McGregor Daniel Daniel Carosone DaniPopes <57450786+DaniPopes@users.noreply.github.com> David David David Chocholatý David Jack Wange Olrik David Legrand <1110600+davlgd@users.noreply.github.com> Dennis Trautwein dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Devin Buhl Dhruv Thakur <13575379+dhth@users.noreply.github.com> Diego Carrasco Gubernatis <557703+dacog@users.noreply.github.com> Dieter Eickstaedt Dom Rodriguez Dongxu Wang DS/Charlie <82801887+ds-cbo@users.noreply.github.com> Ed Ive Edward Loveall Ellie Huxtable Emanuele Panzeri Eric Crosson Eric Hodel Eric Long Eric Ripa Erwin Kroon <123574+ekroon@users.noreply.github.com> eth3lbert Ethan Brierley Evan McBeth <64177332+AtomicRobotMan0101@users.noreply.github.com> Evan Purkhiser Farid Zakaria Felix Yan Frank Hamand frukto Gokul Hamza Hamud <53880692+hhamud@users.noreply.github.com> Helmut K. C. Tessarek Herby Gillot Hesam Pakdaman <14890379+hesampakdaman@users.noreply.github.com> Hilmar Wiegand Hunter Casten <41604962+enchantednatures@users.noreply.github.com> Ian Manske Ian Smith Ian Smith Ilkin Bayramli <43158991+ibayramli@users.noreply.github.com> Ivan Toriya <43750521+ivan-toriya@users.noreply.github.com> J. Emiliano Deustua Jakob Schrettenbrunner Jakub Jirutka Jakub Panek James Trew <66286082+jamestrew@users.noreply.github.com> Jamie Quigley Jan Larres Jannik <32144358+mozzieongit@users.noreply.github.com> Jannik Jax Young jean-santos jean-santos Jeff Gould Jeremy Cline Jeremy Cline Jerome Ducret jfmontanaro Jinn Koriech Jinna Kiisuo Joe Ardent Johannes Baiter Josef Friedrich JT <547158+jntrnr@users.noreply.github.com> Julien P Justin Su János Illés Kian-Meng Ang Kjetil Jørgensen Klas Mellbourn Koichi Murase Korvin Szanto Krithic Kumar <30691152+notjedi@users.noreply.github.com> Krut Patel Laurent le Beau-Martin <1180863+laurentlbm@users.noreply.github.com> lchausmann LeoniePhiline <22329650+LeoniePhiline@users.noreply.github.com> Luca Comellini Lucas Burns <44355502+lmburns@users.noreply.github.com> Lucas Trzesniewski Lucy Luke Baker Luke Karrys Mag Mell Manel Vilar Marcin Puc Marijan Smetko Mark Wotton Martin Indra Martin Junghanns Mat Jones Matheus Martins Matt Godbolt Matthew Berryman Matthias Beyer Matthieu LAURENT Mattias Eriksson Maurice Escher Maxim Burgerhout Maxim Uvarov mb6ockatf <104227451+mb6ockatf@users.noreply.github.com> mentalisttraceur Michael Bianco Michael Mior Michael Vincent <377567+Vynce@users.noreply.github.com> Michele Azzolari Michelle Tilley Mike Pastore Mike Tsao mmx morguldir mundry <1453314+mundry@users.noreply.github.com> Nelyah Nemo157 networkException Nico Kokonas Niklas Hambüchen noyez Omer Katz onkelT2 <126604057+onkelT2@users.noreply.github.com> Onè <43485962+c-git@users.noreply.github.com> Orhun Parmaksız P T Weir Patrick Patrick Decat Patrick Jackson Pavel Ivanov Per Modin Peter Brunner Peter Holloway Philippe Normand Philippe Normand Pierluigi <82404704+IoSonoPiero@users.noreply.github.com> Plamen Dimitrov Poliorcetics postmath printfn <1643883+printfn@users.noreply.github.com> Qiming Xu <33349132+xqm32@users.noreply.github.com> Rain Ramses Remmy Cat Stock <3317423+remmycat@users.noreply.github.com> Remo Senekowitsch Reverier Xu Richard de Boer Richard Jones <4550158+RichardDRJ@users.noreply.github.com> Richard Turner <63139+zygous@users.noreply.github.com> Robin Millette rriski Sam Edwards Sam Lanning Samson Sandro Satyarth Sampath sdr135284 <54752759+sdr135284@users.noreply.github.com> Shroomy Simon Simon Elsbrock slamp Steve Kemp Steven Xu Sven-Hendrik Haase Thomas Buckley-Houston Tobias Genannt Tobias Genannt Tobias Hunger Tom Cammann Tom Cammann Trygve Aaberge TymanWasTaken Ubiquitous Photon <39134173+UbiquitousPhoton@users.noreply.github.com> Violet Shreve Vlad Stepanov <8uk.8ak@gmail.com> Vladislav Stepanov <8uk.8ak@gmail.com> VuiMuich Webmaster At Cosmic DNA <92752640+DanielAtCosmicDNA@users.noreply.github.com> Will Fancher Wind WindSoilder winston wpbrz <61665187+wpbrz@users.noreply.github.com> Xavier Vello xfzv <78810647+xfzv@users.noreply.github.com> Yannick Ulrich Yaroslav Halchenko Yolo Yonatan Goldschmidt YummyOreo Yuvi Panda Zhanibek Adilbekov ZhiHong Li Zhizhen He éclairevoyant <848000+eclairevoyant@users.noreply.github.com> 依云 镜面王子 <153555712@qq.com> ================================================ FILE: Cargo.toml ================================================ [workspace] members = ["crates/*", "crates/atuin-nucleo/matcher", "crates/atuin-nucleo/bench"] resolver = "2" exclude = ["ui/backend", "crates/atuin-nucleo/matcher/fuzz"] [workspace.package] version = "18.13.3" authors = ["Ellie Huxtable "] rust-version = "1.94.0" license = "MIT" homepage = "https://atuin.sh" repository = "https://github.com/atuinsh/atuin" readme = "README.md" [workspace.dependencies] async-trait = "0.1.58" atuin-client = { path = "crates/atuin-client", version = "18.13.3" } atuin-common = { path = "crates/atuin-common", version = "18.13.3" } atuin-daemon = { path = "crates/atuin-daemon", version = "18.13.3" } atuin-dotfiles = { path = "crates/atuin-dotfiles", version = "18.13.3" } atuin-history = { path = "crates/atuin-history", version = "18.13.3" } atuin-kv = { path = "crates/atuin-kv", version = "18.13.3" } atuin-scripts = { path = "crates/atuin-scripts", version = "18.13.3" } atuin-server = { path = "crates/atuin-server", version = "18.13.3" } atuin-server-database = { path = "crates/atuin-server-database", version = "18.13.3" } atuin-server-postgres = { path = "crates/atuin-server-postgres", version = "18.13.3" } atuin-server-sqlite = { path = "crates/atuin-server-sqlite", version = "18.13.3" } atuin-nucleo = { path = "crates/atuin-nucleo", version = "0.6.0" } atuin-nucleo-matcher = { path = "crates/atuin-nucleo/matcher", version = "0.3.1" } base64 = "0.22" crossterm = "0.29.0" log = "0.4" time = { version = "0.3.47", features = [ "serde-human-readable", "macros", "local-offset", ] } clap = { version = "4.5.7", features = ["derive"] } config = { version = "0.15.8", default-features = false, features = ["toml"] } directories = "6.0.0" eyre = "0.6" fs-err = "3.1" interim = { version = "0.2.0", features = ["time_0_3"] } itertools = "0.14.0" rand = { version = "0.8.5", features = ["std"] } semver = "1.0.20" serde = { version = "1.0.202", features = ["derive"] } serde_json = "1.0.119" tokio = { version = "1", features = ["full"] } uuid = { version = "1.9", features = ["v4", "v7", "serde"] } whoami = "2.1.0" typed-builder = "0.18.2" pretty_assertions = "1.3.0" thiserror = "2" rustix = { version = "1.1.4", features = ["process", "fs"] } tower = "0.5" tracing = "0.1" ratatui = "0.30.0" sql-builder = "3" tempfile = { version = "3.19" } minijinja = "2.9.0" rustls = { version = "0.23", default-features = false, features = [ "ring", "std", "tls12", ] } [workspace.dependencies.tracing-subscriber] version = "0.3" features = ["ansi", "fmt", "registry", "env-filter", "json"] [workspace.dependencies.reqwest] version = "0.13" features = ["json", "rustls-no-provider", "stream"] default-features = false [workspace.dependencies.sqlx] version = "0.8" features = ["runtime-tokio-rustls", "time", "postgres", "uuid"] # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" strip = "symbols" ================================================ FILE: Dockerfile ================================================ FROM lukemathwalker/cargo-chef:latest-rust-1.94.0-slim-bookworm AS chef WORKDIR app FROM chef AS planner COPY . . RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder # Ensure working C compile setup (not installed by default in arm64 images) RUN apt update && apt install build-essential -y COPY --from=planner /app/recipe.json recipe.json RUN cargo chef cook --release --recipe-path recipe.json COPY . . RUN cargo build --release --bin atuin-server FROM debian:bookworm-20260202-slim AS runtime RUN useradd -c 'atuin user' atuin && mkdir /config && chown atuin:atuin /config # Install ca-certificates for webhooks to work RUN apt update && apt install ca-certificates -y && rm -rf /var/lib/apt/lists/* WORKDIR app USER atuin ENV TZ=Etc/UTC ENV RUST_LOG=atuin_server=info ENV ATUIN_CONFIG_DIR=/config COPY --from=builder /app/target/release/atuin-server /usr/local/bin ENTRYPOINT ["/usr/local/bin/atuin-server"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Ellie Huxtable Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Text changing depending on mode. Light: 'So light!' Dark: 'So dark!'

magical shell history


[English] | [简体中文] Atuin replaces your existing shell history with a SQLite database, and records additional context for your commands. Additionally, it provides optional and _fully encrypted_ synchronisation of your history between machines, via an Atuin server.

animated

exit code, duration, time and command shown

As well as the search UI, it can do things like this: ``` # search for all successful `make` commands, recorded after 3pm yesterday atuin search --exit 0 --after "yesterday 3pm" make ``` You may use either the server I host, or host your own! Or just don't use sync at all. As all history sync is encrypted, I couldn't access your data even if I wanted to. And I **really** don't want to. ## Features - rebind `ctrl-r` and `up` (configurable) to a full screen history search UI - store shell history in a sqlite database - back up and sync **encrypted** shell history - the same history across terminals, across sessions, and across machines - log exit code, cwd, hostname, session, command duration, etc - calculate statistics such as "most used command" - old history file is not replaced - quick-jump to previous items with Alt-\ - switch filter modes via ctrl-r; search history just from the current session, directory, or globally - enter to execute a command, tab to edit ## Documentation - [Quickstart](#quickstart) - [Install](https://docs.atuin.sh/guide/installation/) - [Setting up sync](https://docs.atuin.sh/guide/sync/) - [Import history](https://docs.atuin.sh/guide/import/) - [Basic usage](https://docs.atuin.sh/guide/basic-usage/) ## Supported Shells - zsh - bash - fish - nushell - xonsh - powershell (tier 2 support) ## Community ### Forum Atuin has a community forum, please ask here for help and support: ### IRC We're also available via #atuin on libera.chat ### Discord Atuin also has a community Discord, available [here](https://discord.gg/jR3tfchVvW) # Quickstart This will sign you up for the Atuin Cloud sync server. Everything is end-to-end encrypted, so your secrets are safe! Read more in the [docs](https://docs.atuin.sh) for an offline setup, self hosted server, and more. ``` curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh atuin register -u -e atuin import auto atuin sync ``` Then restart your shell! > [!NOTE] > > **For Bash users**: The above sets up `bash-preexec` for necessary hooks, but > `bash-preexec` has limitations. For details, please see the > [Bash](https://docs.atuin.sh/guide/installation/#installing-the-shell-plugin) > section of the shell plugin documentation. # Security If you find any security issues, we'd appreciate it if you could alert # Contributors Made with [contrib.rocks](https://contrib.rocks). [English]: ./README.md [简体中文]: ./docs-i18n/zh-CN/README.md ================================================ FILE: atuin.nix ================================================ # Atuin package definition # # This file will be similar to the package definition in nixpkgs: # https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/at/atuin/package.nix # # Helpful documentation: https://github.com/NixOS/nixpkgs/blob/master/doc/languages-frameworks/rust.section.md { lib, stdenv, installShellFiles, rustPlatform, libiconv, }: rustPlatform.buildRustPackage { name = "atuin"; src = lib.cleanSource ./.; cargoLock = { lockFile = ./Cargo.lock; # Allow dependencies to be fetched from git and avoid having to set the outputHashes manually allowBuiltinFetchGit = true; }; nativeBuildInputs = [installShellFiles]; buildInputs = lib.optionals stdenv.isDarwin [libiconv]; postInstall = '' installShellCompletion --cmd atuin \ --bash <($out/bin/atuin gen-completions -s bash) \ --fish <($out/bin/atuin gen-completions -s fish) \ --zsh <($out/bin/atuin gen-completions -s zsh) ''; doCheck = false; meta = with lib; { description = "Replacement for a shell history which records additional commands context with optional encrypted synchronization between machines"; homepage = "https://github.com/atuinsh/atuin"; license = licenses.mit; mainProgram = "atuin"; }; } ================================================ FILE: atuin.plugin.zsh ================================================ # shellcheck disable=2148,SC2168,SC1090,SC2125 local FOUND_ATUIN=$+commands[atuin] if [[ $FOUND_ATUIN -eq 1 ]]; then source <(atuin init zsh) fi ================================================ FILE: cliff.toml ================================================ # git-cliff ~ default configuration file # https://git-cliff.org/docs/configuration # # Lines starting with "#" are comments. # Configuration options are organized into tables and keys. # See documentation for more information on available options. [changelog] # changelog header header = """ # Changelog\n All notable changes to this project will be documented in this file.\n """ # template for the changelog body # https://keats.github.io/tera/docs/#introduction body = """ {% if version %}\ ## {{ version | trim_start_matches(pat="v") }} {% else %}\ ## [unreleased] {% endif %}\ {% for group, commits in commits | group_by(attribute="group") %} ### {{ group | upper_first }} {% for commit in commits | filter(attribute="scope") | sort(attribute="scope") %} - *({{commit.scope}})* {{ commit.message | upper_first }} {%- if commit.breaking %} {% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}} {%- endif -%} {%- endfor -%} {% raw %}\n{% endraw %}\ {%- for commit in commits %} {%- if commit.scope -%} {% else -%} - {{ commit.message | upper_first }} {% if commit.breaking -%} {% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}} {% endif -%} {% endif -%} {% endfor -%} {% raw %}\n{% endraw %}\ {% endfor %}\n """ # remove the leading and trailing whitespace from the template trim = true # changelog footer footer = """ """ # postprocessors postprocessors = [ { pattern = '', replace = "https://github.com/atuinsh/atuin" }, # replace repository URL ] [git] # parse the commits based on https://www.conventionalcommits.org conventional_commits = true # filter out the commits that are not conventional filter_unconventional = true # process each line of a commit as an individual commit split_commits = false # regex for preprocessing the commit messages commit_preprocessors = [ { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))" }, # replace issue numbers ] # regex for parsing and grouping commits commit_parsers = [ { message = "^feat", group = "Features" }, { message = "^fix", group = "Bug Fixes" }, { message = "^doc", group = "Documentation" }, { message = "^perf", group = "Performance" }, { message = "^refactor", group = "Refactor" }, { message = "^style", group = "Styling" }, { message = "^test", group = "Testing" }, { message = "^chore\\(release\\): prepare for", skip = true }, { message = "^chore\\(deps\\)", skip = true }, { message = "^chore\\(pr\\)", skip = true }, { message = "^chore\\(pull\\)", skip = true }, { message = "^chore|ci", group = "Miscellaneous Tasks" }, { body = ".*security", group = "Security" }, { message = "^revert", group = "Revert" }, ] # protect breaking changes from being skipped due to matching a skipping commit_parser protect_breaking_commits = false # filter out the commits that are not matched by commit parsers filter_commits = false # regex for matching git tags tag_pattern = "v[0-9].*" # regex for skipping tags skip_tags = "v0.1.0-beta.1" # regex for ignoring tags ignore_tags = "prerelease|beta|alpha" # sort the tags topologically topo_order = false # sort the commits inside sections by oldest/newest order sort_commits = "oldest" # limit the number of commits included in the changelog. # limit_commits = 42 ================================================ FILE: crates/atuin/Cargo.toml ================================================ [package] name = "atuin" edition = "2024" description = "atuin - magical shell history" readme = "./README.md" rust-version = { workspace = true } version = { workspace = true } authors = { workspace = true } license = { workspace = true } homepage = { workspace = true } repository = { workspace = true } [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/{ name }-{ target }.tar.gz" bin-dir = "{ name }-{ target }/{ bin }{ binary-ext }" pkg-fmt = "tgz" [package.metadata.deb] maintainer = "Ellie Huxtable " copyright = "2021, Ellie Huxtable " license-file = ["LICENSE"] depends = "$auto" section = "utility" [package.metadata.rpm] package = "atuin" [package.metadata.rpm.cargo] buildflags = ["--release"] [package.metadata.rpm.targets] atuin = { path = "/usr/bin/atuin" } [features] default = ["client", "sync", "clipboard", "check-update", "daemon", "ai", "hex"] client = ["atuin-client"] sync = ["atuin-client/sync"] daemon = ["atuin-client/daemon", "atuin-daemon"] ai = ["atuin-ai"] hex = ["atuin-hex"] clipboard = ["arboard"] check-update = ["atuin-client/check-update"] [dependencies] atuin-ai = { path = "../atuin-ai", version = "18.13.3", optional = true, default-features = false } atuin-client = { path = "../atuin-client", version = "18.13.3", optional = true, default-features = false } atuin-common = { workspace = true } atuin-dotfiles = { workspace = true } atuin-history = { workspace = true } atuin-daemon = { path = "../atuin-daemon", version = "18.13.3", optional = true, default-features = false } atuin-hex = { path = "../atuin-hex", version = "18.13.3", optional = true, default-features = false } atuin-scripts = { workspace = true } atuin-kv = { workspace = true } log = { workspace = true } time = { workspace = true } eyre = { workspace = true } indicatif = "0.18.0" serde = { workspace = true } serde_json = { workspace = true } crossterm = { workspace = true, features = ["use-dev-tty"] } unicode-width = "0.2" itertools = { workspace = true } tokio = { workspace = true } async-trait = { workspace = true } interim = { workspace = true } clap = { workspace = true } clap_complete = "4.5.8" clap_complete_nushell = "4.5.4" fs-err = { workspace = true } fs4 = "0.13.1" rpassword = "7.0" semver = { workspace = true } rustix = { workspace = true } runtime-format = "0.1.3" tiny-bip39 = "2" futures-util = "0.3" fuzzy-matcher = "0.3.7" colored = "2.0.4" open = "5" ratatui = { workspace = true } tracing = "0.1" tracing-subscriber = { workspace = true } tracing-appender = "0.2" uuid = { workspace = true } sysinfo = "0.30.7" regex = "1.10.5" norm = { version = "0.1.1", features = ["fzf-v2"] } atuin-nucleo-matcher = { workspace = true } tempfile = { workspace = true } shlex = "1.3.0" # settings editor with comment and relative ordering preservation toml_edit = "0.25.4" [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] arboard = { version = "3.4", optional = true } [target.'cfg(target_os = "linux")'.dependencies] arboard = { version = "3.4", optional = true, features = [ "wayland-data-control", ] } [target.'cfg(unix)'.dependencies] daemonize = "0.5.0" [dev-dependencies] tracing-tree = "0.4" # Integration tests in tests/ spin up a test server to verify sync functionality. # TODO: Consider moving these tests to atuin-server crate instead (client would become a dev dep there) atuin-server = { workspace = true } atuin-server-database = { workspace = true } atuin-server-postgres = { workspace = true } ================================================ FILE: crates/atuin/LICENSE ================================================ MIT License Copyright (c) 2021 Ellie Huxtable Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: crates/atuin/build.rs ================================================ use std::process::Command; fn main() { let output = Command::new("git").args(["rev-parse", "HEAD"]).output(); let sha = match output { Ok(sha) => String::from_utf8(sha.stdout).unwrap(), Err(_) => String::from("NO_GIT"), }; println!("cargo:rustc-env=GIT_HASH={sha}"); } ================================================ FILE: crates/atuin/src/command/client/account/change_password.rs ================================================ use clap::Parser; use eyre::{Result, bail}; use atuin_client::{ auth::{self, MutateResponse}, settings::Settings, }; use rpassword::prompt_password; #[derive(Parser, Debug)] pub struct Cmd { #[clap(long, short)] pub current_password: Option, #[clap(long, short)] pub new_password: Option, /// The two-factor authentication code for your account, if any #[clap(long, short)] pub totp_code: Option, } impl Cmd { pub async fn run(&self, settings: &Settings) -> Result<()> { if !settings.logged_in().await? { bail!("You are not logged in"); } let client = auth::auth_client(settings).await; let current_password = self.current_password.clone().unwrap_or_else(|| { prompt_password("Please enter the current password: ") .expect("Failed to read from input") }); if current_password.is_empty() { bail!("please provide the current password"); } let new_password = self.new_password.clone().unwrap_or_else(|| { prompt_password("Please enter the new password: ").expect("Failed to read from input") }); if new_password.is_empty() { bail!("please provide a new password"); } let mut totp_code = self.totp_code.clone(); loop { let response = client .change_password(¤t_password, &new_password, totp_code.as_deref()) .await?; match response { MutateResponse::Success => break, MutateResponse::TwoFactorRequired => { totp_code = Some(super::login::or_user_input(None, "two-factor code")); } } } println!("Account password successfully changed!"); Ok(()) } } ================================================ FILE: crates/atuin/src/command/client/account/delete.rs ================================================ use atuin_client::{ auth::{self, MutateResponse}, settings::Settings, }; use clap::Parser; use eyre::{Result, bail}; use super::login::{or_user_input, read_user_password}; #[derive(Parser, Debug)] pub struct Cmd { #[clap(long, short)] pub password: Option, /// The two-factor authentication code for your account, if any #[clap(long, short)] pub totp_code: Option, } impl Cmd { pub async fn run(&self, settings: &Settings) -> Result<()> { if !settings.logged_in().await? { bail!("You are not logged in"); } let client = auth::auth_client(settings).await; let password = self.password.clone().unwrap_or_else(read_user_password); if password.is_empty() { bail!("please provide your password"); } let mut totp_code = self.totp_code.clone(); loop { let response = client .delete_account(&password, totp_code.as_deref()) .await?; match response { MutateResponse::Success => break, MutateResponse::TwoFactorRequired => { totp_code = Some(or_user_input(None, "two-factor code")); } } } // Clean up sessions from meta store let meta = Settings::meta_store().await?; meta.delete_session().await?; meta.delete_hub_session().await?; println!("Your account is deleted"); Ok(()) } } ================================================ FILE: crates/atuin/src/command/client/account/link.rs ================================================ use eyre::{Result, bail}; use atuin_client::settings::Settings; pub async fn run(settings: &Settings) -> Result<()> { let meta = Settings::meta_store().await?; let cli_token = meta.session_token().await?; let hub_token = meta.hub_session_token().await?; let Some(cli_token) = cli_token else { bail!("No CLI session found. Please log in first with 'atuin login'."); }; let hub_address = settings.active_hub_endpoint().unwrap_or_default(); if hub_token.is_some() { println!("Found both Hub and CLI sessions. Linking accounts..."); } else { println!("Found CLI session but no Hub session. Logging in to Hub first..."); let session = atuin_client::hub::HubAuthSession::start(hub_address.as_ref()).await?; println!("Open this URL to authenticate with Atuin Hub:"); println!("{}", session.auth_url); let token = session .wait_for_completion( atuin_client::hub::DEFAULT_AUTH_TIMEOUT, atuin_client::hub::DEFAULT_POLL_INTERVAL, ) .await?; atuin_client::hub::save_session(&token).await?; println!("Hub authentication complete."); } atuin_client::hub::link_account(hub_address.as_ref(), &cli_token).await?; println!("Successfully linked CLI account to Hub."); Ok(()) } ================================================ FILE: crates/atuin/src/command/client/account/login.rs ================================================ use std::{io, path::PathBuf}; use clap::Parser; use eyre::{Context, Result, bail}; use tokio::{fs::File, io::AsyncWriteExt}; use atuin_client::{ auth::{self, AuthResponse}, encryption::{Key, decode_key, encode_key, load_key}, record::sqlite_store::SqliteStore, record::store::Store, settings::Settings, }; use rpassword::prompt_password; #[derive(Parser, Debug)] pub struct Cmd { #[clap(long, short)] pub username: Option, #[clap(long, short)] pub password: Option, /// The encryption key for your account #[clap(long, short)] pub key: Option, /// The two-factor authentication code for your account, if any #[clap(long, short)] pub totp_code: Option, #[clap(long, hide = true)] pub from_registration: bool, } fn get_input() -> Result { let mut input = String::new(); io::stdin().read_line(&mut input)?; Ok(input.trim_end_matches(&['\r', '\n'][..]).to_string()) } impl Cmd { pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { if settings.logged_in().await? { if settings.is_hub_sync() { println!("You are authenticated with Atuin Hub."); } else { println!("You are already logged in."); } println!("Run 'atuin logout' to log out."); return Ok(()); } if settings.is_hub_sync() { self.run_hub_login(settings, store).await } else { self.run_legacy_login(settings, store).await } } /// Hub login: use the browser OAuth flow unless all three flags /// (username, password, key) were provided for headless/CI use. async fn run_hub_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { let endpoint = settings.active_hub_endpoint().unwrap_or_default(); if let Some(username) = &self.username { // Headless login via v0 API (for CI / scripting). let client = auth::auth_client(settings).await; self.prompt_and_store_key(settings, store).await?; let password = self.password.clone().unwrap_or_else(read_user_password); let mut totp_code = self.totp_code.clone(); let session = loop { let response = client .login(username, &password, totp_code.as_deref()) .await?; match response { AuthResponse::Success { session } => break session, AuthResponse::TwoFactorRequired => { totp_code = Some(or_user_input(None, "two-factor code")); } } }; Settings::meta_store() .await? .save_hub_session(&session) .await?; } else { // Interactive login via browser OAuth flow. if self.from_registration { load_key(settings)?; } else { self.prompt_and_store_key(settings, store).await?; } self.ensure_hub_session(settings, endpoint.as_ref()).await?; } // Silently attempt to link CLI account to Hub if one exists if let Ok(cli_token) = settings.session_token().await && let Err(e) = atuin_client::hub::link_account(endpoint.as_ref(), &cli_token).await { tracing::debug!("Could not link CLI account to Hub: {}", e); } println!("Successfully authenticated with Atuin Hub."); Ok(()) } /// Legacy login: always prompt for username/password interactively /// (or accept them via flags). async fn run_legacy_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { let username = or_user_input(self.username.clone(), "username"); let password = self.password.clone().unwrap_or_else(read_user_password); self.prompt_and_store_key(settings, store).await?; let client = auth::auth_client(settings).await; let response = client.login(&username, &password, None).await?; match response { AuthResponse::Success { session } => { Settings::meta_store().await?.save_session(&session).await?; } AuthResponse::TwoFactorRequired => { // Legacy server doesn't support 2FA, so this shouldn't happen. bail!("unexpected two-factor requirement from legacy server"); } } println!("Logged in!"); Ok(()) } async fn ensure_hub_session(&self, _settings: &Settings, hub_address: &str) -> Result<()> { tracing::info!("Authenticating with Atuin Hub..."); let session = atuin_client::hub::HubAuthSession::start(hub_address).await?; println!("Open this URL to continue authenticating with Atuin Hub:"); println!("{}", session.auth_url); let token = session .wait_for_completion( atuin_client::hub::DEFAULT_AUTH_TIMEOUT, atuin_client::hub::DEFAULT_POLL_INTERVAL, ) .await?; tracing::info!("Authentication complete, saving session token"); atuin_client::hub::save_session(&token).await?; Ok(()) } async fn prompt_and_store_key(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { let key_path = settings.key_path.as_str(); let key_path = PathBuf::from(key_path); println!("IMPORTANT"); println!( "If you are already logged in on another machine, you must ensure that the key you use here is the same as the key you used there." ); println!("You can find your key by running 'atuin key' on the other machine."); println!("Do not share this key with anyone."); println!("\nRead more here: https://docs.atuin.sh/guide/sync/#login \n"); let key = or_user_input( self.key.clone(), "encryption key [blank to use existing key file]", ); // if provided, the key may be EITHER base64, or a bip mnemonic // try to normalize on base64 let key = if key.is_empty() { key } else { // try parse the key as a mnemonic... match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) { Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?, Err(err) => { match err { // assume they copied in the base64 key bip39::ErrorKind::InvalidWord(_) => key, bip39::ErrorKind::InvalidChecksum => { bail!("Key mnemonic is not valid") } bip39::ErrorKind::InvalidKeysize(_) | bip39::ErrorKind::InvalidWordLength(_) | bip39::ErrorKind::InvalidEntropyLength(_, _) => { bail!("Key is not the correct length") } } } } }; if key.is_empty() { if key_path.exists() { let bytes = fs_err::read_to_string(&key_path).context(format!( "Existing key file at '{}' could not be read", key_path.to_string_lossy() ))?; if decode_key(bytes).is_err() { bail!(format!( "The key in existing key file at '{}' is invalid", key_path.to_string_lossy() )); } } else { panic!( "No key provided and no existing key file found. Please use 'atuin key' on your other machine, or recover your key from a backup" ) } } else if !key_path.exists() { if decode_key(key.clone()).is_err() { bail!("The specified key is invalid"); } let mut file = File::create(&key_path).await?; file.write_all(key.as_bytes()).await?; } else { // we now know that the user has logged in specifying a key, AND that the key path // exists // 1. check if the saved key and the provided key match. if so, nothing to do. // 2. if not, re-encrypt the local history and overwrite the key let current_key: [u8; 32] = load_key(settings)?.into(); let encoded = key.clone(); // gonna want to save it in a bit let new_key: [u8; 32] = decode_key(key) .context("Could not decode provided key; is not valid base64-encoded key")? .into(); if new_key != current_key { println!("\nRe-encrypting local store with new key"); store.re_encrypt(¤t_key, &new_key).await?; println!("Writing new key"); let mut file = File::create(&key_path).await?; file.write_all(encoded.as_bytes()).await?; } } Ok(()) } } pub(super) fn or_user_input(value: Option, name: &'static str) -> String { value.unwrap_or_else(|| read_user_input(name)) } pub(super) fn read_user_password() -> String { let password = prompt_password("Please enter password: "); password.expect("Failed to read from input") } fn read_user_input(name: &'static str) -> String { eprint!("Please enter {name}: "); get_input().expect("Failed to read from input") } #[cfg(test)] mod tests { use atuin_client::encryption::Key; #[test] fn mnemonic_round_trip() { let key = Key::from([ 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3, 2, 3, 8, 4, 6, 2, 6, 4, 3, 3, 8, 3, 2, 7, 9, 5, ]); let phrase = bip39::Mnemonic::from_entropy(&key, bip39::Language::English) .unwrap() .into_phrase(); let mnemonic = bip39::Mnemonic::from_phrase(&phrase, bip39::Language::English).unwrap(); assert_eq!(mnemonic.entropy(), key.as_slice()); assert_eq!( phrase, "adapt amused able anxiety mother adapt beef gaze amount else seat alcohol cage lottery avoid scare alcohol cactus school avoid coral adjust catch pink" ); } } ================================================ FILE: crates/atuin/src/command/client/account/logout.rs ================================================ use eyre::Result; pub async fn run() -> Result<()> { atuin_client::logout::logout().await } ================================================ FILE: crates/atuin/src/command/client/account/register.rs ================================================ use clap::Parser; use eyre::{Result, bail}; use super::login::or_user_input; use atuin_client::{ auth::{self, AuthResponse}, record::sqlite_store::SqliteStore, settings::Settings, }; #[derive(Parser, Debug)] pub struct Cmd { #[clap(long, short)] pub username: Option, #[clap(long, short)] pub password: Option, #[clap(long, short)] pub email: Option, } impl Cmd { pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { if settings.logged_in().await? { if settings.is_hub_sync() { println!("You are already authenticated with Atuin Hub."); } else { println!("You are already logged in."); } println!("Run 'atuin logout' to log out."); return Ok(()); } if settings.is_hub_sync() { let required_for_headless = 3; let provided = [ self.username.is_some(), self.email.is_some(), self.password.is_some(), ] .iter() .filter(|&b| *b) .count(); if provided < required_for_headless { println!( "Username, password, and email are all required for headless registration. Continuing with interactive registration.\n" ); } if let (Some(username), Some(email), Some(password)) = (&self.username, &self.email, &self.password) { // Headless registration via v0 API (for CI / scripting). let client = auth::auth_client(settings).await; if password.is_empty() { bail!("please provide a password"); } let response = client.register(username, email, password).await?; match response { AuthResponse::Success { session } => { Settings::meta_store() .await? .save_hub_session(&session) .await?; } AuthResponse::TwoFactorRequired => { bail!("unexpected two-factor requirement during registration"); } } let _key = atuin_client::encryption::load_key(settings)?; println!( "Registration successful! Please make a note of your key (run 'atuin key') and keep it safe." ); println!( "You will need it to log in on other devices, and we cannot help recover it if you lose it." ); } else { // Interactive registration: delegate to the browser OAuth flow. // Registration on Hub happens on the website; the CLI just needs // to authenticate afterwards. super::login::Cmd { username: None, password: None, key: None, totp_code: None, from_registration: true, } .run(settings, store) .await?; } } else { // Legacy registration flow println!("Registering for an Atuin Sync account"); let username = or_user_input(self.username.clone(), "username"); let email = or_user_input(self.email.clone(), "email"); let password = self .password .clone() .unwrap_or_else(super::login::read_user_password); if password.is_empty() { bail!("please provide a password"); } let session = atuin_client::api_client::register( settings.sync_address.as_str(), &username, &email, &password, ) .await?; let meta = Settings::meta_store().await?; meta.save_session(&session.session).await?; let _key = atuin_client::encryption::load_key(settings)?; println!( "Registration successful! Please make a note of your key (run 'atuin key') and keep it safe." ); println!( "You will need it to log in on other devices, and we cannot help recover it if you lose it." ); } Ok(()) } } ================================================ FILE: crates/atuin/src/command/client/account.rs ================================================ use clap::{Args, Subcommand}; use eyre::Result; use atuin_client::record::sqlite_store::SqliteStore; use atuin_client::settings::Settings; pub mod change_password; pub mod delete; pub mod link; pub mod login; pub mod logout; pub mod register; #[derive(Args, Debug)] pub struct Cmd { #[command(subcommand)] command: Commands, } #[derive(Subcommand, Debug)] pub enum Commands { /// Login to the configured server Login(login::Cmd), /// Register a new account Register(register::Cmd), /// Log out Logout, /// Delete your account, and all synced data Delete(delete::Cmd), /// Change your password ChangePassword(change_password::Cmd), /// Link your CLI sync account to your Hub account Link, } impl Cmd { pub async fn run(self, settings: Settings, store: SqliteStore) -> Result<()> { match self.command { Commands::Login(l) => l.run(&settings, &store).await, Commands::Register(r) => r.run(&settings, &store).await, Commands::Logout => logout::run().await, Commands::Delete(d) => d.run(&settings).await, Commands::ChangePassword(c) => c.run(&settings).await, Commands::Link => link::run(&settings).await, } } } ================================================ FILE: crates/atuin/src/command/client/daemon.rs ================================================ use std::fs::{self, File, OpenOptions}; use std::io::{ErrorKind, Write}; #[cfg(unix)] use std::os::unix::net::UnixStream as StdUnixStream; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; use atuin_client::{ database::Sqlite, history::History, record::sqlite_store::SqliteStore, settings::Settings, }; use atuin_daemon::client::{DaemonClientErrorKind, HistoryClient, classify_error}; use clap::Subcommand; #[cfg(unix)] use daemonize::Daemonize; use eyre::{Result, WrapErr, bail, eyre}; use fs4::fs_std::FileExt; use tokio::time::sleep; #[derive(clap::Args, Debug)] pub struct Cmd { /// Internal flag for daemonization #[arg(long, hide = true)] daemonize: bool, /// Also write daemon logs to the console (useful for debugging) #[arg(long)] show_logs: bool, #[command(subcommand)] subcmd: Option, } #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub enum SubCmd { /// Start the daemon server Start { #[arg(long, hide = true)] daemonize: bool, /// Also write daemon logs to the console (useful for debugging) #[arg(long)] show_logs: bool, /// Force start: kill existing daemon process and reset the socket #[arg(long)] force: bool, }, /// Show the daemon's current status Status, /// Stop the daemon gracefully Stop, /// Restart the daemon (stop, then start in background) Restart, } impl Cmd { /// Returns `true` when the process should daemonize before creating the /// async runtime or opening any database connections. #[cfg(unix)] pub fn should_daemonize(&self) -> bool { match &self.subcmd { Some(SubCmd::Start { daemonize, .. }) => *daemonize, None => self.daemonize, _ => false, } } /// Returns `true` when logs should also be written to the console. pub fn show_logs(&self) -> bool { match &self.subcmd { Some(SubCmd::Start { show_logs, .. }) => *show_logs, None => self.show_logs, _ => false, } } pub async fn run( self, settings: Settings, store: SqliteStore, history_db: Sqlite, ) -> Result<()> { match self.subcmd { None => { eprintln!("Warning: `atuin daemon` is deprecated, use `atuin daemon start`"); run(settings, store, history_db, false).await } Some(SubCmd::Start { force, .. }) => run(settings, store, history_db, force).await, Some(SubCmd::Status) => status_cmd(&settings).await, Some(SubCmd::Stop) => stop_cmd(&settings).await, Some(SubCmd::Restart) => restart_cmd(&settings).await, } } } const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); const DAEMON_PROTOCOL_VERSION: u32 = 1; const STARTUP_POLL: Duration = Duration::from_millis(40); const LOCK_POLL: Duration = Duration::from_millis(20); const LEGACY_DAEMON_RESTART_MESSAGE: &str = "legacy daemon detected; restart daemon manually"; struct PidfileGuard { file: File, } impl PidfileGuard { fn acquire(path: &Path) -> Result { let mut file = open_lock_file(path)?; if !file.try_lock_exclusive()? { bail!( "daemon already running (pidfile lock busy at {})", path.display() ); } file.set_len(0) .wrap_err_with(|| format!("could not truncate daemon pidfile {}", path.display()))?; writeln!(file, "{}", std::process::id()) .and_then(|()| writeln!(file, "{DAEMON_VERSION}")) .wrap_err_with(|| format!("could not write daemon pidfile {}", path.display()))?; Ok(Self { file }) } } impl Drop for PidfileGuard { fn drop(&mut self) { let _ = self.file.unlock(); } } enum Probe { Ready(HistoryClient), NeedsRestart(String), Unreachable(eyre::Report), } fn daemon_matches_expected(version: &str, protocol: u32) -> bool { version == DAEMON_VERSION && protocol == DAEMON_PROTOCOL_VERSION } fn daemon_mismatch_message(version: &str, protocol: u32) -> String { if protocol == DAEMON_PROTOCOL_VERSION { format!("daemon is out of date: expected {DAEMON_VERSION}, got {version}") } else { format!("daemon protocol mismatch: expected {DAEMON_PROTOCOL_VERSION}, got {protocol}") } } fn is_legacy_daemon_error(err: &eyre::Report) -> bool { matches!(classify_error(err), DaemonClientErrorKind::Unimplemented) } fn should_retry_after_error(err: &eyre::Report) -> bool { matches!( classify_error(err), DaemonClientErrorKind::Connect | DaemonClientErrorKind::Unavailable | DaemonClientErrorKind::Unimplemented ) } fn daemon_startup_lock_path(pidfile_path: &Path) -> PathBuf { let mut os = pidfile_path.as_os_str().to_os_string(); os.push(".startup.lock"); PathBuf::from(os) } fn open_lock_file(path: &Path) -> Result { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .wrap_err_with(|| format!("could not create lock directory {}", parent.display()))?; } OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .open(path) .wrap_err_with(|| format!("could not open lock file {}", path.display())) } async fn wait_for_lock(path: &Path, timeout: Duration) -> Result { let file = open_lock_file(path)?; let start = Instant::now(); loop { match file.try_lock_exclusive() { Ok(true) => return Ok(file), Ok(false) => { if start.elapsed() >= timeout { bail!("timed out waiting for lock at {}", path.display()); } sleep(LOCK_POLL).await; } Err(err) => { return Err(eyre!("could not lock {}: {err}", path.display())); } } } } async fn wait_for_pidfile_available(path: &Path, timeout: Duration) -> Result<()> { let file = wait_for_lock(path, timeout).await?; file.unlock() .wrap_err_with(|| format!("failed to unlock {}", path.display()))?; Ok(()) } async fn connect_client(settings: &Settings) -> Result { HistoryClient::new( #[cfg(not(unix))] settings.daemon.tcp_port, #[cfg(unix)] settings.daemon.socket_path.clone(), ) .await } async fn probe(settings: &Settings) -> Probe { let mut client = match connect_client(settings).await { Ok(client) => client, Err(err) => return Probe::Unreachable(err), }; match client.status().await { Ok(status) => { if daemon_matches_expected(&status.version, status.protocol) { Probe::Ready(client) } else { Probe::NeedsRestart(daemon_mismatch_message(&status.version, status.protocol)) } } Err(err) => Probe::Unreachable(err), } } async fn request_shutdown(settings: &Settings) { if let Ok(mut client) = connect_client(settings).await { let _ = client.shutdown().await; } } fn spawn_daemon_process() -> Result<()> { let exe = std::env::current_exe().wrap_err("could not locate atuin executable")?; let mut cmd = Command::new(exe); cmd.arg("daemon") .arg("start") .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); #[cfg(unix)] cmd.arg("--daemonize"); cmd.spawn().wrap_err("failed to spawn daemon process")?; Ok(()) } fn startup_timeout(settings: &Settings) -> Duration { Duration::from_secs_f64(settings.local_timeout.max(0.5) + 2.0) } #[cfg(unix)] fn remove_stale_socket_if_present(settings: &Settings) -> Result<()> { if settings.daemon.systemd_socket { return Ok(()); } let socket_path = Path::new(&settings.daemon.socket_path); if !socket_path.exists() { return Ok(()); } match StdUnixStream::connect(socket_path) { Ok(stream) => { drop(stream); Ok(()) } Err(err) if err.kind() == ErrorKind::ConnectionRefused => { fs::remove_file(socket_path).wrap_err_with(|| { format!( "failed to remove stale daemon socket {}", socket_path.display() ) })?; Ok(()) } Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), Err(_) => Ok(()), } } async fn wait_until_ready(settings: &Settings, timeout: Duration) -> Result { let start = Instant::now(); let mut last_error = eyre!("daemon did not become ready"); loop { match probe(settings).await { Probe::Ready(client) => return Ok(client), Probe::NeedsRestart(reason) => { last_error = eyre!(reason); } Probe::Unreachable(err) => { if is_legacy_daemon_error(&err) { return Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE)); } last_error = err; } } if start.elapsed() >= timeout { return Err(last_error.wrap_err(format!( "timed out waiting for daemon startup after {}ms", timeout.as_millis() ))); } sleep(STARTUP_POLL).await; } } fn ensure_autostart_supported(settings: &Settings) -> Result<()> { #[cfg(unix)] if settings.daemon.systemd_socket { bail!( "daemon autostart is incompatible with `daemon.systemd_socket = true`; use systemd to manage the daemon" ); } #[cfg(not(unix))] let _ = settings; Ok(()) } async fn restart_daemon(settings: &Settings) -> Result { ensure_autostart_supported(settings)?; let timeout = startup_timeout(settings); let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); let startup_lock_path = daemon_startup_lock_path(&pidfile_path); let startup_lock = wait_for_lock(&startup_lock_path, timeout).await?; match probe(settings).await { Probe::Ready(client) => { drop(startup_lock); return Ok(client); } Probe::NeedsRestart(_) => { request_shutdown(settings).await; } Probe::Unreachable(err) => { if is_legacy_daemon_error(&err) { return Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE)); } } } // This prevents rapid-fire hook invocations from racing daemon restart. wait_for_pidfile_available(&pidfile_path, timeout).await?; #[cfg(unix)] remove_stale_socket_if_present(settings)?; spawn_daemon_process()?; let client = wait_until_ready(settings, timeout).await?; drop(startup_lock); Ok(client) } fn ensure_reply_compatible(settings: &Settings, version: &str, protocol: u32) -> Result<()> { if daemon_matches_expected(version, protocol) { return Ok(()); } let message = daemon_mismatch_message(version, protocol); if settings.daemon.autostart { bail!("{message}"); } bail!("{message}. Enable `daemon.autostart = true` or restart the daemon manually"); } pub async fn start_history(settings: &Settings, history: History) -> Result { match async { connect_client(settings) .await? .start_history(history.clone()) .await } .await { Ok(resp) => { if daemon_matches_expected(&resp.version, resp.protocol) { return Ok(resp.id); } if !settings.daemon.autostart { return Err(eyre!( "{}. Enable `daemon.autostart = true` or restart the daemon manually", daemon_mismatch_message(&resp.version, resp.protocol) )); } } Err(err) if !settings.daemon.autostart => return Err(err), Err(err) if !should_retry_after_error(&err) => return Err(err), Err(_) => {} } let resp = restart_daemon(settings) .await? .start_history(history) .await?; ensure_reply_compatible(settings, &resp.version, resp.protocol)?; Ok(resp.id) } pub async fn end_history(settings: &Settings, id: String, duration: u64, exit: i64) -> Result<()> { match async { connect_client(settings) .await? .end_history(id.clone(), duration, exit) .await } .await { Ok(resp) => { if daemon_matches_expected(&resp.version, resp.protocol) { return Ok(()); } if !settings.daemon.autostart { return Err(eyre!( "{}. Enable `daemon.autostart = true` or restart the daemon manually", daemon_mismatch_message(&resp.version, resp.protocol) )); } // End succeeded on the running daemon, so avoid replaying it. // We only restart to make subsequent hook calls target the expected version. let _ = restart_daemon(settings).await; return Ok(()); } Err(err) if !settings.daemon.autostart => return Err(err), Err(err) if !should_retry_after_error(&err) => return Err(err), Err(_) => {} } let resp = restart_daemon(settings) .await? .end_history(id, duration, exit) .await?; ensure_reply_compatible(settings, &resp.version, resp.protocol)?; Ok(()) } async fn status_cmd(settings: &Settings) -> Result<()> { match probe(settings).await { Probe::Ready(mut client) => { let status = client.status().await?; println!("Daemon running"); println!(" PID: {}", status.pid); println!(" Version: {}", status.version); println!(" Protocol: {}", status.protocol); println!(" Healthy: {}", status.healthy); #[cfg(unix)] println!(" Socket: {}", settings.daemon.socket_path); #[cfg(not(unix))] println!(" Port: {}", settings.daemon.tcp_port); } Probe::NeedsRestart(reason) => { println!("Daemon running (needs restart)"); println!(" Reason: {reason}"); } Probe::Unreachable(_) => { println!("Daemon is not running"); } } Ok(()) } async fn stop_cmd(settings: &Settings) -> Result<()> { let Ok(mut client) = connect_client(settings).await else { println!("Daemon is not running"); return Ok(()); }; match client.shutdown().await { Ok(true) => { println!("Shutdown requested"); let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); let timeout = Duration::from_secs(5); match wait_for_pidfile_available(&pidfile_path, timeout).await { Ok(()) => println!("Daemon stopped"), Err(_) => println!("Daemon may still be shutting down"), } Ok(()) } Ok(false) => bail!("Daemon rejected shutdown request"), Err(err) => Err(err.wrap_err("Failed to send shutdown request")), } } async fn restart_cmd(settings: &Settings) -> Result<()> { // Stop if running match probe(settings).await { Probe::Ready(_) | Probe::NeedsRestart(_) => { request_shutdown(settings).await; println!("Stopping daemon..."); let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); let timeout = Duration::from_secs(5); wait_for_pidfile_available(&pidfile_path, timeout) .await .wrap_err("Timed out waiting for old daemon to stop")?; } Probe::Unreachable(_) => { println!("No daemon running"); } } #[cfg(unix)] remove_stale_socket_if_present(settings)?; spawn_daemon_process()?; println!("Starting daemon..."); let timeout = startup_timeout(settings); let status = wait_until_ready(settings, timeout).await?.status().await?; println!("Daemon restarted"); println!(" PID: {}", status.pid); println!(" Version: {}", status.version); Ok(()) } /// Daemonize the current process. Must be called before creating the tokio /// runtime or opening database connections, since `fork()` inside an async /// runtime corrupts its internal state. #[cfg(unix)] pub fn daemonize_current_process() -> Result<()> { let cwd = std::env::current_dir().wrap_err("could not determine current directory for daemon")?; Daemonize::new() .working_directory(cwd) .start() .wrap_err("failed to daemonize process")?; Ok(()) } async fn run( settings: Settings, store: SqliteStore, history_db: Sqlite, force: bool, ) -> Result<()> { if force { force_cleanup(&settings); } let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?; atuin_daemon::boot(settings, store, history_db).await?; Ok(()) } /// Force cleanup: kill existing daemon process and remove socket. fn force_cleanup(settings: &Settings) { let pidfile_path = Path::new(&settings.daemon.pidfile_path); // Read and kill the existing process if pidfile exists if pidfile_path.exists() { if let Ok(contents) = fs::read_to_string(pidfile_path) && let Some(pid_str) = contents.lines().next() && let Ok(pid) = pid_str.parse::() { kill_process(pid); // Give it a moment to release resources std::thread::sleep(Duration::from_millis(100)); } // Remove the pidfile if let Err(e) = fs::remove_file(pidfile_path) && e.kind() != ErrorKind::NotFound { tracing::warn!("failed to remove pidfile: {e}"); } } // Remove the socket file #[cfg(unix)] { let socket_path = Path::new(&settings.daemon.socket_path); if socket_path.exists() && let Err(e) = fs::remove_file(socket_path) && e.kind() != ErrorKind::NotFound { tracing::warn!("failed to remove socket: {e}"); } } } /// Kill a process by PID. #[cfg(unix)] fn kill_process(pid: u32) { // Use kill command to send SIGTERM for graceful shutdown let _ = Command::new("kill") .args(["-TERM", &pid.to_string()]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); } /// Kill a process by PID. #[cfg(not(unix))] fn kill_process(pid: u32) { // On Windows, use taskkill let _ = Command::new("taskkill") .args(["/PID", &pid.to_string(), "/F"]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); } #[cfg(test)] mod tests { use super::*; #[test] fn test_version_matches() { assert!(daemon_matches_expected( DAEMON_VERSION, DAEMON_PROTOCOL_VERSION )); } #[test] fn test_version_mismatch() { assert!(!daemon_matches_expected("0.0.0", DAEMON_PROTOCOL_VERSION)); assert!(!daemon_matches_expected(DAEMON_VERSION, 999)); assert!(!daemon_matches_expected("0.0.0", 999)); } #[test] fn test_mismatch_message_version() { let msg = daemon_mismatch_message("0.0.0", DAEMON_PROTOCOL_VERSION); assert!(msg.contains("out of date"), "got: {msg}"); assert!(msg.contains("0.0.0")); assert!(msg.contains(DAEMON_VERSION)); } #[test] fn test_mismatch_message_protocol() { let msg = daemon_mismatch_message(DAEMON_VERSION, 999); assert!(msg.contains("protocol mismatch"), "got: {msg}"); } #[test] fn test_startup_lock_path() { let pidfile = Path::new("/tmp/atuin-daemon.pid"); let lock = daemon_startup_lock_path(pidfile); assert_eq!(lock, PathBuf::from("/tmp/atuin-daemon.pid.startup.lock")); } #[test] fn test_pidfile_guard_acquire_and_drop() { let tmp = tempfile::tempdir().unwrap(); let pidfile = tmp.path().join("daemon.pid"); { let _guard = PidfileGuard::acquire(&pidfile).unwrap(); // Guard holds an exclusive lock — on Windows other handles cannot // read the file, so we verify contents after the guard is dropped. } let contents = std::fs::read_to_string(&pidfile).unwrap(); let lines: Vec<&str> = contents.lines().collect(); assert_eq!(lines.len(), 2); assert_eq!(lines[0], std::process::id().to_string()); assert_eq!(lines[1], DAEMON_VERSION); // After guard is dropped, lock should be released — acquiring again must succeed. let _guard2 = PidfileGuard::acquire(&pidfile).unwrap(); } #[test] fn test_pidfile_guard_prevents_double_acquire() { let tmp = tempfile::tempdir().unwrap(); let pidfile = tmp.path().join("daemon.pid"); let _guard = PidfileGuard::acquire(&pidfile).unwrap(); let result = PidfileGuard::acquire(&pidfile); assert!(result.is_err()); } } ================================================ FILE: crates/atuin/src/command/client/default_config.rs ================================================ use atuin_client::settings::Settings; pub fn run() { println!("{}", Settings::example_config()); } ================================================ FILE: crates/atuin/src/command/client/doctor.rs ================================================ use std::process::Command; use std::{env, str::FromStr}; use atuin_client::database::Sqlite; use atuin_client::settings::Settings; use atuin_common::shell::{Shell, shell_name}; use atuin_common::utils; use colored::Colorize; use eyre::Result; use serde::Serialize; use sysinfo::{Disks, System, get_current_pid}; #[derive(Debug, Serialize)] struct ShellInfo { pub name: String, // best-effort, not supported on all OSes pub default: String, // Detect some shell plugins that the user has installed. // I'm just going to start with preexec/blesh pub plugins: Vec, // The preexec framework used in the current session, if Atuin is loaded. pub preexec: Option, } impl ShellInfo { // HACK ALERT! // Many of the shell vars we need to detect are not exported :( // So, we're going to run a interactive session and directly check the // variable. There's a chance this won't work, so it should not be fatal. // // Every shell we support handles `shell -ic 'command'` fn shellvar_exists(shell: &str, var: &str) -> bool { let cmd = Command::new(shell) .args([ "-ic", format!("[ -z ${var} ] || echo ATUIN_DOCTOR_ENV_FOUND").as_str(), ]) .output() .map_or(String::new(), |v| { let out = v.stdout; String::from_utf8(out).unwrap_or_default() }); cmd.contains("ATUIN_DOCTOR_ENV_FOUND") } fn detect_preexec_framework(shell: &str) -> Option { if env::var("ATUIN_SESSION").ok().is_none() { None } else if shell.starts_with("bash") || shell == "sh" { env::var("ATUIN_PREEXEC_BACKEND") .ok() .filter(|value| !value.is_empty()) .and_then(|atuin_preexec_backend| { atuin_preexec_backend.rfind(':').and_then(|pos_colon| { u32::from_str(&atuin_preexec_backend[..pos_colon]) .ok() .is_some_and(|preexec_shlvl| { env::var("SHLVL") .ok() .and_then(|shlvl| u32::from_str(&shlvl).ok()) .is_some_and(|shlvl| shlvl == preexec_shlvl) }) .then(|| atuin_preexec_backend[pos_colon + 1..].to_string()) }) }) } else { Some("built-in".to_string()) } } fn validate_plugin_blesh( _shell: &str, shell_process: &sysinfo::Process, ble_session_id: &str, ) -> Option { ble_session_id .split('/') .nth(1) .and_then(|field| u32::from_str(field).ok()) .filter(|&blesh_pid| blesh_pid == shell_process.pid().as_u32()) .map(|_| "blesh".to_string()) } pub fn plugins(shell: &str, shell_process: &sysinfo::Process) -> Vec { // consider a different detection approach if there are plugins // that don't set shell vars enum PluginShellType { Any, Bash, // Note: these are currently unused #[allow(dead_code)] Zsh, #[allow(dead_code)] Fish, #[allow(dead_code)] Nushell, #[allow(dead_code)] Xonsh, } enum PluginProbeType { EnvironmentVariable(&'static str), InteractiveShellVariable(&'static str), } type PluginValidator = fn(&str, &sysinfo::Process, &str) -> Option; let plugin_list: [( &str, PluginShellType, PluginProbeType, Option, ); 3] = [ ( "atuin", PluginShellType::Any, PluginProbeType::EnvironmentVariable("ATUIN_SESSION"), None, ), ( "blesh", PluginShellType::Bash, PluginProbeType::EnvironmentVariable("BLE_SESSION_ID"), Some(Self::validate_plugin_blesh), ), ( "bash-preexec", PluginShellType::Bash, PluginProbeType::InteractiveShellVariable("bash_preexec_imported"), None, ), ]; plugin_list .into_iter() .filter(|(_, shell_type, _, _)| match shell_type { PluginShellType::Any => true, PluginShellType::Bash => shell.starts_with("bash") || shell == "sh", PluginShellType::Zsh => shell.starts_with("zsh"), PluginShellType::Fish => shell.starts_with("fish"), PluginShellType::Nushell => shell.starts_with("nu"), PluginShellType::Xonsh => shell.starts_with("xonsh"), }) .filter_map(|(plugin, _, probe_type, validator)| -> Option { match probe_type { PluginProbeType::EnvironmentVariable(env) => { env::var(env).ok().filter(|value| !value.is_empty()) } PluginProbeType::InteractiveShellVariable(shellvar) => { ShellInfo::shellvar_exists(shell, shellvar).then_some(String::default()) } } .and_then(|value| { validator.map_or_else( || Some(plugin.to_string()), |validator| validator(shell, shell_process, &value), ) }) }) .collect() } pub fn new() -> Self { // TODO: rework to use atuin_common::Shell let sys = System::new_all(); let process = sys .process(get_current_pid().expect("Failed to get current PID")) .expect("Process with current pid does not exist"); let parent = sys .process(process.parent().expect("Atuin running with no parent!")) .expect("Process with parent pid does not exist"); let name = shell_name(Some(parent)); let plugins = ShellInfo::plugins(name.as_str(), parent); let default = Shell::default_shell().unwrap_or(Shell::Unknown).to_string(); let preexec = Self::detect_preexec_framework(name.as_str()); Self { name, default, plugins, preexec, } } } #[derive(Debug, Serialize)] struct DiskInfo { pub name: String, pub filesystem: String, } #[derive(Debug, Serialize)] struct SystemInfo { pub os: String, pub arch: String, pub version: String, pub disks: Vec, } impl SystemInfo { pub fn new() -> Self { let disks = Disks::new_with_refreshed_list(); let disks = disks .list() .iter() .map(|d| DiskInfo { name: d.name().to_os_string().into_string().unwrap(), filesystem: d.file_system().to_os_string().into_string().unwrap(), }) .collect(); Self { os: System::name().unwrap_or_else(|| "unknown".to_string()), arch: System::cpu_arch().unwrap_or_else(|| "unknown".to_string()), version: System::os_version().unwrap_or_else(|| "unknown".to_string()), disks, } } } #[derive(Debug, Serialize)] struct SyncInfo { /// Whether the main Atuin sync server is in use /// I'm just calling it Atuin Cloud for lack of a better name atm pub cloud: bool, pub records: bool, pub auto_sync: bool, pub last_sync: String, } impl SyncInfo { pub async fn new(settings: &Settings) -> Self { Self { cloud: settings.is_hub_sync(), auto_sync: settings.auto_sync, records: settings.sync.records, last_sync: Settings::last_sync() .await .map_or_else(|_| "no last sync".to_string(), |v| v.to_string()), } } } #[derive(Debug)] struct SettingPaths { db: String, record_store: String, key: String, } impl SettingPaths { pub fn new(settings: &Settings) -> Self { Self { db: settings.db_path.clone(), record_store: settings.record_store_path.clone(), key: settings.key_path.clone(), } } pub fn verify(&self) { let paths = vec![ ("ATUIN_DB_PATH", &self.db), ("ATUIN_RECORD_STORE", &self.record_store), ("ATUIN_KEY", &self.key), ]; for (path_env_var, path) in paths { if utils::broken_symlink(path) { eprintln!( "{path} (${path_env_var}) is a broken symlink. This may cause issues with Atuin." ); } } } } #[derive(Debug, Serialize)] struct AtuinInfo { pub version: String, pub commit: String, /// Whether the main Atuin sync server is in use /// I'm just calling it Atuin Cloud for lack of a better name atm pub sync: Option, pub sqlite_version: String, #[serde(skip)] // probably unnecessary to expose this pub setting_paths: SettingPaths, } impl AtuinInfo { pub async fn new(settings: &Settings) -> Self { let logged_in = settings.logged_in().await.unwrap_or(false); let sync = if logged_in { Some(SyncInfo::new(settings).await) } else { None }; let sqlite_version = match Sqlite::new("sqlite::memory:", 0.1).await { Ok(db) => db .sqlite_version() .await .unwrap_or_else(|_| "unknown".to_string()), Err(_) => "error".to_string(), }; Self { version: crate::VERSION.to_string(), commit: crate::SHA.to_string(), sync, sqlite_version, setting_paths: SettingPaths::new(settings), } } } #[derive(Debug, Serialize)] struct DoctorDump { pub atuin: AtuinInfo, pub shell: ShellInfo, pub system: SystemInfo, } impl DoctorDump { pub async fn new(settings: &Settings) -> Self { Self { atuin: AtuinInfo::new(settings).await, shell: ShellInfo::new(), system: SystemInfo::new(), } } } fn checks(info: &DoctorDump) { println!(); // spacing // let zfs_error = "[Filesystem] ZFS is known to have some issues with SQLite. Atuin uses SQLite heavily. If you are having poor performance, there are some workarounds here: https://github.com/atuinsh/atuin/issues/952".bold().red(); let bash_plugin_error = "[Shell] If you are using Bash, Atuin requires that either bash-preexec or ble.sh (>= 0.4) be installed. An older ble.sh may not be detected. so ignore this if you have ble.sh >= 0.4 set up! Read more here: https://docs.atuin.sh/guide/installation/#bash".bold().red(); let blesh_integration_error = "[Shell] Atuin and ble.sh seem to be loaded in the session, but the integration does not seem to be working. Please check the setup in .bashrc.".bold().red(); // ZFS: https://github.com/atuinsh/atuin/issues/952 if info.system.disks.iter().any(|d| d.filesystem == "zfs") { println!("{zfs_error}"); } info.atuin.setting_paths.verify(); // Shell if info.shell.name == "bash" { if !info .shell .plugins .iter() .any(|p| p == "blesh" || p == "bash-preexec") { println!("{bash_plugin_error}"); } if info.shell.plugins.iter().any(|plugin| plugin == "atuin") && info.shell.plugins.iter().any(|plugin| plugin == "blesh") && info.shell.preexec.as_ref().is_some_and(|val| val == "none") { println!("{blesh_integration_error}"); } } } pub async fn run(settings: &Settings) -> Result<()> { println!("{}", "Atuin Doctor".bold()); println!("Checking for diagnostics"); let dump = DoctorDump::new(settings).await; checks(&dump); let dump = serde_json::to_string_pretty(&dump)?; println!("\nPlease include the output below with any bug reports or issues\n"); println!("{dump}"); Ok(()) } ================================================ FILE: crates/atuin/src/command/client/dotfiles/alias.rs ================================================ use clap::{Subcommand, ValueEnum}; use eyre::{Context, Result, eyre}; use atuin_client::{encryption, record::sqlite_store::SqliteStore, settings::Settings}; use atuin_dotfiles::{shell::Alias, store::AliasStore}; #[derive(Clone, Copy, Debug, Default, ValueEnum)] pub enum SortBy { /// Sort by alias name #[default] Name, /// Sort by alias value Value, } #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub enum Cmd { /// Set an alias Set { name: String, value: String }, /// Delete an alias Delete { name: String }, /// List all aliases List { /// Sort results by field #[arg(long, value_enum, default_value_t = SortBy::Name)] sort_by: SortBy, /// Sort in reverse (descending) order #[arg(long, short)] reverse: bool, /// Filter aliases by name (substring match) #[arg(long, short)] name: Option, /// Filter aliases by value (substring match) #[arg(long, short)] value: Option, }, /// Delete all aliases Clear, // There are too many edge cases to parse at the moment. Disable for now. // Import, } impl Cmd { async fn set(&self, store: &AliasStore, name: String, value: String) -> Result<()> { let illegal_char = regex::Regex::new("[ \t\n&();<>|\\\"'`$/]").unwrap(); if illegal_char.is_match(name.as_str()) { return Err(eyre!("Illegal character in alias name")); } let aliases = store.aliases().await?; let found: Vec = aliases.into_iter().filter(|a| a.name == name).collect(); if found.is_empty() { println!("Aliasing '{name}={value}'."); } else { println!( "Overwriting alias '{name}={}' with '{name}={value}'.", found[0].value ); } store.set(&name, &value).await?; Ok(()) } async fn list( &self, store: &AliasStore, sort_by: SortBy, reverse: bool, name_filter: Option, value_filter: Option, ) -> Result<()> { let mut aliases = store.aliases().await?; // Apply filters if let Some(ref name_pattern) = name_filter { let pattern = name_pattern.to_lowercase(); aliases.retain(|a| a.name.to_lowercase().contains(&pattern)); } if let Some(ref value_pattern) = value_filter { let pattern = value_pattern.to_lowercase(); aliases.retain(|a| a.value.to_lowercase().contains(&pattern)); } // Apply sorting match sort_by { SortBy::Name => { aliases.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); } SortBy::Value => { aliases.sort_by(|a, b| a.value.to_lowercase().cmp(&b.value.to_lowercase())); } } // Apply reverse if requested if reverse { aliases.reverse(); } for i in aliases { println!("{}={}", i.name, i.value); } Ok(()) } async fn clear(&self, store: &AliasStore) -> Result<()> { let aliases = store.aliases().await?; for i in aliases { self.delete(store, i.name).await?; } Ok(()) } async fn delete(&self, store: &AliasStore, name: String) -> Result<()> { let mut aliases = store.aliases().await?.into_iter(); if let Some(alias) = aliases.find(|alias| alias.name == name) { println!("Deleting '{name}={}'.", alias.value); store.delete(&name).await?; } else { eprintln!("Cannot delete '{name}': Alias not set."); } Ok(()) } /* async fn import(&self, store: &AliasStore) -> Result<()> { let aliases = atuin_dotfiles::shell::import_aliases(store).await?; for i in aliases { println!("Importing {}={}", i.name, i.value); } Ok(()) } */ pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { if !settings.dotfiles.enabled { eprintln!( "Dotfiles are not enabled. Add\n\n[dotfiles]\nenabled = true\n\nto your configuration file to enable them.\n" ); eprintln!("The default configuration file is located at ~/.config/atuin/config.toml."); return Ok(()); } let encryption_key: [u8; 32] = encryption::load_key(settings) .context("could not load encryption key")? .into(); let host_id = Settings::host_id().await?; let alias_store = AliasStore::new(store, host_id, encryption_key); match self { Self::Set { name, value } => self.set(&alias_store, name.clone(), value.clone()).await, Self::Delete { name } => self.delete(&alias_store, name.clone()).await, Self::List { sort_by, reverse, name, value, } => { self.list( &alias_store, *sort_by, *reverse, name.clone(), value.clone(), ) .await } Self::Clear => self.clear(&alias_store).await, } } } ================================================ FILE: crates/atuin/src/command/client/dotfiles/var.rs ================================================ use clap::{Subcommand, ValueEnum}; use eyre::{Context, Result}; use atuin_client::{encryption, record::sqlite_store::SqliteStore, settings::Settings}; use atuin_dotfiles::{shell::Var, store::var::VarStore}; #[derive(Clone, Copy, Debug, Default, ValueEnum)] pub enum SortBy { /// Sort by variable name #[default] Name, /// Sort by variable value Value, } #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub enum Cmd { /// Set a variable Set { name: String, value: String, #[clap(long, short, action)] no_export: bool, }, /// Delete a variable Delete { name: String }, /// List all variables List { /// Sort results by field #[arg(long, value_enum, default_value_t = SortBy::Name)] sort_by: SortBy, /// Sort in reverse (descending) order #[arg(long, short)] reverse: bool, /// Filter variables by name (substring match) #[arg(long, short)] name: Option, /// Filter variables by value (substring match) #[arg(long, short)] value: Option, /// Show only exported variables #[arg(long, conflicts_with = "shell_only")] exports_only: bool, /// Show only non-exported (shell) variables #[arg(long, conflicts_with = "exports_only")] shell_only: bool, }, } impl Cmd { async fn set(&self, store: VarStore, name: String, value: String, export: bool) -> Result<()> { let vars = store.vars().await?; let found: Vec = vars.into_iter().filter(|a| a.name == name).collect(); let show_export = if export { "export " } else { "" }; if found.is_empty() { println!("Setting '{show_export}{name}={value}'."); } else { println!( "Overwriting var '{show_export}{name}={}' with '{name}={value}'.", found[0].value ); } store.set(&name, &value, export).await?; Ok(()) } #[allow(clippy::too_many_arguments)] async fn list( &self, store: VarStore, sort_by: SortBy, reverse: bool, name_filter: Option, value_filter: Option, exports_only: bool, shell_only: bool, ) -> Result<()> { let mut vars = store.vars().await?; // Apply export/shell filters if exports_only { vars.retain(|v| v.export); } if shell_only { vars.retain(|v| !v.export); } // Apply name/value filters if let Some(ref name_pattern) = name_filter { let pattern = name_pattern.to_lowercase(); vars.retain(|v| v.name.to_lowercase().contains(&pattern)); } if let Some(ref value_pattern) = value_filter { let pattern = value_pattern.to_lowercase(); vars.retain(|v| v.value.to_lowercase().contains(&pattern)); } // Apply sorting match sort_by { SortBy::Name => { vars.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); } SortBy::Value => { vars.sort_by(|a, b| a.value.to_lowercase().cmp(&b.value.to_lowercase())); } } // Apply reverse if requested if reverse { vars.reverse(); } for i in vars { if i.export { println!("export {}={}", i.name, i.value); } else { println!("{}={}", i.name, i.value); } } Ok(()) } async fn delete(&self, store: VarStore, name: String) -> Result<()> { let mut vars = store.vars().await?.into_iter(); if let Some(var) = vars.find(|var| var.name == name) { println!("Deleting '{name}={}'.", var.value); store.delete(&name).await?; } else { eprintln!("Cannot delete '{name}': Var not set."); } Ok(()) } pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { if !settings.dotfiles.enabled { eprintln!( "Dotfiles are not enabled. Add\n\n[dotfiles]\nenabled = true\n\nto your configuration file to enable them.\n" ); eprintln!("The default configuration file is located at ~/.config/atuin/config.toml."); return Ok(()); } let encryption_key: [u8; 32] = encryption::load_key(settings) .context("could not load encryption key")? .into(); let host_id = Settings::host_id().await?; let var_store = VarStore::new(store, host_id, encryption_key); match self { Self::Set { name, value, no_export, } => { self.set(var_store, name.clone(), value.clone(), !no_export) .await } Self::Delete { name } => self.delete(var_store, name.clone()).await, Self::List { sort_by, reverse, name, value, exports_only, shell_only, } => { self.list( var_store, *sort_by, *reverse, name.clone(), value.clone(), *exports_only, *shell_only, ) .await } } } } ================================================ FILE: crates/atuin/src/command/client/dotfiles.rs ================================================ use clap::Subcommand; use eyre::Result; use atuin_client::{record::sqlite_store::SqliteStore, settings::Settings}; mod alias; mod var; #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub enum Cmd { /// Manage shell aliases with Atuin #[command(subcommand)] Alias(alias::Cmd), /// Manage shell and environment variables with Atuin #[command(subcommand)] Var(var::Cmd), } impl Cmd { pub async fn run(self, settings: &Settings, store: SqliteStore) -> Result<()> { match self { Self::Alias(cmd) => cmd.run(settings, store).await, Self::Var(cmd) => cmd.run(settings, store).await, } } } ================================================ FILE: crates/atuin/src/command/client/history.rs ================================================ use std::{ fmt::{self, Display}, io::{self, IsTerminal, Write}, path::PathBuf, time::Duration, }; use atuin_common::utils::{self, Escapable as _}; use clap::Subcommand; use eyre::{Context, Result}; use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt}; #[cfg(feature = "daemon")] use atuin_daemon::emit_event; use atuin_client::{ database::{Database, Sqlite, current_context}, encryption, history::{History, store::HistoryStore}, record::sqlite_store::SqliteStore, settings::{ FilterMode::{Directory, Global, Session}, Settings, Timezone, }, }; #[cfg(feature = "sync")] use atuin_client::{record, sync}; use log::{debug, warn}; use time::{OffsetDateTime, macros::format_description}; #[cfg(feature = "daemon")] use super::daemon; use super::search::format_duration_into; #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub enum Cmd { /// Begins a new command in the history Start { /// Collects the command from the `ATUIN_COMMAND_LINE` environment variable, /// which does not need escaping and is more compatible between OS and shells #[arg(long = "command-from-env", hide = true)] cmd_env: bool, /// Author of this command, eg `ellie`, `claude`, or `copilot` #[arg(long)] author: Option, /// Optional intent/rationale for running this command #[arg(long)] intent: Option, command: Vec, }, /// Finishes a new command in the history (adds time, exit code) End { id: String, #[arg(long, short)] exit: i64, #[arg(long, short)] duration: Option, }, /// List all items in history List { #[arg(long, short)] cwd: bool, #[arg(long, short)] session: bool, #[arg(long)] human: bool, /// Show only the text of the command #[arg(long)] cmd_only: bool, /// Terminate the output with a null, for better multiline support #[arg(long)] print0: bool, #[arg(long, short, default_value = "true")] // accept no value #[arg(num_args(0..=1), default_missing_value("true"))] // accept a value #[arg(action = clap::ArgAction::Set)] reverse: bool, /// Display the command time in another timezone other than the configured default. /// /// This option takes one of the following kinds of values: /// - the special value "local" (or "l") which refers to the system time zone /// - an offset from UTC (e.g. "+9", "-2:30") #[arg(long, visible_alias = "tz")] timezone: Option, /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {exit}, {time}, {session}, and {uuid} /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" #[arg(long, short)] format: Option, }, /// Get the last command ran Last { #[arg(long)] human: bool, /// Show only the text of the command #[arg(long)] cmd_only: bool, /// Display the command time in another timezone other than the configured default. /// /// This option takes one of the following kinds of values: /// - the special value "local" (or "l") which refers to the system time zone /// - an offset from UTC (e.g. "+9", "-2:30") #[arg(long, visible_alias = "tz")] timezone: Option, /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {time}, {session}, {uuid} and {relativetime}. /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" #[arg(long, short)] format: Option, }, InitStore, /// Delete history entries matching the configured exclusion filters Prune { /// List matching history lines without performing the actual deletion. #[arg(short = 'n', long)] dry_run: bool, }, /// Delete duplicate history entries (that have the same command, cwd and hostname) Dedup { /// List matching history lines without performing the actual deletion. #[arg(short = 'n', long)] dry_run: bool, /// Only delete results added before this date #[arg(long, short)] before: String, /// How many recent duplicates to keep #[arg(long)] dupkeep: u32, }, } #[derive(Clone, Copy, Debug)] pub enum ListMode { Human, CmdOnly, Regular, } impl ListMode { pub const fn from_flags(human: bool, cmd_only: bool) -> Self { if human { ListMode::Human } else if cmd_only { ListMode::CmdOnly } else { ListMode::Regular } } } #[allow(clippy::cast_sign_loss)] pub fn print_list( h: &[History], list_mode: ListMode, format: Option<&str>, print0: bool, reverse: bool, tz: Timezone, ) { let w = std::io::stdout(); let mut w = w.lock(); let fmt_str = match list_mode { ListMode::Human => format .unwrap_or("{time} · {duration}\t{command}") .replace("\\t", "\t"), ListMode::Regular => format .unwrap_or("{time}\t{command}\t{duration}") .replace("\\t", "\t"), // not used ListMode::CmdOnly => String::new(), }; let parsed_fmt = match list_mode { ListMode::Human | ListMode::Regular => parse_fmt(&fmt_str), ListMode::CmdOnly => std::iter::once(ParseSegment::Key("command")).collect(), }; let iterator = if reverse { Box::new(h.iter().rev()) as Box> } else { Box::new(h.iter()) as Box> }; let entry_terminator = if print0 { "\0" } else { "\n" }; let flush_each_line = print0; for history in iterator { let fh = FmtHistory { history, cmd_format: CmdFormat::for_output(&w), tz: &tz, }; let args = parsed_fmt.with_args(&fh); // Check for formatting errors before attempting to write if let Err(err) = args.status() { eprintln!("ERROR: history output failed with: {err}"); std::process::exit(1); } let write_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { write!(w, "{args}{entry_terminator}") })); match write_result { Ok(Ok(())) => { // Write succeeded } Ok(Err(err)) => { if err.kind() != io::ErrorKind::BrokenPipe { eprintln!("ERROR: Failed to write history output: {err}"); std::process::exit(1); } } Err(_) => { eprintln!("ERROR: Format string caused a formatting error."); eprintln!( "This may be due to an unsupported format string containing special characters." ); eprintln!( "Please check your format string syntax and ensure literal braces are properly escaped." ); std::process::exit(1); } } if flush_each_line { check_for_write_errors(w.flush()); } } if !flush_each_line { check_for_write_errors(w.flush()); } } fn check_for_write_errors(write: Result<(), io::Error>) { if let Err(err) = write { // Ignore broken pipe (issue #626) if err.kind() != io::ErrorKind::BrokenPipe { eprintln!("ERROR: History output failed with the following error: {err}"); std::process::exit(1); } } } /// Type wrapper around `History` with formatting settings. #[derive(Clone, Copy, Debug)] struct FmtHistory<'a> { history: &'a History, cmd_format: CmdFormat, tz: &'a Timezone, } #[derive(Clone, Copy, Debug)] enum CmdFormat { Literal, Escaped, } impl CmdFormat { fn for_output(out: &O) -> Self { if out.is_terminal() { Self::Escaped } else { Self::Literal } } } static TIME_FMT: &[time::format_description::FormatItem<'static>] = format_description!("[year]-[month]-[day] [hour repr:24]:[minute]:[second]"); /// defines how to format the history impl FormatKey for FmtHistory<'_> { #[allow(clippy::cast_sign_loss)] fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> { match key { "command" => match self.cmd_format { CmdFormat::Literal => f.write_str(self.history.command.trim()), CmdFormat::Escaped => f.write_str(&self.history.command.trim().escape_control()), }?, "directory" => f.write_str(self.history.cwd.trim())?, "exit" => f.write_str(&self.history.exit.to_string())?, "duration" => { let dur = Duration::from_nanos(std::cmp::max(self.history.duration, 0) as u64); format_duration_into(dur, f)?; } "time" => { self.history .timestamp .to_offset(self.tz.0) .format(TIME_FMT) .map_err(|_| fmt::Error)? .fmt(f)?; } "relativetime" => { let since = OffsetDateTime::now_utc() - self.history.timestamp; let d = Duration::try_from(since).unwrap_or_default(); format_duration_into(d, f)?; } "host" => f.write_str( self.history .hostname .split_once(':') .map_or(&self.history.hostname, |(host, _)| host), )?, "author" => f.write_str(&self.history.author)?, "intent" => f.write_str(self.history.intent.as_deref().unwrap_or_default())?, "user" => f.write_str( self.history .hostname .split_once(':') .map_or("", |(_, user)| user), )?, "session" => f.write_str(&self.history.session)?, "uuid" => f.write_str(&self.history.id.0)?, _ => return Err(FormatKeyError::UnknownKey), } Ok(()) } } fn parse_fmt(format: &str) -> ParsedFmt<'_> { match ParsedFmt::new(format) { Ok(fmt) => fmt, Err(err) => { eprintln!("ERROR: History formatting failed with the following error: {err}"); if format.contains('"') && (format.contains(":{") || format.contains(",{")) { eprintln!("It looks like you're trying to create JSON output."); eprintln!("For JSON, you need to escape literal braces by doubling them:"); eprintln!("Example: '{{\"command\":\"{{command}}\",\"time\":\"{{time}}\"}}'"); } else { eprintln!( "If your formatting string contains literal curly braces, you need to escape them by doubling:" ); eprintln!("Use {{{{ for literal {{ and }}}} for literal }}"); } std::process::exit(1) } } } impl Cmd { fn apply_start_metadata(history: &mut History, author: Option<&str>, intent: Option<&str>) { if let Some(author) = author.map(str::trim).filter(|author| !author.is_empty()) { author.clone_into(&mut history.author); } if let Some(intent) = intent.map(str::trim).filter(|intent| !intent.is_empty()) { history.intent = Some(intent.to_owned()); } else if intent.is_some() { history.intent = None; } } #[allow(clippy::too_many_lines, clippy::cast_possible_truncation)] async fn handle_start( db: &impl Database, settings: &Settings, command: &str, author: Option<&str>, intent: Option<&str>, ) -> Result<()> { // It's better for atuin to silently fail here and attempt to // store whatever is ran, than to throw an error to the terminal let cwd = utils::get_current_dir(); let mut h: History = History::capture() .timestamp(OffsetDateTime::now_utc()) .command(command) .cwd(cwd) .build() .into(); Self::apply_start_metadata(&mut h, author, intent); if !h.should_save(settings) { return Ok(()); } // print the ID // we use this as the key for calling end println!("{}", h.id); // Silently ignore database errors to avoid breaking the shell // This is important when disk is full or database is locked if let Err(e) = db.save(&h).await { debug!("failed to save history: {e}"); } Ok(()) } #[cfg(feature = "daemon")] async fn handle_daemon_start( settings: &Settings, command: &str, author: Option<&str>, intent: Option<&str>, ) -> Result<()> { // It's better for atuin to silently fail here and attempt to // store whatever is ran, than to throw an error to the terminal let cwd = utils::get_current_dir(); let mut h: History = History::capture() .timestamp(OffsetDateTime::now_utc()) .command(command) .cwd(cwd) .build() .into(); Self::apply_start_metadata(&mut h, author, intent); if !h.should_save(settings) { return Ok(()); } // Attempt to start history via daemon, but silently ignore errors // to avoid breaking the shell when the daemon is unavailable or disk is full let resp = match daemon::start_history(settings, h.clone()).await { Ok(id) => id, Err(e) => { debug!("failed to start history via daemon: {e}"); h.id.0.clone() } }; // print the ID // we use this as the key for calling end println!("{resp}"); Ok(()) } #[allow(unused_variables)] async fn handle_end( db: &impl Database, store: SqliteStore, history_store: HistoryStore, settings: &Settings, id: &str, exit: i64, duration: Option, ) -> Result<()> { if id.trim() == "" { return Ok(()); } let Some(mut h) = db.load(id).await? else { warn!("history entry is missing"); return Ok(()); }; if h.duration > 0 { debug!("cannot end history - already has duration"); // returning OK as this can occur if someone Ctrl-c a prompt return Ok(()); } if !settings.store_failed && exit > 0 { debug!("history has non-zero exit code, and store_failed is false"); // the history has already been inserted half complete. remove it db.delete(h).await?; return Ok(()); } h.exit = exit; h.duration = match duration { Some(value) => i64::try_from(value).context("command took over 292 years")?, None => i64::try_from((OffsetDateTime::now_utc() - h.timestamp).whole_nanoseconds()) .context("command took over 292 years")?, }; db.update(&h).await?; history_store.push(h).await?; if settings.should_sync().await? { #[cfg(feature = "sync")] { if settings.sync.records { let (_, downloaded) = record::sync::sync(settings, &store).await?; Settings::save_sync_time().await?; crate::sync::build(settings, &store, db, Some(&downloaded)).await?; } else { debug!("running periodic background sync"); sync::sync(settings, false, db).await?; } } #[cfg(not(feature = "sync"))] debug!("not compiled with sync support"); } else { debug!("sync disabled! not syncing"); } Ok(()) } #[cfg(feature = "daemon")] async fn handle_daemon_end( settings: &Settings, id: &str, exit: i64, duration: Option, ) -> Result<()> { daemon::end_history(settings, id.to_string(), duration.unwrap_or(0), exit).await?; Ok(()) } #[allow(clippy::too_many_arguments)] #[allow(clippy::fn_params_excessive_bools)] async fn handle_list( db: &impl Database, settings: &Settings, context: atuin_client::database::Context, session: bool, cwd: bool, mode: ListMode, format: Option, include_deleted: bool, print0: bool, reverse: bool, tz: Timezone, ) -> Result<()> { let filters = match (session, cwd) { (true, true) => [Session, Directory], (true, false) => [Session, Global], (false, true) => [Global, Directory], (false, false) => [ settings.default_filter_mode(context.git_root.is_some()), Global, ], }; let history = db .list(&filters, &context, None, false, include_deleted) .await?; print_list( &history, mode, match format { None => Some(settings.history_format.as_str()), _ => format.as_deref(), }, print0, reverse, tz, ); Ok(()) } async fn handle_prune( db: &impl Database, settings: &Settings, store: SqliteStore, context: atuin_client::database::Context, dry_run: bool, ) -> Result<()> { // Grab all executed commands and filter them using History::should_save. // We could iterate or paginate here if memory usage becomes an issue. let matches: Vec = db .list(&[Global], &context, None, false, false) .await? .into_iter() .filter(|h| !h.should_save(settings)) .collect(); match matches.len() { 0 => { println!("No entries to prune."); return Ok(()); } 1 => println!("Found 1 entry to prune."), n => println!("Found {n} entries to prune."), } if dry_run { print_list( &matches, ListMode::Human, Some(settings.history_format.as_str()), false, false, settings.timezone, ); } else { let encryption_key: [u8; 32] = encryption::load_key(settings) .context("could not load encryption key")? .into(); let host_id = Settings::host_id().await?; let history_store = HistoryStore::new(store.clone(), host_id, encryption_key); for entry in matches { eprintln!("deleting {}", entry.id); if settings.sync.records { let (id, _) = history_store.delete(entry.id.clone()).await?; history_store.incremental_build(db, &[id]).await?; } else { db.delete(entry.clone()).await?; } } #[cfg(feature = "daemon")] let _ = emit_event(atuin_daemon::DaemonEvent::HistoryPruned).await; } Ok(()) } async fn handle_dedup( db: &impl Database, settings: &Settings, store: SqliteStore, before: i64, dupkeep: u32, dry_run: bool, ) -> Result<()> { if dupkeep == 0 { eprintln!( "\"--dupkeep 0\" would keep 0 copies of duplicate commands and thus delete all of them! Use \"atuin search --delete ...\" if you really want that." ); std::process::exit(1); } let matches: Vec = db.get_dups(before, dupkeep).await?; match matches.len() { 0 => { println!("No duplicates to delete."); return Ok(()); } 1 => println!("Found 1 duplicate to delete."), n => println!("Found {n} duplicates to delete."), } if dry_run { print_list( &matches, ListMode::Human, Some(settings.history_format.as_str()), false, false, settings.timezone, ); } else { let encryption_key: [u8; 32] = encryption::load_key(settings) .context("could not load encryption key")? .into(); let host_id = Settings::host_id().await?; let history_store = HistoryStore::new(store.clone(), host_id, encryption_key); #[cfg(feature = "daemon")] let ids = matches.iter().map(|h| h.id.clone()).collect::>(); for entry in matches { eprintln!("deleting {}", entry.id); if settings.sync.records { let (id, _) = history_store.delete(entry.id).await?; history_store.incremental_build(db, &[id]).await?; } else { db.delete(entry).await?; } } #[cfg(feature = "daemon")] let _ = emit_event(atuin_daemon::DaemonEvent::HistoryDeleted { ids }).await; } Ok(()) } pub async fn run(self, settings: &Settings) -> Result<()> { let context = current_context().await?; #[cfg(feature = "daemon")] // Skip initializing any databases for start/end, if the daemon is enabled if settings.daemon.enabled { match self { Self::Start { .. } => { let command = self.get_start_command().unwrap_or_default(); let (author, intent) = self.get_start_metadata().unwrap_or_default(); return Self::handle_daemon_start(settings, &command, author, intent).await; } Self::End { id, exit, duration } => { return Self::handle_daemon_end(settings, &id, exit, duration).await; } _ => {} } } let db_path = PathBuf::from(settings.db_path.as_str()); let record_store_path = PathBuf::from(settings.record_store_path.as_str()); let db = Sqlite::new(db_path, settings.local_timeout).await?; let store = SqliteStore::new(record_store_path, settings.local_timeout).await?; let encryption_key: [u8; 32] = encryption::load_key(settings) .context("could not load encryption key")? .into(); let host_id = Settings::host_id().await?; let history_store = HistoryStore::new(store.clone(), host_id, encryption_key); match self { Self::Start { .. } => { let command = self.get_start_command().unwrap_or_default(); let (author, intent) = self.get_start_metadata().unwrap_or_default(); Self::handle_start(&db, settings, &command, author, intent).await } Self::End { id, exit, duration } => { Self::handle_end(&db, store, history_store, settings, &id, exit, duration).await } Self::List { session, cwd, human, cmd_only, print0, reverse, timezone, format, } => { let mode = ListMode::from_flags(human, cmd_only); let tz = timezone.unwrap_or(settings.timezone); Self::handle_list( &db, settings, context, session, cwd, mode, format, false, print0, reverse, tz, ) .await } Self::Last { human, cmd_only, timezone, format, } => { let last = db.last().await?; let last = last.as_slice(); let tz = timezone.unwrap_or(settings.timezone); print_list( last, ListMode::from_flags(human, cmd_only), match format { None => Some(settings.history_format.as_str()), _ => format.as_deref(), }, false, true, tz, ); Ok(()) } Self::InitStore => history_store.init_store(&db).await, Self::Prune { dry_run } => { Self::handle_prune(&db, settings, store, context, dry_run).await } Self::Dedup { dry_run, before, dupkeep, } => { let before = i64::try_from( interim::parse_date_string( before.as_str(), OffsetDateTime::now_utc(), interim::Dialect::Uk, )? .unix_timestamp_nanos(), )?; Self::handle_dedup(&db, settings, store, before, dupkeep, dry_run).await } } } /// Returns the command line to use for the `Start` variant. /// Returns `None` for any other variant. fn get_start_command(&self) -> Option { match self { Self::Start { cmd_env: true, .. } => { Some(std::env::var("ATUIN_COMMAND_LINE").unwrap_or_default()) } Self::Start { command, .. } => Some(command.join(" ")), _ => None, } } /// Returns `(author, intent)` for the `Start` variant. /// Returns `None` for any other variant. fn get_start_metadata(&self) -> Option<(Option<&str>, Option<&str>)> { match self { Self::Start { author, intent, .. } => Some((author.as_deref(), intent.as_deref())), _ => None, } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_format_string_no_panic() { // Don't panic but provide helpful output (issue #2776) let malformed_json = r#"{"command":"{command}","key":"value"}"#; let result = std::panic::catch_unwind(|| parse_fmt(malformed_json)); assert!(result.is_ok()); } #[test] fn test_valid_formats_still_work() { assert!(std::panic::catch_unwind(|| parse_fmt("{command}")).is_ok()); assert!(std::panic::catch_unwind(|| parse_fmt("{time} - {command}")).is_ok()); } } ================================================ FILE: crates/atuin/src/command/client/import.rs ================================================ use std::env; use async_trait::async_trait; use clap::Parser; use eyre::Result; use indicatif::ProgressBar; use atuin_client::{ database::Database, history::History, import::{ Importer, Loader, bash::Bash, fish::Fish, nu::Nu, nu_histdb::NuHistDb, powershell::PowerShell, replxx::Replxx, resh::Resh, xonsh::Xonsh, xonsh_sqlite::XonshSqlite, zsh::Zsh, zsh_histdb::ZshHistDb, }, }; #[derive(Parser, Debug)] #[command(infer_subcommands = true)] pub enum Cmd { /// Import history for the current shell Auto, /// Import history from the zsh history file Zsh, /// Import history from the zsh history file ZshHistDb, /// Import history from the bash history file Bash, /// Import history from the replxx history file Replxx, /// Import history from the resh history file Resh, /// Import history from the fish history file Fish, /// Import history from the nu history file Nu, /// Import history from the nu history file NuHistDb, /// Import history from xonsh json files Xonsh, /// Import history from xonsh sqlite db XonshSqlite, /// Import history from the powershell history file Powershell, } const BATCH_SIZE: usize = 100; impl Cmd { #[allow(clippy::cognitive_complexity)] pub async fn run(&self, db: &DB) -> Result<()> { println!(" Atuin "); println!("======================"); println!(" \u{1f30d} "); println!(" \u{1f418}\u{1f418}\u{1f418}\u{1f418} "); println!(" \u{1f422} "); println!("======================"); println!("Importing history..."); match self { Self::Auto => { if cfg!(windows) { return if env::var("PSModulePath").is_ok() { println!("Detected PowerShell"); import::(db).await } else { println!("Could not detect the current shell."); println!("Please run atuin import ."); println!("To view a list of shells, run atuin import."); Ok(()) }; } // $XONSH_HISTORY_BACKEND isn't always set, but $XONSH_HISTORY_FILE is let xonsh_histfile = env::var("XONSH_HISTORY_FILE").unwrap_or_else(|_| String::new()); let shell = env::var("SHELL").unwrap_or_else(|_| String::from("NO_SHELL")); if xonsh_histfile.to_lowercase().ends_with(".json") { println!("Detected Xonsh",); import::(db).await } else if xonsh_histfile.to_lowercase().ends_with(".sqlite") { println!("Detected Xonsh (SQLite backend)"); import::(db).await } else if shell.ends_with("/zsh") { if ZshHistDb::histpath().is_ok() { println!( "Detected Zsh-HistDb, using :{}", ZshHistDb::histpath().unwrap().to_str().unwrap() ); import::(db).await } else { println!("Detected ZSH"); import::(db).await } } else if shell.ends_with("/fish") { println!("Detected Fish"); import::(db).await } else if shell.ends_with("/bash") { println!("Detected Bash"); import::(db).await } else if shell.ends_with("/nu") { if NuHistDb::histpath().is_ok() { println!( "Detected Nu-HistDb, using :{}", NuHistDb::histpath().unwrap().to_str().unwrap() ); import::(db).await } else { println!("Detected Nushell"); import::(db).await } } else if shell.ends_with("/pwsh") { println!("Detected PowerShell"); import::(db).await } else { println!("cannot import {shell} history"); Ok(()) } } Self::Zsh => import::(db).await, Self::ZshHistDb => import::(db).await, Self::Bash => import::(db).await, Self::Replxx => import::(db).await, Self::Resh => import::(db).await, Self::Fish => import::(db).await, Self::Nu => import::(db).await, Self::NuHistDb => import::(db).await, Self::Xonsh => import::(db).await, Self::XonshSqlite => import::(db).await, Self::Powershell => import::(db).await, } } } pub struct HistoryImporter<'db, DB: Database> { pb: ProgressBar, buf: Vec, db: &'db DB, } impl<'db, DB: Database> HistoryImporter<'db, DB> { fn new(db: &'db DB, len: usize) -> Self { Self { pb: ProgressBar::new(len as u64), buf: Vec::with_capacity(BATCH_SIZE), db, } } async fn flush(self) -> Result<()> { if !self.buf.is_empty() { self.db.save_bulk(&self.buf).await?; } self.pb.finish(); Ok(()) } } #[async_trait] impl Loader for HistoryImporter<'_, DB> { async fn push(&mut self, hist: History) -> Result<()> { self.pb.inc(1); self.buf.push(hist); if self.buf.len() == self.buf.capacity() { self.db.save_bulk(&self.buf).await?; self.buf.clear(); } Ok(()) } } async fn import(db: &DB) -> Result<()> { println!("Importing history from {}", I::NAME); let mut importer = I::new().await?; let len = importer.entries().await.unwrap(); let mut loader = HistoryImporter::new(db, len); importer.load(&mut loader).await?; loader.flush().await?; println!("Import complete!"); Ok(()) } ================================================ FILE: crates/atuin/src/command/client/info.rs ================================================ use atuin_client::settings::Settings; use crate::{SHA, VERSION}; pub fn run(settings: &Settings) { let config = atuin_common::utils::config_dir(); let mut config_file = config.clone(); config_file.push("config.toml"); let mut sever_config = config; sever_config.push("server.toml"); let config_paths = format!( "Config files:\nclient config: {:?}\nserver config: {:?}\nclient db path: {:?}\nkey path: {:?}\nmeta db path: {:?}", config_file.to_string_lossy(), sever_config.to_string_lossy(), settings.db_path, settings.key_path, settings.meta.db_path ); let env_vars = format!( "Env Vars:\nATUIN_CONFIG_DIR = {:?}", std::env::var("ATUIN_CONFIG_DIR").unwrap_or_else(|_| "None".into()) ); let general_info = format!("Version info:\nversion: {VERSION}\ncommit: {SHA}"); let print_out = format!("{config_paths}\n\n{env_vars}\n\n{general_info}"); println!("{print_out}"); } ================================================ FILE: crates/atuin/src/command/client/init/bash.rs ================================================ use atuin_client::settings::Tmux; use atuin_dotfiles::store::{AliasStore, var::VarStore}; use eyre::Result; fn print_tmux_config(tmux: &Tmux) { if tmux.enabled { println!("export ATUIN_TMUX_POPUP_WIDTH='{}'", tmux.width); println!("export ATUIN_TMUX_POPUP_HEIGHT='{}'", tmux.height); } else { println!("export ATUIN_TMUX_POPUP=false"); } } pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux) { let base = include_str!("../../../shell/atuin.bash"); let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { (false, false) } else { (!disable_ctrl_r, !disable_up_arrow) }; print_tmux_config(tmux); println!("__atuin_bind_ctrl_r={bind_ctrl_r}"); println!("__atuin_bind_up_arrow={bind_up_arrow}"); println!("{base}"); #[cfg(feature = "ai")] if !disable_ai { let bind_ai = atuin_ai::commands::init::generate_bash_integration(); println!("{bind_ai}"); } } pub async fn init( aliases: AliasStore, vars: VarStore, disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux, ) -> Result<()> { init_static(disable_up_arrow, disable_ctrl_r, disable_ai, tmux); let aliases = atuin_dotfiles::shell::bash::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::bash::var_config(&vars).await; println!("{aliases}"); println!("{vars}"); Ok(()) } ================================================ FILE: crates/atuin/src/command/client/init/fish.rs ================================================ use atuin_client::settings::Tmux; use atuin_dotfiles::store::{AliasStore, var::VarStore}; use eyre::Result; fn print_tmux_config(tmux: &Tmux) { if tmux.enabled { println!("set -gx ATUIN_TMUX_POPUP_WIDTH '{}'", tmux.width); println!("set -gx ATUIN_TMUX_POPUP_HEIGHT '{}'", tmux.height); } else { println!("set -gx ATUIN_TMUX_POPUP false"); } } fn print_bindings( indent: &str, disable_up_arrow: bool, disable_ctrl_r: bool, bind_ctrl_r: &str, bind_up_arrow: &str, bind_ctrl_r_ins: &str, bind_up_arrow_ins: &str, ) { if !disable_ctrl_r { println!("{indent}{bind_ctrl_r}"); } if !disable_up_arrow { println!("{indent}{bind_up_arrow}"); } println!("{indent}if bind -M insert >/dev/null 2>&1"); if !disable_ctrl_r { println!("{indent}{indent}{bind_ctrl_r_ins}"); } if !disable_up_arrow { println!("{indent}{indent}{bind_up_arrow_ins}"); } println!("{indent}end"); } pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux) { let indent = " ".repeat(4); let base = include_str!("../../../shell/atuin.fish"); print_tmux_config(tmux); println!("{base}"); if std::env::var("ATUIN_NOBIND").is_err() { println!("if string match -q '4.*' $version"); // In fish 4.0 and above the option bind -k doesn't exist anymore, // instead we can use key names and modifiers directly. print_bindings( &indent, disable_up_arrow, disable_ctrl_r, "bind ctrl-r _atuin_search", "bind up _atuin_bind_up", "bind -M insert ctrl-r _atuin_search", "bind -M insert up _atuin_bind_up", ); println!("else"); // We keep these for compatibility with fish 3.x print_bindings( &indent, disable_up_arrow, disable_ctrl_r, r"bind \cr _atuin_search", &[ r"bind -k up _atuin_bind_up", r"bind \eOA _atuin_bind_up", r"bind \e\[A _atuin_bind_up", ] .join("; "), r"bind -M insert \cr _atuin_search", &[ r"bind -M insert -k up _atuin_bind_up", r"bind -M insert \eOA _atuin_bind_up", r"bind -M insert \e\[A _atuin_bind_up", ] .join("; "), ); println!("end"); #[cfg(feature = "ai")] if !disable_ai { let bind_ai = atuin_ai::commands::init::generate_fish_integration(); println!("{bind_ai}"); } } } pub async fn init( aliases: AliasStore, vars: VarStore, disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux, ) -> Result<()> { init_static(disable_up_arrow, disable_ctrl_r, disable_ai, tmux); let aliases = atuin_dotfiles::shell::fish::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::fish::var_config(&vars).await; println!("{aliases}"); println!("{vars}"); Ok(()) } ================================================ FILE: crates/atuin/src/command/client/init/powershell.rs ================================================ use atuin_client::settings::Tmux; use atuin_dotfiles::store::{AliasStore, var::VarStore}; pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, _tmux: &Tmux) { let base = include_str!("../../../shell/atuin.ps1"); let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { (false, false) } else { (!disable_ctrl_r, !disable_up_arrow) }; // TODO: tmux popup for Powershell println!("{base}"); println!( "Enable-AtuinSearchKeys -CtrlR {} -UpArrow {}", ps_bool(bind_ctrl_r), ps_bool(bind_up_arrow) ); } pub async fn init( aliases: AliasStore, vars: VarStore, disable_up_arrow: bool, disable_ctrl_r: bool, tmux: &Tmux, ) -> eyre::Result<()> { init_static(disable_up_arrow, disable_ctrl_r, tmux); let aliases = atuin_dotfiles::shell::powershell::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::powershell::var_config(&vars).await; println!("{aliases}"); println!("{vars}"); Ok(()) } fn ps_bool(value: bool) -> &'static str { if value { "$true" } else { "$false" } } ================================================ FILE: crates/atuin/src/command/client/init/xonsh.rs ================================================ use atuin_client::settings::Tmux; use atuin_dotfiles::store::{AliasStore, var::VarStore}; use eyre::Result; pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, _tmux: &Tmux) { let base = include_str!("../../../shell/atuin.xsh"); let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { (false, false) } else { (!disable_ctrl_r, !disable_up_arrow) }; // TODO: tmux popup for xonsh println!( "_ATUIN_BIND_CTRL_R={}", if bind_ctrl_r { "True" } else { "False" } ); println!( "_ATUIN_BIND_UP_ARROW={}", if bind_up_arrow { "True" } else { "False" } ); println!("{base}"); } pub async fn init( aliases: AliasStore, vars: VarStore, disable_up_arrow: bool, disable_ctrl_r: bool, tmux: &Tmux, ) -> Result<()> { init_static(disable_up_arrow, disable_ctrl_r, tmux); let aliases = atuin_dotfiles::shell::xonsh::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::xonsh::var_config(&vars).await; println!("{aliases}"); println!("{vars}"); Ok(()) } ================================================ FILE: crates/atuin/src/command/client/init/zsh.rs ================================================ use atuin_client::settings::Tmux; use atuin_dotfiles::store::{AliasStore, var::VarStore}; use eyre::Result; fn print_tmux_config(tmux: &Tmux) { if tmux.enabled { println!("export ATUIN_TMUX_POPUP_WIDTH='{}'", tmux.width); println!("export ATUIN_TMUX_POPUP_HEIGHT='{}'", tmux.height); } else { println!("export ATUIN_TMUX_POPUP=false"); } } pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux) { let base = include_str!("../../../shell/atuin.zsh"); print_tmux_config(tmux); println!("{base}"); if std::env::var("ATUIN_NOBIND").is_err() { const BIND_CTRL_R: &str = r"bindkey -M emacs '^r' atuin-search bindkey -M viins '^r' atuin-search-viins bindkey -M vicmd '/' atuin-search"; const BIND_UP_ARROW: &str = r"bindkey -M emacs '^[[A' atuin-up-search bindkey -M vicmd '^[[A' atuin-up-search-vicmd bindkey -M viins '^[[A' atuin-up-search-viins bindkey -M emacs '^[OA' atuin-up-search bindkey -M vicmd '^[OA' atuin-up-search-vicmd bindkey -M viins '^[OA' atuin-up-search-viins bindkey -M vicmd 'k' atuin-up-search-vicmd"; if !disable_ctrl_r { println!("{BIND_CTRL_R}"); } if !disable_up_arrow { println!("{BIND_UP_ARROW}"); } #[cfg(feature = "ai")] if !disable_ai { let bind_ai = atuin_ai::commands::init::generate_zsh_integration(); println!("{bind_ai}"); } } } pub async fn init( aliases: AliasStore, vars: VarStore, disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux, ) -> Result<()> { init_static(disable_up_arrow, disable_ctrl_r, disable_ai, tmux); let aliases = atuin_dotfiles::shell::zsh::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::zsh::var_config(&vars).await; println!("{aliases}"); println!("{vars}"); Ok(()) } ================================================ FILE: crates/atuin/src/command/client/init.rs ================================================ use std::path::PathBuf; use atuin_client::{ encryption, record::sqlite_store::SqliteStore, settings::{Settings, Tmux}, }; use atuin_dotfiles::store::{AliasStore, var::VarStore}; use clap::{Parser, ValueEnum}; use eyre::{Result, WrapErr}; mod bash; mod fish; mod powershell; mod xonsh; mod zsh; #[derive(Parser, Debug)] pub struct Cmd { shell: Shell, /// Disable the binding of CTRL-R to atuin #[clap(long)] disable_ctrl_r: bool, /// Disable the binding of the Up Arrow key to atuin #[clap(long)] disable_up_arrow: bool, /// Disable the binding of ? to Atuin AI #[clap(long)] disable_ai: bool, } #[derive(Clone, Copy, ValueEnum, Debug)] #[value(rename_all = "lower")] #[allow(clippy::enum_variant_names, clippy::doc_markdown)] pub enum Shell { /// Zsh setup Zsh, /// Bash setup Bash, /// Fish setup Fish, /// Nu setup Nu, /// Xonsh setup Xonsh, /// PowerShell setup PowerShell, } impl Cmd { fn init_nu(&self, _tmux: &Tmux) { let full = include_str!("../../shell/atuin.nu"); // TODO: tmux popup for Nu println!("{full}"); if std::env::var("ATUIN_NOBIND").is_err() { const BIND_CTRL_R: &str = r"$env.config = ( $env.config | upsert keybindings ( $env.config.keybindings | append { name: atuin modifier: control keycode: char_r mode: [emacs, vi_normal, vi_insert] event: { send: executehostcommand cmd: (_atuin_search_cmd) } } ) )"; const BIND_UP_ARROW: &str = r" $env.config = ( $env.config | upsert keybindings ( $env.config.keybindings | append { name: atuin modifier: none keycode: up mode: [emacs, vi_normal, vi_insert] event: { until: [ {send: menuup} {send: executehostcommand cmd: (_atuin_search_cmd '--shell-up-key-binding') } ] } } ) ) "; if !self.disable_ctrl_r { println!("{BIND_CTRL_R}"); } if !self.disable_up_arrow { println!("{BIND_UP_ARROW}"); } } } fn static_init(&self, tmux: &Tmux) { match self.shell { Shell::Zsh => { zsh::init_static( self.disable_up_arrow, self.disable_ctrl_r, self.disable_ai, tmux, ); } Shell::Bash => { bash::init_static( self.disable_up_arrow, self.disable_ctrl_r, self.disable_ai, tmux, ); } Shell::Fish => { fish::init_static( self.disable_up_arrow, self.disable_ctrl_r, self.disable_ai, tmux, ); } Shell::Nu => { self.init_nu(tmux); } Shell::Xonsh => { xonsh::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux); } Shell::PowerShell => { powershell::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux); } } } async fn dotfiles_init(&self, settings: &Settings) -> Result<()> { let record_store_path = PathBuf::from(settings.record_store_path.as_str()); let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?; let encryption_key: [u8; 32] = encryption::load_key(settings) .context("could not load encryption key")? .into(); let host_id = Settings::host_id().await?; let alias_store = AliasStore::new(sqlite_store.clone(), host_id, encryption_key); let var_store = VarStore::new(sqlite_store.clone(), host_id, encryption_key); match self.shell { Shell::Zsh => { zsh::init( alias_store, var_store, self.disable_up_arrow, self.disable_ctrl_r, self.disable_ai, &settings.tmux, ) .await?; } Shell::Bash => { bash::init( alias_store, var_store, self.disable_up_arrow, self.disable_ctrl_r, self.disable_ai, &settings.tmux, ) .await?; } Shell::Fish => { fish::init( alias_store, var_store, self.disable_up_arrow, self.disable_ctrl_r, self.disable_ai, &settings.tmux, ) .await?; } Shell::Nu => self.init_nu(&settings.tmux), Shell::Xonsh => { xonsh::init( alias_store, var_store, self.disable_up_arrow, self.disable_ctrl_r, &settings.tmux, ) .await?; } Shell::PowerShell => { powershell::init( alias_store, var_store, self.disable_up_arrow, self.disable_ctrl_r, &settings.tmux, ) .await?; } } Ok(()) } pub async fn run(self, settings: &Settings) -> Result<()> { if !settings.paths_ok() { eprintln!( "Atuin settings paths are broken. Disabling atuin shell hooks. Run `atuin doctor` to diagnose." ); return Ok(()); } if settings.dotfiles.enabled { self.dotfiles_init(settings).await?; } else { self.static_init(&settings.tmux); } Ok(()) } } ================================================ FILE: crates/atuin/src/command/client/kv.rs ================================================ use std::io::{self, IsTerminal, Read}; use clap::Subcommand; use eyre::{Context, Result, eyre}; use atuin_client::{encryption, record::sqlite_store::SqliteStore, settings::Settings}; use atuin_kv::store::KvStore; #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub enum Cmd { /// Set a key-value pair Set { /// Key to set #[arg(long, short)] key: String, /// Value to store (reads from stdin if not provided) value: Option, /// Namespace for the key-value pair #[arg(long, short, default_value = "default")] namespace: String, }, /// Delete one or more key-value pairs #[command(alias = "rm")] Delete { /// Keys to delete #[arg(required = true)] keys: Vec, /// Namespace for the key-value pair #[arg(long, short, default_value = "default")] namespace: String, }, /// Retrieve a saved value Get { /// Key to retrieve key: String, /// Namespace for the key-value pair #[arg(long, short, default_value = "default")] namespace: String, }, /// List all keys in a namespace, or in all namespaces #[command(alias = "ls")] List { /// Namespace to list keys from #[arg(long, short, default_value = "default")] namespace: String, /// List all keys in all namespaces #[arg(long, short, alias = "all")] all_namespaces: bool, }, /// Rebuild the KV store Rebuild, } impl Cmd { pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { let encryption_key: [u8; 32] = encryption::load_key(settings) .context("could not load encryption key")? .into(); let host_id = Settings::host_id().await?; let kv_db = atuin_kv::database::Database::new(settings.kv.db_path.clone(), 1.0).await?; let kv_store = KvStore::new(store.clone(), kv_db, host_id, encryption_key); match self { Self::Set { key, value, namespace, } => { if namespace.is_empty() { return Err(eyre!("namespace cannot be empty")); } let value = if let Some(v) = value { v.clone() } else if !io::stdin().is_terminal() { let mut buf = String::new(); io::stdin() .read_to_string(&mut buf) .context("failed to read value from stdin")?; buf } else { return Err(eyre!( "no value provided. Pass as an argument or pipe via stdin" )); }; kv_store.set(namespace, key, &value).await } Self::Delete { keys, namespace } => kv_store.delete(namespace, keys).await, Self::Get { key, namespace } => { let kv = kv_store.get(namespace, key).await?; if let Some(val) = kv { println!("{val}"); } Ok(()) } Self::List { namespace, all_namespaces, } => { let entries = if *all_namespaces { kv_store.list(None).await? } else { kv_store.list(Some(namespace)).await? }; for entry in entries { if *all_namespaces { println!("{}.{}", entry.namespace, entry.key); } else { println!("{}", entry.key); } } Ok(()) } Self::Rebuild {} => kv_store.build().await, } } } ================================================ FILE: crates/atuin/src/command/client/scripts.rs ================================================ use std::collections::HashMap; use std::collections::HashSet; use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; use atuin_scripts::execution::template_script; use atuin_scripts::{ execution::{build_executable_script, execute_script_interactive, template_variables}, store::{ScriptStore, script::Script}, }; use clap::{Parser, Subcommand}; use eyre::OptionExt; use eyre::{Result, bail}; use tempfile::NamedTempFile; use atuin_client::{database::Database, record::sqlite_store::SqliteStore, settings::Settings}; use tracing::debug; #[derive(Parser, Debug)] pub struct NewScript { pub name: String, #[arg(short, long)] pub description: Option, #[arg(short, long)] pub tags: Vec, #[arg(short, long)] pub shebang: Option, #[arg(long)] pub script: Option, #[allow(clippy::option_option)] #[arg(long)] /// Use the last command as the script content /// Optionally specify a number to use the last N commands pub last: Option>, #[arg(long)] /// Skip opening editor when using --last pub no_edit: bool, } #[derive(Parser, Debug)] pub struct Run { pub name: String, /// Specify template variables in the format KEY=VALUE /// Example: -v name=John -v greeting="Hello there" #[arg(short, long = "var")] pub var: Vec, } #[derive(Parser, Debug)] pub struct List {} #[derive(Parser, Debug)] pub struct Get { pub name: String, #[arg(short, long)] /// Display only the executable script with shebang pub script: bool, } #[derive(Parser, Debug)] pub struct Edit { pub name: String, #[arg(short, long)] pub description: Option, /// Replace all existing tags with these new tags #[arg(short, long)] pub tags: Vec, /// Remove all tags from the script #[arg(long)] pub no_tags: bool, /// Rename the script #[arg(long)] pub rename: Option, #[arg(short, long)] pub shebang: Option, #[arg(long)] pub script: Option, #[allow(clippy::struct_field_names)] /// Skip opening editor #[arg(long)] pub no_edit: bool, } #[derive(Parser, Debug)] pub struct Delete { pub name: String, #[arg(short, long)] pub force: bool, } #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub enum Cmd { New(NewScript), Run(Run), #[command(alias = "ls")] List(List), Get(Get), Edit(Edit), #[command(alias = "rm")] Delete(Delete), } impl Cmd { // Helper function to open an editor with optional initial content fn open_editor(initial_content: Option<&str>) -> Result { // Create a temporary file let temp_file = NamedTempFile::new()?; let path = temp_file.into_temp_path(); // Write initial content to the temp file if provided if let Some(content) = initial_content { std::fs::write(&path, content)?; } // Open the file in the user's preferred editor let editor_str = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); // Use shlex to safely split the string into shell-like parts. let parts = shlex::split(&editor_str).ok_or_eyre("Failed to parse editor command")?; let (command, args) = parts.split_first().ok_or_eyre("No editor command found")?; let status = std::process::Command::new(command) .args(args) .arg(&path) .status()?; if !status.success() { bail!("failed to open editor"); } // Read back the edited content let content = std::fs::read_to_string(&path)?; path.close()?; Ok(content) } // Helper function to execute a script and manage stdin/stdout/stderr async fn execute_script(script_content: String, shebang: String) -> Result { let mut session = execute_script_interactive(script_content, shebang) .await .expect("failed to execute script"); // Create a channel to signal when the process exits let (exit_tx, mut exit_rx) = tokio::sync::oneshot::channel(); // Set up a task to read from stdin and forward to the script let sender = session.stdin_tx.clone(); let stdin_task = tokio::spawn(async move { use tokio::io::AsyncReadExt; use tokio::select; let stdin = tokio::io::stdin(); let mut reader = tokio::io::BufReader::new(stdin); let mut buffer = vec![0u8; 1024]; // Read in chunks for efficiency loop { // Use select to either read from stdin or detect when the process exits select! { // Check if the script process has exited _ = &mut exit_rx => { break; } // Try to read from stdin read_result = reader.read(&mut buffer) => { match read_result { Ok(0) => break, // EOF Ok(n) => { // Convert the bytes to a string and forward to script let input = String::from_utf8_lossy(&buffer[0..n]).to_string(); if let Err(e) = sender.send(input).await { eprintln!("Error sending input to script: {e}"); break; } }, Err(e) => { eprintln!("Error reading from stdin: {e}"); break; } } } } } }); // Wait for the script to complete let exit_code = session.wait_for_exit().await; // Signal the stdin task to stop let _ = exit_tx.send(()); let _ = stdin_task.await; let code = exit_code.unwrap_or(-1); if code != 0 { eprintln!("Script exited with code {code}"); } Ok(code) } async fn handle_new_script( settings: &Settings, new_script: NewScript, script_store: ScriptStore, script_db: atuin_scripts::database::Database, history_db: &impl Database, ) -> Result<()> { let mut stdin = std::io::stdin(); let script_content = if let Some(count_opt) = new_script.last { // Get the last N commands from history, plus 1 to exclude the command that runs this script let count = count_opt.unwrap_or(1) + 1; // Add 1 to the count to exclude the current command let context = atuin_client::database::current_context().await?; // Get the last N+1 commands, filtering by the default mode let filters = [settings.default_filter_mode(context.git_root.is_some())]; let mut history = history_db .list(&filters, &context, Some(count), false, false) .await?; // Reverse to get chronological order history.reverse(); // Skip the most recent command (which would be the atuin scripts new command itself) if !history.is_empty() { history.pop(); // Remove the most recent command } // Format the commands into a script let commands: Vec = history.iter().map(|h| h.command.clone()).collect(); if commands.is_empty() { bail!("No commands found in history"); } let script_text = commands.join("\n"); // Only open editor if --no-edit is not specified if new_script.no_edit { Some(script_text) } else { // Open the editor with the commands pre-loaded Some(Self::open_editor(Some(&script_text))?) } } else if let Some(script_path) = new_script.script { let script_content = std::fs::read_to_string(script_path)?; Some(script_content) } else if !stdin.is_terminal() { let mut buffer = String::new(); stdin.read_to_string(&mut buffer)?; Some(buffer) } else { // Open editor with empty file Some(Self::open_editor(None)?) }; let script = Script::builder() .name(new_script.name) .description(new_script.description.unwrap_or_default()) .shebang(new_script.shebang.unwrap_or_default()) .tags(new_script.tags) .script(script_content.unwrap_or_default()) .build(); script_store.create(script).await?; script_store.build(script_db).await?; Ok(()) } async fn handle_run( _settings: &Settings, run: Run, script_db: atuin_scripts::database::Database, ) -> Result<()> { let script = script_db.get_by_name(&run.name).await?; if let Some(script) = script { // Get variables used in the template let variables = template_variables(&script)?; // Create a hashmap to store variable values let mut variable_values: HashMap = HashMap::new(); // Parse variables from command-line arguments first for var_str in &run.var { if let Some((key, value)) = var_str.split_once('=') { // Add to variable values variable_values.insert( key.to_string(), serde_json::Value::String(value.to_string()), ); debug!("Using CLI variable: {}={}", key, value); } else { eprintln!("Warning: Ignoring malformed variable specification: {var_str}"); eprintln!("Variables should be specified as KEY=VALUE"); } } // Collect variables that are still needed (not specified via CLI) let remaining_vars: HashSet = variables .into_iter() .filter(|var| !variable_values.contains_key(var)) .collect(); // If there are variables in the template that weren't specified on the command line, prompt for them if !remaining_vars.is_empty() { println!("This script contains template variables that need values:"); let stdin = std::io::stdin(); let mut input = String::new(); for var in remaining_vars { input.clear(); println!("Enter value for '{var}': "); if stdin.read_line(&mut input).is_err() { eprintln!("Failed to read input for variable '{var}'"); // Provide an empty string as fallback variable_values.insert(var, serde_json::Value::String(String::new())); continue; } let value = input.trim().to_string(); variable_values.insert(var, serde_json::Value::String(value)); } } let final_script = if variable_values.is_empty() { // No variables to template, just use the original script script.script.clone() } else { // If we have variables, we need to template the script debug!("Templating script with variables: {:?}", variable_values); template_script(&script, &variable_values)? }; // Execute the script (either templated or original) Self::execute_script(final_script, script.shebang.clone()).await?; } else { bail!("script not found"); } Ok(()) } async fn handle_list( _settings: &Settings, _list: List, script_db: atuin_scripts::database::Database, ) -> Result<()> { let scripts = script_db.list().await?; if scripts.is_empty() { println!("No scripts found"); } else { println!("Available scripts:"); for script in scripts { if script.tags.is_empty() { println!("- {} ", script.name); } else { println!("- {} [tags: {}]", script.name, script.tags.join(", ")); } // Print description if it's not empty if !script.description.is_empty() { println!(" Description: {}", script.description); } } } Ok(()) } async fn handle_get( _settings: &Settings, get: Get, script_db: atuin_scripts::database::Database, ) -> Result<()> { let script = script_db.get_by_name(&get.name).await?; if let Some(script) = script { if get.script { // Just print the executable script with shebang print!( "{}", build_executable_script(script.script.clone(), script.shebang) ); return Ok(()); } // Create a YAML representation of the script println!("---"); println!("name: {}", script.name); println!("id: {}", script.id); if script.description.is_empty() { println!("description: \"\""); } else { println!("description: |"); // Indent multiline descriptions properly for YAML for line in script.description.lines() { println!(" {line}"); } } if script.tags.is_empty() { println!("tags: []"); } else { println!("tags:"); for tag in &script.tags { println!(" - {tag}"); } } println!("shebang: {}", script.shebang); println!("script: |"); // Indent the script content for proper YAML multiline format for line in script.script.lines() { println!(" {line}"); } Ok(()) } else { bail!("script '{}' not found", get.name); } } #[allow(clippy::cognitive_complexity)] async fn handle_edit( _settings: &Settings, edit: Edit, script_store: ScriptStore, script_db: atuin_scripts::database::Database, ) -> Result<()> { debug!("editing script {:?}", edit); // Find the existing script let existing_script = script_db.get_by_name(&edit.name).await?; debug!("existing script {:?}", existing_script); if let Some(mut script) = existing_script { // Update the script with new values if provided if let Some(description) = edit.description { script.description = description; } // Handle renaming if requested if let Some(new_name) = edit.rename { // Check if a script with the new name already exists if (script_db.get_by_name(&new_name).await?).is_some() { bail!("A script named '{}' already exists", new_name); } // Update the name script.name = new_name; } // Handle tag updates with priority: // 1. If --no-tags is provided, clear all tags // 2. If --tags is provided, replace all tags // 3. If neither is provided, tags remain unchanged if edit.no_tags { // Clear all tags script.tags.clear(); } else if !edit.tags.is_empty() { // Replace all tags script.tags = edit.tags; } // If none of the above conditions are met, tags remain unchanged if let Some(shebang) = edit.shebang { script.shebang = shebang; } // Handle script content update let script_content = if let Some(script_path) = edit.script { // Load script from provided file std::fs::read_to_string(script_path)? } else if !edit.no_edit { // Open the script in editor for interactive editing if --no-edit is not specified Self::open_editor(Some(&script.script))? } else { // If --no-edit is specified, keep the existing script content script.script.clone() }; // Update the script content script.script = script_content; // Update the script in the store script_store.update(script).await?; // Rebuild the database to apply changes script_store.build(script_db).await?; println!("Script '{}' updated successfully!", edit.name); Ok(()) } else { bail!("script '{}' not found", edit.name); } } async fn handle_delete( _settings: &Settings, delete: Delete, script_store: ScriptStore, script_db: atuin_scripts::database::Database, ) -> Result<()> { // Find the script by name let script = script_db.get_by_name(&delete.name).await?; if let Some(script) = script { // If not force, confirm deletion if !delete.force { println!( "Are you sure you want to delete script '{}'? [y/N]", delete.name ); let mut input = String::new(); std::io::stdin().read_line(&mut input)?; let input = input.trim().to_lowercase(); if input != "y" && input != "yes" { println!("Deletion cancelled"); return Ok(()); } } // Delete the script script_store.delete(script.id).await?; // Rebuild the database to apply changes script_store.build(script_db).await?; println!("Script '{}' deleted successfully", delete.name); Ok(()) } else { bail!("script '{}' not found", delete.name); } } pub async fn run( self, settings: &Settings, store: SqliteStore, history_db: &impl Database, ) -> Result<()> { let host_id = Settings::host_id().await?; let encryption_key: [u8; 32] = atuin_client::encryption::load_key(settings)?.into(); let script_store = ScriptStore::new(store, host_id, encryption_key); let script_db = atuin_scripts::database::Database::new(settings.scripts.db_path.clone(), 1.0).await?; match self { Self::New(new_script) => { Self::handle_new_script(settings, new_script, script_store, script_db, history_db) .await } Self::Run(run) => Self::handle_run(settings, run, script_db).await, Self::List(list) => Self::handle_list(settings, list, script_db).await, Self::Get(get) => Self::handle_get(settings, get, script_db).await, Self::Edit(edit) => Self::handle_edit(settings, edit, script_store, script_db).await, Self::Delete(delete) => { Self::handle_delete(settings, delete, script_store, script_db).await } } } } ================================================ FILE: crates/atuin/src/command/client/search/cursor.rs ================================================ use atuin_client::settings::WordJumpMode; pub struct Cursor { source: String, index: usize, } impl From for Cursor { fn from(source: String) -> Self { Self { source, index: 0 } } } pub struct WordJumper<'a> { word_chars: &'a str, word_jump_mode: WordJumpMode, } impl WordJumper<'_> { fn is_word_boundary(&self, c: char, next_c: char) -> bool { (c.is_whitespace() && !next_c.is_whitespace()) || (!c.is_whitespace() && next_c.is_whitespace()) || (self.word_chars.contains(c) && !self.word_chars.contains(next_c)) || (!self.word_chars.contains(c) && self.word_chars.contains(next_c)) } fn emacs_get_next_word_pos(&self, source: &str, index: usize) -> usize { let index = (index + 1..source.len().saturating_sub(1)) .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap())) .unwrap_or(source.len()); (index + 1..source.len().saturating_sub(1)) .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap())) .unwrap_or(source.len()) } fn emacs_get_prev_word_pos(&self, source: &str, index: usize) -> usize { let index = (1..index) .rev() .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap())) .unwrap_or(0); (1..index) .rev() .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap())) .map_or(0, |i| i + 1) } fn subl_get_next_word_pos(&self, source: &str, index: usize) -> usize { let index = (index..source.len().saturating_sub(1)).find(|&i| { self.is_word_boundary( source.chars().nth(i).unwrap(), source.chars().nth(i + 1).unwrap(), ) }); if index.is_none() { return source.len(); } (index.unwrap() + 1..source.len()) .find(|&i| !source.chars().nth(i).unwrap().is_whitespace()) .unwrap_or(source.len()) } fn subl_get_prev_word_pos(&self, source: &str, index: usize) -> usize { let index = (1..index) .rev() .find(|&i| !source.chars().nth(i).unwrap().is_whitespace()); if index.is_none() { return 0; } (1..index.unwrap()) .rev() .find(|&i| { self.is_word_boundary( source.chars().nth(i - 1).unwrap(), source.chars().nth(i).unwrap(), ) }) .unwrap_or(0) } fn get_next_word_pos(&self, source: &str, index: usize) -> usize { match self.word_jump_mode { WordJumpMode::Emacs => self.emacs_get_next_word_pos(source, index), WordJumpMode::Subl => self.subl_get_next_word_pos(source, index), } } fn get_prev_word_pos(&self, source: &str, index: usize) -> usize { match self.word_jump_mode { WordJumpMode::Emacs => self.emacs_get_prev_word_pos(source, index), WordJumpMode::Subl => self.subl_get_prev_word_pos(source, index), } } } impl Cursor { pub fn as_str(&self) -> &str { self.source.as_str() } pub fn into_inner(self) -> String { self.source } /// Returns the string before the cursor pub fn substring(&self) -> &str { &self.source[..self.index] } /// Returns the currently selected [`char`] pub fn char(&self) -> Option { self.source[self.index..].chars().next() } pub fn right(&mut self) { if self.index < self.source.len() { loop { self.index += 1; if self.source.is_char_boundary(self.index) { break; } } } } pub fn left(&mut self) -> bool { if self.index > 0 { loop { self.index -= 1; if self.source.is_char_boundary(self.index) { break true; } } } else { false } } pub fn next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { let word_jumper = WordJumper { word_chars, word_jump_mode, }; self.index = word_jumper.get_next_word_pos(&self.source, self.index); } pub fn prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { let word_jumper = WordJumper { word_chars, word_jump_mode, }; self.index = word_jumper.get_prev_word_pos(&self.source, self.index); } /// Move cursor to the end of the current/next word (vim `e` motion). /// /// If cursor is in the middle of a word, moves to the end of that word. /// If cursor is at the end of a word (or on whitespace), moves to the /// end of the next word. pub fn word_end(&mut self, word_chars: &str) { let len = self.source.len(); if self.index >= len { return; } let chars: Vec = self.source.chars().collect(); let mut char_idx = self.source[..self.index].chars().count(); if char_idx >= chars.len() { return; } let current = chars[char_idx]; // Check if we're at a word boundary (end of current word or on whitespace) let at_word_boundary = current.is_whitespace() || char_idx + 1 >= chars.len() || { let next = chars[char_idx + 1]; next.is_whitespace() || (word_chars.contains(current) != word_chars.contains(next)) }; // If at word boundary, advance past it and skip whitespace to find next word if at_word_boundary { char_idx += 1; while char_idx < chars.len() && chars[char_idx].is_whitespace() { char_idx += 1; } } // If we've gone past end, go to end of string if char_idx >= chars.len() { self.index = len; return; } // Find end of word: advance until next char is whitespace or different word type let in_word_chars = word_chars.contains(chars[char_idx]); while char_idx < chars.len() { let next_idx = char_idx + 1; if next_idx >= chars.len() { // At last char, move past it char_idx = next_idx; break; } let next_c = chars[next_idx]; if next_c.is_whitespace() || (word_chars.contains(next_c) != in_word_chars) { // Next char is start of new word/whitespace, so current char is end char_idx = next_idx; break; } char_idx += 1; } // Convert char index back to byte index self.index = chars.iter().take(char_idx).map(|c| c.len_utf8()).sum(); } pub fn insert(&mut self, c: char) { self.source.insert(self.index, c); self.index += c.len_utf8(); } pub fn remove(&mut self) -> Option { if self.index < self.source.len() { Some(self.source.remove(self.index)) } else { None } } pub fn remove_next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { let word_jumper = WordJumper { word_chars, word_jump_mode, }; let next_index = word_jumper.get_next_word_pos(&self.source, self.index); self.source.replace_range(self.index..next_index, ""); } pub fn remove_prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { let word_jumper = WordJumper { word_chars, word_jump_mode, }; let next_index = word_jumper.get_prev_word_pos(&self.source, self.index); self.source.replace_range(next_index..self.index, ""); self.index = next_index; } pub fn back(&mut self) -> Option { if self.left() { self.remove() } else { None } } pub fn clear(&mut self) { self.source.clear(); self.index = 0; } pub fn clear_to_start(&mut self) { self.source.replace_range(..self.index, ""); self.index = 0; } pub fn clear_to_end(&mut self) { self.source.replace_range(self.index.., ""); self.index = self.source.len(); } pub fn end(&mut self) { self.index = self.source.len(); } pub fn start(&mut self) { self.index = 0; } pub fn position(&self) -> usize { self.index } } #[cfg(test)] mod cursor_tests { use super::Cursor; use super::*; static EMACS_WORD_JUMPER: WordJumper = WordJumper { word_chars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", word_jump_mode: WordJumpMode::Emacs, }; static SUBL_WORD_JUMPER: WordJumper = WordJumper { word_chars: "./\\()\"'-:,.;<>~!@#$%^&*|+=[]{}`~?", word_jump_mode: WordJumpMode::Subl, }; #[test] fn right() { // ö is 2 bytes let mut c = Cursor::from(String::from("öaöböcödöeöfö")); let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20]; for i in indices { assert_eq!(c.index, i); c.right(); } } #[test] fn left() { // ö is 2 bytes let mut c = Cursor::from(String::from("öaöböcödöeöfö")); c.end(); let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0]; for i in indices { assert_eq!(c.index, i); c.left(); } } #[test] fn test_emacs_get_next_word_pos() { let s = String::from(" aaa ((()))bbb ((())) "); let indices = [(0, 6), (3, 6), (7, 18), (19, 30)]; for (i_src, i_dest) in indices { assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest); } assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos("", 0), 0); } #[test] fn test_emacs_get_prev_word_pos() { let s = String::from(" aaa ((()))bbb ((())) "); let indices = [(30, 15), (29, 15), (15, 3), (3, 0)]; for (i_src, i_dest) in indices { assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest); } assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos("", 0), 0); } #[test] fn test_subl_get_next_word_pos() { let s = String::from(" aaa ((()))bbb ((())) "); let indices = [(0, 3), (1, 3), (3, 9), (9, 15), (15, 21), (21, 30)]; for (i_src, i_dest) in indices { assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest); } assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos("", 0), 0); } #[test] fn test_subl_get_prev_word_pos() { let s = String::from(" aaa ((()))bbb ((())) "); let indices = [(30, 21), (21, 15), (15, 9), (9, 3), (3, 0)]; for (i_src, i_dest) in indices { assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest); } assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos("", 0), 0); } #[test] fn pop() { let mut s = String::from("öaöböcödöeöfö"); let mut c = Cursor::from(s.clone()); c.end(); while !s.is_empty() { let c1 = s.pop(); let c2 = c.back(); assert_eq!(c1, c2); assert_eq!(s.as_str(), c.substring()); } let c1 = s.pop(); let c2 = c.back(); assert_eq!(c1, c2); } #[test] fn back() { let mut c = Cursor::from(String::from("öaöböcödöeöfö")); // move to ^ for _ in 0..4 { c.right(); } assert_eq!(c.substring(), "öaöb"); assert_eq!(c.back(), Some('b')); assert_eq!(c.back(), Some('ö')); assert_eq!(c.back(), Some('a')); assert_eq!(c.back(), Some('ö')); assert_eq!(c.back(), None); assert_eq!(c.as_str(), "öcödöeöfö"); } #[test] fn insert() { let mut c = Cursor::from(String::from("öaöböcödöeöfö")); // move to ^ for _ in 0..4 { c.right(); } assert_eq!(c.substring(), "öaöb"); c.insert('ö'); c.insert('g'); c.insert('ö'); c.insert('h'); assert_eq!(c.substring(), "öaöbögöh"); assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö"); } } ================================================ FILE: crates/atuin/src/command/client/search/duration.rs ================================================ use core::fmt; use std::{ops::ControlFlow, time::Duration}; #[allow(clippy::module_name_repetitions)] pub fn format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> { if value > 0 { ControlFlow::Break((unit, value)) } else { ControlFlow::Continue(()) } } // impl taken and modified from // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331 // Copyright (c) 2016 The humantime Developers fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> { let secs = f.as_secs(); let nanos = f.subsec_nanos(); let years = secs / 31_557_600; // 365.25d let year_days = secs % 31_557_600; let months = year_days / 2_630_016; // 30.44d let month_days = year_days % 2_630_016; let days = month_days / 86400; let day_secs = month_days % 86400; let hours = day_secs / 3600; let minutes = day_secs % 3600 / 60; let seconds = day_secs % 60; let millis = nanos / 1_000_000; let micros = nanos / 1_000; // a difference from our impl than the original is that // we only care about the most-significant segment of the duration. // If the item call returns `Break`, then the `?` will early-return. // This allows for a very consise impl item("y", years)?; item("mo", months)?; item("d", days)?; item("h", hours)?; item("m", minutes)?; item("s", seconds)?; item("ms", u64::from(millis))?; item("us", u64::from(micros))?; item("ns", u64::from(nanos))?; ControlFlow::Continue(()) } match fmt(dur) { ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"), ControlFlow::Continue(()) => write!(f, "0s"), } } #[allow(clippy::module_name_repetitions)] pub fn format_duration(f: Duration) -> String { struct F(Duration); impl fmt::Display for F { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { format_duration_into(self.0, f) } } F(f).to_string() } ================================================ FILE: crates/atuin/src/command/client/search/engines/daemon.rs ================================================ use async_trait::async_trait; use atuin_client::{ database::{Database, OptFilters}, history::History, settings::{SearchMode, Settings}, }; use atuin_daemon::client::SearchClient; use atuin_nucleo_matcher::{ Config, Matcher, Utf32Str, pattern::{CaseMatching, Normalization, Pattern}, }; use eyre::Result; use tracing::{Level, debug, instrument, span}; use uuid::Uuid; use super::{SearchEngine, SearchState}; pub struct Search { client: Option, query_id: u64, socket_path: String, #[cfg(not(unix))] tcp_port: u64, } impl Search { pub fn new(settings: &Settings) -> Self { Search { client: None, query_id: 0, socket_path: settings.daemon.socket_path.clone(), #[cfg(not(unix))] tcp_port: settings.daemon.tcp_port, } } #[instrument(skip_all, level = Level::TRACE, name = "get_daemon_client")] async fn get_client(&mut self) -> Result<&mut SearchClient> { if self.client.is_none() { #[cfg(unix)] let client = SearchClient::new(self.socket_path.clone()).await?; #[cfg(not(unix))] let client = SearchClient::new(self.tcp_port).await?; self.client = Some(client); } Ok(self.client.as_mut().unwrap()) } fn next_query_id(&mut self) -> u64 { self.query_id += 1; self.query_id } /// Check if query contains regex pattern (r/.../) /// Nucleo doesn't support regex, so we fall back to database search fn contains_regex_pattern(query: &str) -> bool { query.starts_with("r/") || query.contains(" r/") } #[instrument(skip_all, level = Level::TRACE, name = "daemon_db_fallback")] async fn fallback_to_db_search( &self, state: &SearchState, db: &dyn Database, ) -> Result> { let results = db .search( SearchMode::FullText, state.filter_mode, &state.context, state.input.as_str(), OptFilters { limit: Some(200), ..Default::default() }, ) .await .map_or(Vec::new(), |r| r.into_iter().collect()); Ok(results) } #[instrument(skip_all, level = Level::TRACE, name = "hydrate_from_db", fields(count = ids.len()))] async fn hydrate_from_db(&self, db: &dyn Database, ids: &[String]) -> Result> { let placeholders: Vec = ids.iter().map(|id| format!("'{id}'")).collect(); let sql_query = format!( "SELECT * FROM history WHERE id IN ({}) ORDER BY timestamp DESC", placeholders.join(",") ); Ok(db.query_history(&sql_query).await?) } } #[async_trait] impl SearchEngine for Search { #[instrument(skip_all, level = Level::TRACE, name = "daemon_search", fields(query = %state.input.as_str()))] async fn full_query( &mut self, state: &SearchState, db: &mut dyn Database, ) -> Result> { let query = state.input.as_str().to_string(); // Fall back to database for regex queries (Nucleo doesn't support regex) if Self::contains_regex_pattern(&query) { debug!(query = %query, "[daemon-client] regex detected, falling back to db"); return self.fallback_to_db_search(state, db).await; } let query_id = self.next_query_id(); let span = span!(Level::TRACE, "daemon_search.req_resp", query = %query, query_id = query_id); let client = self.get_client().await?; let _span = span.enter(); let mut stream = client .search( query.clone(), query_id, state.filter_mode, Some(state.context.clone()), ) .await?; let mut ids = Vec::with_capacity(200); span!(Level::TRACE, "daemon_search.resp") .in_scope(async || { while let Ok(Some(response)) = stream.message().await { let span2 = span!( Level::TRACE, "daemon_search.resp.item", query_id = response.query_id ); let _span2 = span2.enter(); // Only process if the query_id matches (prevents stale responses) if response.query_id == query_id { let uuids = response .ids .iter() .map(|id| { let bytes: [u8; 16] = id.as_slice().try_into().expect("id should be 16 bytes"); Uuid::from_bytes(bytes).as_simple().to_string() }) .collect::>(); ids.extend(uuids); } drop(_span2); drop(span2); } }) .await; drop(_span); drop(span); if ids.is_empty() { debug!(query = %query, results = 0, "[daemon-client] empty results"); return Ok(Vec::new()); } // // Hydrate from local database let results = self.hydrate_from_db(db, &ids).await?; // // Reorder results to match the order from the daemon (which is ranked by relevance) let ordered_results = span!(Level::TRACE, "reorder_results").in_scope(|| { let mut ordered_results = Vec::with_capacity(results.len()); for id in &ids { if let Some(history) = results.iter().find(|h| h.id.0 == *id) { ordered_results.push(history.clone()); } } ordered_results }); debug!( query = %query, results = results.len(), "[daemon-client]" ); Ok(ordered_results) } #[instrument(skip_all, level = Level::TRACE, name = "daemon_highlight")] fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec { // Use fulltext highlighting for regex queries if Self::contains_regex_pattern(search_input) { return super::db::get_highlight_indices_fulltext(command, search_input); } let mut matcher = Matcher::new(Config::DEFAULT); let pattern = Pattern::parse(search_input, CaseMatching::Smart, Normalization::Smart); let mut indices: Vec = Vec::new(); let mut haystack_buf = Vec::new(); let haystack = Utf32Str::new(command, &mut haystack_buf); pattern.indices(haystack, &mut matcher, &mut indices); // Convert u32 indices to usize indices.into_iter().map(|i| i as usize).collect() } } ================================================ FILE: crates/atuin/src/command/client/search/engines/db.rs ================================================ use super::{SearchEngine, SearchState}; use async_trait::async_trait; use atuin_client::{ database::Database, database::OptFilters, database::{QueryToken, QueryTokenizer}, history::History, settings::SearchMode, }; use eyre::Result; use norm::Metric; use norm::fzf::{FzfParser, FzfV2}; use std::ops::Range; use tracing::{Level, instrument}; pub struct Search(pub SearchMode); #[async_trait] impl SearchEngine for Search { #[instrument(skip_all, level = Level::TRACE, name = "db_search", fields(mode = ?self.0, query = %state.input.as_str()))] async fn full_query( &mut self, state: &SearchState, db: &mut dyn Database, ) -> Result> { let results = db .search( self.0, state.filter_mode, &state.context, state.input.as_str(), OptFilters { limit: Some(200), ..Default::default() }, ) .await // ignore errors as it may be caused by incomplete regex .map_or(Vec::new(), |r| r.into_iter().collect()); Ok(results) } #[instrument(skip_all, level = Level::TRACE, name = "db_highlight")] fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec { if self.0 == SearchMode::Prefix { return vec![]; } else if self.0 == SearchMode::FullText { return get_highlight_indices_fulltext(command, search_input); } let mut fzf = FzfV2::new(); let mut parser = FzfParser::new(); let query = parser.parse(search_input); let mut ranges: Vec> = Vec::new(); let _ = fzf.distance_and_ranges(query, command, &mut ranges); // convert ranges to all indices ranges.into_iter().flatten().collect() } } #[instrument(skip_all, level = Level::TRACE, name = "db_highlight_fulltext")] pub fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec { let mut ranges = vec![]; let lower_command = command.to_ascii_lowercase(); for token in QueryTokenizer::new(search_input) { let matchee = if token.has_uppercase() { command } else { &lower_command }; if token.is_inverse() { continue; } match token { QueryToken::Or => {} QueryToken::Regex(r) => { if let Ok(re) = regex::Regex::new(r) { for m in re.find_iter(command) { ranges.push(m.range()); } } } QueryToken::MatchStart(term, _) => { if matchee.starts_with(term) { ranges.push(0..term.len()); } } QueryToken::MatchEnd(term, _) => { if matchee.ends_with(term) { let l = matchee.len(); ranges.push((l - term.len())..l); } } QueryToken::Match(term, _) | QueryToken::MatchFull(term, _) => { for (idx, m) in matchee.match_indices(term) { ranges.push(idx..(idx + m.len())); } } } } let mut ret: Vec<_> = ranges.into_iter().flatten().collect(); ret.sort_unstable(); ret.dedup(); ret } ================================================ FILE: crates/atuin/src/command/client/search/engines/skim.rs ================================================ use std::path::Path; use async_trait::async_trait; use atuin_client::{database::Database, history::History, settings::FilterMode}; use eyre::Result; use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; use itertools::Itertools; use time::OffsetDateTime; use tokio::task::yield_now; use tracing::{Level, instrument, warn}; use uuid; use super::{SearchEngine, SearchState}; pub struct Search { all_history: Vec<(History, i32)>, engine: SkimMatcherV2, } impl Search { pub fn new() -> Self { Search { all_history: vec![], engine: SkimMatcherV2::default(), } } } #[async_trait] impl SearchEngine for Search { #[instrument(skip_all, level = Level::TRACE, name = "skim_search", fields(query = %state.input.as_str()))] async fn full_query( &mut self, state: &SearchState, db: &mut dyn Database, ) -> Result> { if self.all_history.is_empty() { self.all_history = load_all_history(db).await; } Ok(fuzzy_search(&self.engine, state, &self.all_history).await) } #[instrument(skip_all, level = Level::TRACE, name = "skim_highlight")] fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec { let (_, indices) = self .engine .fuzzy_indices(command, search_input) .unwrap_or_default(); indices } } #[instrument(skip_all, level = Level::TRACE, name = "load_all_history")] async fn load_all_history(db: &dyn Database) -> Vec<(History, i32)> { db.all_with_count().await.unwrap() } #[allow(clippy::too_many_lines)] #[instrument(skip_all, level = Level::TRACE, name = "fuzzy_match", fields(history_count = all_history.len()))] async fn fuzzy_search( engine: &SkimMatcherV2, state: &SearchState, all_history: &[(History, i32)], ) -> Vec { let mut set = Vec::with_capacity(200); let mut ranks = Vec::with_capacity(200); let query = state.input.as_str(); let now = OffsetDateTime::now_utc(); for (i, (history, count)) in all_history.iter().enumerate() { if i % 256 == 0 { yield_now().await; } let context = &state.context; let git_root = context .git_root .as_ref() .and_then(|git_root| git_root.to_str()) .unwrap_or(&context.cwd); match state.filter_mode { FilterMode::Global => {} // we aggregate host by ',' separating them FilterMode::Host if history .hostname .split(',') .contains(&context.hostname.as_str()) => {} // we aggregate session by concattenating them. // sessions are 32 byte simple uuid formats FilterMode::Session if history .session .as_bytes() .chunks(32) .contains(&context.session.as_bytes()) => {} // SessionPreload: include current session + global history from before session start FilterMode::SessionPreload => { let is_current_session = { history .session .as_bytes() .chunks(32) .any(|chunk| chunk == context.session.as_bytes()) }; if !is_current_session { let Ok(uuid) = uuid::Uuid::parse_str(&context.session) else { warn!("failed to parse session id '{}'", context.session); continue; }; let Some(timestamp) = uuid.get_timestamp() else { warn!( "failed to get timestamp from uuid '{}'", uuid.as_hyphenated() ); continue; }; let (seconds, nanos) = timestamp.to_unix(); let Ok(session_start) = time::OffsetDateTime::from_unix_timestamp_nanos( i128::from(seconds) * 1_000_000_000 + i128::from(nanos), ) else { warn!( "failed to create OffsetDateTime from second: {seconds}, nanosecond: {nanos}" ); continue; }; if history.timestamp >= session_start { continue; } } } // we aggregate directory by ':' separating them FilterMode::Directory if history.cwd.split(':').contains(&context.cwd.as_str()) => {} FilterMode::Workspace if history.cwd.split(':').contains(&git_root) => {} _ => continue, } #[allow(clippy::cast_lossless, clippy::cast_precision_loss)] if let Some((score, indices)) = engine.fuzzy_indices(&history.command, query) { let begin = indices.first().copied().unwrap_or_default(); let mut duration = (now - history.timestamp).as_seconds_f64().log2(); if !duration.is_finite() || duration <= 1.0 { duration = 1.0; } // these + X.0 just make the log result a bit smoother. // log is very spiky towards 1-4, but I want a gradual decay. // eg: // log2(4) = 2, log2(5) = 2.3 (16% increase) // log2(8) = 3, log2(9) = 3.16 (5% increase) // log2(16) = 4, log2(17) = 4.08 (2% increase) let count = (*count as f64 + 8.0).log2(); let begin = (begin as f64 + 16.0).log2(); let path = path_dist(history.cwd.as_ref(), state.context.cwd.as_ref()); let path = (path as f64 + 8.0).log2(); // reduce longer durations, raise higher counts, raise matches close to the start let score = (-score as f64) * count / path / duration / begin; 'insert: { // algorithm: // 1. find either the position that this command ranks // 2. find the same command positioned better than our rank. for i in 0..set.len() { // do we out score the current position? if ranks[i] > score { ranks.insert(i, score); set.insert(i, history.clone()); let mut j = i + 1; while j < set.len() { // remove duplicates that have a worse score if set[j].command == history.command { ranks.remove(j); set.remove(j); // break this while loop because there won't be any other // duplicates. break; } j += 1; } // keep it limited if ranks.len() > 200 { ranks.pop(); set.pop(); } break 'insert; } // don't continue if this command has a better score already if set[i].command == history.command { break 'insert; } } if set.len() < 200 { ranks.push(score); set.push(history.clone()); } } } } set } fn path_dist(a: &Path, b: &Path) -> usize { let mut a: Vec<_> = a.components().collect(); let b: Vec<_> = b.components().collect(); let mut dist = 0; // pop a until there's a common ancestor while !b.starts_with(&a) { dist += 1; a.pop(); } b.len() - a.len() + dist } ================================================ FILE: crates/atuin/src/command/client/search/engines.rs ================================================ use async_trait::async_trait; use atuin_client::{ database::{Context, Database}, history::{History, HistoryId}, settings::{FilterMode, SearchMode, Settings}, }; use eyre::Result; use super::cursor::Cursor; #[cfg(feature = "daemon")] pub mod daemon; pub mod db; pub mod skim; #[allow(unused)] // settings is only used if daemon feature is enabled pub fn engine(search_mode: SearchMode, settings: &Settings) -> Box { match search_mode { SearchMode::Skim => Box::new(skim::Search::new()) as Box<_>, #[cfg(feature = "daemon")] SearchMode::DaemonFuzzy => Box::new(daemon::Search::new(settings)) as Box<_>, #[cfg(not(feature = "daemon"))] SearchMode::DaemonFuzzy => { // Fall back to fuzzy mode if daemon feature is not enabled Box::new(db::Search(SearchMode::Fuzzy)) as Box<_> } mode => Box::new(db::Search(mode)) as Box<_>, } } pub struct SearchState { pub input: Cursor, pub filter_mode: FilterMode, pub context: Context, pub custom_context: Option, } impl SearchState { pub(crate) fn rotate_filter_mode(&mut self, settings: &Settings, offset: isize) { let mut i = settings .search .filters .iter() .position(|&m| m == self.filter_mode) .unwrap_or_default(); for _ in 0..settings.search.filters.len() { i = (i.wrapping_add_signed(offset)) % settings.search.filters.len(); let mode = settings.search.filters[i]; if self.filter_mode_available(mode, settings) { self.filter_mode = mode; break; } } } fn filter_mode_available(&self, mode: FilterMode, settings: &Settings) -> bool { match mode { FilterMode::Global | FilterMode::SessionPreload => self.custom_context.is_none(), FilterMode::Workspace => settings.workspaces && self.context.git_root.is_some(), _ => true, } } } #[async_trait] pub trait SearchEngine: Send + Sync + 'static { async fn full_query( &mut self, state: &SearchState, db: &mut dyn Database, ) -> Result>; async fn query(&mut self, state: &SearchState, db: &mut dyn Database) -> Result> { if state.input.as_str().is_empty() { Ok(db .list(&[state.filter_mode], &state.context, Some(200), true, false) .await? .into_iter() .collect::>()) } else { self.full_query(state, db).await } } fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec; } ================================================ FILE: crates/atuin/src/command/client/search/history_list.rs ================================================ use std::time::Duration; use super::duration::format_duration; use super::engines::SearchEngine; use atuin_client::{ history::History, settings::{UiColumn, UiColumnType}, theme::{Meaning, Theme}, }; use atuin_common::utils::Escapable as _; use itertools::Itertools; use ratatui::{ backend::FromCrossterm, buffer::Buffer, crossterm::style, layout::Rect, style::{Modifier, Style}, widgets::{Block, StatefulWidget, Widget}, }; use time::OffsetDateTime; pub struct HistoryHighlighter<'a> { pub engine: &'a dyn SearchEngine, pub search_input: &'a str, } impl HistoryHighlighter<'_> { pub fn get_highlight_indices(&self, command: &str) -> Vec { self.engine .get_highlight_indices(command, self.search_input) } } pub struct HistoryList<'a> { history: &'a [History], block: Option>, inverted: bool, /// Apply an alternative highlighting to the selected row alternate_highlight: bool, now: &'a dyn Fn() -> OffsetDateTime, indicator: &'a str, theme: &'a Theme, history_highlighter: HistoryHighlighter<'a>, show_numeric_shortcuts: bool, /// Columns to display (in order, after the indicator) columns: &'a [UiColumn], } #[derive(Default)] pub struct ListState { offset: usize, selected: usize, max_entries: usize, } impl ListState { pub fn selected(&self) -> usize { self.selected } pub fn max_entries(&self) -> usize { self.max_entries } pub fn offset(&self) -> usize { self.offset } pub fn select(&mut self, index: usize) { self.selected = index; } } impl StatefulWidget for HistoryList<'_> { type State = ListState; fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let list_area = self.block.take().map_or(area, |b| { let inner_area = b.inner(area); b.render(area, buf); inner_area }); if list_area.width < 1 || list_area.height < 1 || self.history.is_empty() { return; } let list_height = list_area.height as usize; let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height); state.offset = start; state.max_entries = end - start; let mut s = DrawState { buf, list_area, x: 0, y: 0, state, inverted: self.inverted, alternate_highlight: self.alternate_highlight, now: &self.now, indicator: self.indicator, theme: self.theme, history_highlighter: self.history_highlighter, show_numeric_shortcuts: self.show_numeric_shortcuts, columns: self.columns, }; for item in self.history.iter().skip(state.offset).take(end - start) { s.render_row(item); // reset line s.y += 1; s.x = 0; } } } impl<'a> HistoryList<'a> { #[allow(clippy::too_many_arguments)] pub fn new( history: &'a [History], inverted: bool, alternate_highlight: bool, now: &'a dyn Fn() -> OffsetDateTime, indicator: &'a str, theme: &'a Theme, history_highlighter: HistoryHighlighter<'a>, show_numeric_shortcuts: bool, columns: &'a [UiColumn], ) -> Self { Self { history, block: None, inverted, alternate_highlight, now, indicator, theme, history_highlighter, show_numeric_shortcuts, columns, } } pub fn block(mut self, block: Block<'a>) -> Self { self.block = Some(block); self } fn get_items_bounds(&self, selected: usize, offset: usize, height: usize) -> (usize, usize) { let offset = offset.min(self.history.len().saturating_sub(1)); let max_scroll_space = height.min(10).min(self.history.len() - selected); if offset + height < selected + max_scroll_space { let end = selected + max_scroll_space; (end - height, end) } else if selected < offset { (selected, selected + height) } else { (offset, offset + height) } } } struct DrawState<'a> { buf: &'a mut Buffer, list_area: Rect, x: u16, y: u16, state: &'a ListState, inverted: bool, alternate_highlight: bool, now: &'a dyn Fn() -> OffsetDateTime, indicator: &'a str, theme: &'a Theme, history_highlighter: HistoryHighlighter<'a>, show_numeric_shortcuts: bool, columns: &'a [UiColumn], } // these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form. // Yes, this is a hack, but it makes me feel happy static SLICES: &str = " > 1 2 3 4 5 6 7 8 9 "; impl DrawState<'_> { /// Render a complete row for a history item based on configured columns. fn render_row(&mut self, h: &History) { // Always render the indicator first (width 3) self.index(); // Calculate the width for the expanding column // Fixed columns use their configured width + 1 (trailing space) let indicator_width: u16 = 3; let fixed_width: u16 = self .columns .iter() .filter(|c| !c.expand) .map(|c| c.width + 1) .sum(); let expand_width = self .list_area .width .saturating_sub(indicator_width + fixed_width); let style = self.theme.as_style(Meaning::Base); // Render each configured column for (idx, column) in self.columns.iter().enumerate() { if idx != 0 { self.draw(" ", Style::from_crossterm(style)); } let width = if column.expand { expand_width } else { column.width }; match column.column_type { UiColumnType::Duration => self.duration(h, width), UiColumnType::Time => self.time(h, width), UiColumnType::Datetime => self.datetime(h, width), UiColumnType::Directory => self.directory(h, width), UiColumnType::Host => self.host(h, width), UiColumnType::User => self.user(h, width), UiColumnType::Exit => self.exit_code(h, width), UiColumnType::Command => self.command(h), } } } fn index(&mut self) { if !self.show_numeric_shortcuts { let i = self.y as usize + self.state.offset; let is_selected = i == self.state.selected(); let prompt: &str = if is_selected { self.indicator } else { " " }; self.draw(prompt, Style::default()); return; } // these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form. // Yes, this is a hack, but it makes me feel happy let i = self.y as usize + self.state.offset; let i = i.checked_sub(self.state.selected); let i = i.unwrap_or(10).min(10) * 2; let prompt: &str = if i == 0 { self.indicator } else { &SLICES[i..i + 3] }; self.draw(prompt, Style::default()); } fn duration(&mut self, h: &History, width: u16) { let style = self.theme.as_style(if h.success() { Meaning::AlertInfo } else { Meaning::AlertError }); let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0)); let formatted = format_duration(duration); let w = width as usize; // Right-align duration within its column width, plus trailing space let display = format!("{formatted:>w$}"); self.draw(&display, Style::from_crossterm(style)); } fn time(&mut self, h: &History, width: u16) { let style = self.theme.as_style(Meaning::Guidance); // Account for the chance that h.timestamp is "in the future" // This would mean that "since" is negative, and the unwrap here // would fail. // If the timestamp would otherwise be in the future, display // the time since as 0. let since = (self.now)() - h.timestamp; let time = format_duration(since.try_into().unwrap_or_default()); // Format as "Xs ago" right-aligned within column width let w = width as usize; let time_str = format!("{time} ago"); let display = format!("{time_str:>w$}"); self.draw(&display, Style::from_crossterm(style)); } fn command(&mut self, h: &History) { let mut style = self.theme.as_style(Meaning::Base); let mut row_highlighted = false; if !self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected) { row_highlighted = true; // if not applying alternative highlighting to the whole row, color the command style = self.theme.as_style(Meaning::AlertError); style.attributes.set(style::Attribute::Bold); } let highlight_indices = self.history_highlighter.get_highlight_indices( h.command .escape_control() .split_ascii_whitespace() .join(" ") .as_str(), ); let mut pos = 0; for section in h.command.escape_control().split_ascii_whitespace() { if pos != 0 { self.draw(" ", Style::from_crossterm(style)); } for ch in section.chars() { if self.x > self.list_area.width { // Avoid attempting to draw a command section beyond the width // of the list return; } let mut style = style; if highlight_indices.contains(&pos) { if row_highlighted { // if the row is highlighted bold is not enough as the whole row is bold // change the color too style = self.theme.as_style(Meaning::AlertWarn); } style.attributes.set(style::Attribute::Bold); } let s = ch.to_string(); self.draw(&s, Style::from_crossterm(style)); pos += s.len(); } pos += 1; } } /// Render the absolute datetime column (e.g., "2025-01-22 14:35") fn datetime(&mut self, h: &History, width: u16) { let style = self.theme.as_style(Meaning::Annotation); // Format: YYYY-MM-DD HH:MM let formatted = h .timestamp .format( &time::format_description::parse("[year]-[month]-[day] [hour]:[minute]") .expect("valid format"), ) .unwrap_or_else(|_| "????-??-?? ??:??".to_string()); let w = width as usize; let display = format!("{formatted:w$}"); self.draw(&display, Style::from_crossterm(style)); } /// Render the directory column (working directory, truncated) fn directory(&mut self, h: &History, width: u16) { let style = self.theme.as_style(Meaning::Annotation); let w = width as usize; let cwd = &h.cwd; let char_count = cwd.chars().count(); // Truncate from the left with "..." if too long, plus trailing space // Use character count for comparison and skip for UTF-8 safety let display = if char_count > w && w >= 4 { let truncated: String = cwd.chars().skip(char_count - (w - 3)).collect(); format!("...{truncated}") } else { format!("{cwd:w$}") }; self.draw(&display, Style::from_crossterm(style)); } /// Render the host column (just the hostname) fn host(&mut self, h: &History, width: u16) { let style = self.theme.as_style(Meaning::Annotation); let w = width as usize; // Database stores hostname as "hostname:username" let host = h.hostname.split(':').next().unwrap_or(&h.hostname); let char_count = host.chars().count(); // Use character count for comparison and take for UTF-8 safety let display = if char_count > w && w >= 4 { let truncated: String = host.chars().take(w.saturating_sub(4)).collect(); format!("{truncated}...") } else { format!("{host:w$}") }; self.draw(&display, Style::from_crossterm(style)); } /// Render the user column fn user(&mut self, h: &History, width: u16) { let style = self.theme.as_style(Meaning::Annotation); let w = width as usize; // Database stores hostname as "hostname:username" let user = h.hostname.split(':').nth(1).unwrap_or(""); let char_count = user.chars().count(); // Use character count for comparison and take for UTF-8 safety let display = if char_count > w && w >= 4 { let truncated: String = user.chars().take(w.saturating_sub(4)).collect(); format!("{truncated}...") } else { format!("{user:w$}") }; self.draw(&display, Style::from_crossterm(style)); } /// Render the exit code column fn exit_code(&mut self, h: &History, width: u16) { let style = if h.success() { self.theme.as_style(Meaning::AlertInfo) } else { self.theme.as_style(Meaning::AlertError) }; let w = width as usize; let display = format!("{:>w$}", h.exit); self.draw(&display, Style::from_crossterm(style)); } fn draw(&mut self, s: &str, mut style: Style) { let cx = self.list_area.left() + self.x; let cy = if self.inverted { self.list_area.top() + self.y } else { self.list_area.bottom() - self.y - 1 }; if self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected) { style = style.add_modifier(Modifier::REVERSED); } let w = (self.list_area.width - self.x) as usize; self.x += self.buf.set_stringn(cx, cy, s, w, style).0 - cx; } } ================================================ FILE: crates/atuin/src/command/client/search/inspector.rs ================================================ use std::time::Duration; use time::macros::format_description; use atuin_client::{ history::{History, HistoryStats}, settings::{Settings, Timezone}, }; use ratatui::{ Frame, backend::FromCrossterm, layout::Rect, prelude::{Constraint, Direction, Layout}, style::Style, text::{Span, Text}, widgets::{Bar, BarChart, BarGroup, Block, Borders, Padding, Paragraph, Row, Table}, }; use super::duration::format_duration; use super::super::theme::{Meaning, Theme}; use super::interactive::{Compactness, to_compactness}; #[allow(clippy::cast_sign_loss)] fn u64_or_zero(num: i64) -> u64 { if num < 0 { 0 } else { num as u64 } } pub fn draw_commands( f: &mut Frame<'_>, parent: Rect, history: &History, stats: &HistoryStats, compact: bool, theme: &Theme, ) { let commands = Layout::default() .direction(if compact { Direction::Vertical } else { Direction::Horizontal }) .constraints(if compact { [ Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ] } else { [ Constraint::Ratio(1, 4), Constraint::Ratio(1, 2), Constraint::Ratio(1, 4), ] }) .split(parent); let command = Paragraph::new(Text::from(Span::styled( history.command.clone(), Style::from_crossterm(theme.as_style(Meaning::Important)), ))) .block(if compact { Block::new() .borders(Borders::NONE) .style(Style::from_crossterm(theme.as_style(Meaning::Base))) } else { Block::new() .borders(Borders::ALL) .style(Style::from_crossterm(theme.as_style(Meaning::Base))) .title("Command") .padding(Padding::horizontal(1)) }); let previous = Paragraph::new( stats .previous .clone() .map_or_else(|| "[No previous command]".to_string(), |prev| prev.command), ) .block(if compact { Block::new() .borders(Borders::NONE) .style(Style::from_crossterm(theme.as_style(Meaning::Annotation))) } else { Block::new() .borders(Borders::ALL) .style(Style::from_crossterm(theme.as_style(Meaning::Annotation))) .title("Previous command") .padding(Padding::horizontal(1)) }); // Add [] around blank text, as when this is shown in a list // compacted, it makes it more obviously control text. let next = Paragraph::new( stats .next .clone() .map_or_else(|| "[No next command]".to_string(), |next| next.command), ) .block(if compact { Block::new() .borders(Borders::NONE) .style(Style::from_crossterm(theme.as_style(Meaning::Annotation))) } else { Block::new() .borders(Borders::ALL) .title("Next command") .padding(Padding::horizontal(1)) .style(Style::from_crossterm(theme.as_style(Meaning::Annotation))) }); f.render_widget(previous, commands[0]); f.render_widget(command, commands[1]); f.render_widget(next, commands[2]); } pub fn draw_stats_table( f: &mut Frame<'_>, parent: Rect, history: &History, tz: Timezone, stats: &HistoryStats, theme: &Theme, ) { let duration = Duration::from_nanos(u64_or_zero(history.duration)); let avg_duration = Duration::from_nanos(stats.average_duration); let (host, user) = history.hostname.split_once(':').unwrap_or(("", "")); let rows = [ Row::new(vec!["Host".to_string(), host.to_string()]), Row::new(vec!["User".to_string(), user.to_string()]), Row::new(vec![ "Time".to_string(), history.timestamp.to_offset(tz.0).to_string(), ]), Row::new(vec!["Duration".to_string(), format_duration(duration)]), Row::new(vec![ "Avg duration".to_string(), format_duration(avg_duration), ]), Row::new(vec!["Exit".to_string(), history.exit.to_string()]), Row::new(vec!["Directory".to_string(), history.cwd.clone()]), Row::new(vec!["Session".to_string(), history.session.clone()]), Row::new(vec!["Total runs".to_string(), stats.total.to_string()]), ]; let widths = [Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)]; let table = Table::new(rows, widths).column_spacing(1).block( Block::default() .title("Command stats") .borders(Borders::ALL) .style(Style::from_crossterm(theme.as_style(Meaning::Base))) .padding(Padding::vertical(1)), ); f.render_widget(table, parent); } fn num_to_day(num: &str) -> String { match num { "0" => "Sunday".to_string(), "1" => "Monday".to_string(), "2" => "Tuesday".to_string(), "3" => "Wednesday".to_string(), "4" => "Thursday".to_string(), "5" => "Friday".to_string(), "6" => "Saturday".to_string(), _ => "Invalid day".to_string(), } } fn sort_duration_over_time(durations: &[(String, i64)]) -> Vec<(String, i64)> { let format = format_description!("[day]-[month]-[year]"); let output = format_description!("[month]/[year repr:last_two]"); let mut durations: Vec<(time::Date, i64)> = durations .iter() .map(|d| { ( time::Date::parse(d.0.as_str(), &format).expect("invalid date string from sqlite"), d.1, ) }) .collect(); durations.sort_by(|a, b| a.0.cmp(&b.0)); durations .iter() .map(|(date, duration)| { ( date.format(output).expect("failed to format sqlite date"), *duration, ) }) .collect() } fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats, theme: &Theme) { let exits: Vec = stats .exits .iter() .map(|(exit, count)| { Bar::default() .label(exit.to_string()) .value(u64_or_zero(*count)) }) .collect(); let exits = BarChart::default() .block( Block::default() .title("Exit code distribution") .style(Style::from_crossterm(theme.as_style(Meaning::Base))) .borders(Borders::ALL), ) .bar_width(3) .bar_gap(1) .bar_style(Style::default()) .value_style(Style::default()) .label_style(Style::default()) .data(BarGroup::default().bars(&exits)); let day_of_week: Vec = stats .day_of_week .iter() .map(|(day, count)| { Bar::default() .label(num_to_day(day.as_str())) .value(u64_or_zero(*count)) }) .collect(); let day_of_week = BarChart::default() .block( Block::default() .title("Runs per day") .style(Style::from_crossterm(theme.as_style(Meaning::Base))) .borders(Borders::ALL), ) .bar_width(3) .bar_gap(1) .bar_style(Style::default()) .value_style(Style::default()) .label_style(Style::default()) .data(BarGroup::default().bars(&day_of_week)); let duration_over_time = sort_duration_over_time(&stats.duration_over_time); let duration_over_time: Vec = duration_over_time .iter() .map(|(date, duration)| { let d = Duration::from_nanos(u64_or_zero(*duration)); Bar::default() .label(date.clone()) .value(u64_or_zero(*duration)) .text_value(format_duration(d)) }) .collect(); let duration_over_time = BarChart::default() .block( Block::default() .title("Duration over time") .style(Style::from_crossterm(theme.as_style(Meaning::Base))) .borders(Borders::ALL), ) .bar_width(5) .bar_gap(1) .bar_style(Style::default()) .value_style(Style::default()) .label_style(Style::default()) .data(BarGroup::default().bars(&duration_over_time)); let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), ]) .split(parent); f.render_widget(exits, layout[0]); f.render_widget(day_of_week, layout[1]); f.render_widget(duration_over_time, layout[2]); } pub fn draw( f: &mut Frame<'_>, chunk: Rect, history: &History, stats: &HistoryStats, settings: &Settings, theme: &Theme, tz: Timezone, ) { let compactness = to_compactness(f, settings); match compactness { Compactness::Ultracompact => draw_ultracompact(f, chunk, history, stats, theme), _ => draw_full(f, chunk, history, stats, theme, tz), } } pub fn draw_ultracompact( f: &mut Frame<'_>, chunk: Rect, history: &History, stats: &HistoryStats, theme: &Theme, ) { draw_commands(f, chunk, history, stats, true, theme); } pub fn draw_full( f: &mut Frame<'_>, chunk: Rect, history: &History, stats: &HistoryStats, theme: &Theme, tz: Timezone, ) { let vert_layout = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)]) .split(chunk); let stats_layout = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) .split(vert_layout[1]); draw_commands(f, vert_layout[0], history, stats, false, theme); draw_stats_table(f, stats_layout[0], history, tz, stats, theme); draw_stats_charts(f, stats_layout[1], stats, theme); } #[cfg(test)] mod tests { use super::draw_ultracompact; use atuin_client::{ history::{History, HistoryId, HistoryStats}, theme::ThemeManager, }; use ratatui::{backend::TestBackend, prelude::*}; use time::OffsetDateTime; fn mock_history_stats() -> (History, HistoryStats) { let history = History { id: HistoryId::from("test1".to_string()), timestamp: OffsetDateTime::now_utc(), duration: 3, exit: 0, command: "/bin/cmd".to_string(), cwd: "/toot".to_string(), session: "sesh1".to_string(), hostname: "hostn".to_string(), author: "hostn".to_string(), intent: None, deleted_at: None, }; let next = History { id: HistoryId::from("test2".to_string()), timestamp: OffsetDateTime::now_utc(), duration: 2, exit: 0, command: "/bin/cmd -os".to_string(), cwd: "/toot".to_string(), session: "sesh1".to_string(), hostname: "hostn".to_string(), author: "hostn".to_string(), intent: None, deleted_at: None, }; let prev = History { id: HistoryId::from("test3".to_string()), timestamp: OffsetDateTime::now_utc(), duration: 1, exit: 0, command: "/bin/cmd -a".to_string(), cwd: "/toot".to_string(), session: "sesh1".to_string(), hostname: "hostn".to_string(), author: "hostn".to_string(), intent: None, deleted_at: None, }; let stats = HistoryStats { next: Some(next.clone()), previous: Some(prev.clone()), total: 2, average_duration: 3, exits: Vec::new(), day_of_week: Vec::new(), duration_over_time: Vec::new(), }; (history, stats) } #[test] fn test_output_looks_correct_for_ultracompact() { let backend = TestBackend::new(22, 5); let mut terminal = Terminal::new(backend).expect("Could not create terminal"); let chunk = Rect::new(0, 0, 22, 5); let (history, stats) = mock_history_stats(); let prev = stats.previous.clone().unwrap(); let next = stats.next.clone().unwrap(); let mut manager = ThemeManager::new(Some(true), Some("".to_string())); let theme = manager.load_theme("(none)", None); let _ = terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats, &theme)); let mut lines = [" "; 5].map(|l| Line::from(l)); for (n, entry) in [prev, history, next].iter().enumerate() { let mut l = lines[n].to_string(); l.replace_range(0..entry.command.len(), &entry.command); lines[n] = Line::from(l); } terminal.backend().assert_buffer_lines(lines); } } ================================================ FILE: crates/atuin/src/command/client/search/interactive.rs ================================================ use std::{ io::{IsTerminal, Write, stdout}, time::Duration, }; #[cfg(unix)] use std::io::Read as _; use atuin_common::{shell::Shell, utils::Escapable as _}; use eyre::Result; use futures_util::FutureExt; use semver::Version; use time::OffsetDateTime; use unicode_width::UnicodeWidthStr; use super::{ cursor::Cursor, engines::{SearchEngine, SearchState}, history_list::{HistoryList, ListState}, }; use atuin_client::{ database::{Context, Database, current_context}, history::{History, HistoryId, HistoryStats, store::HistoryStore}, settings::{ CursorStyle, ExitMode, FilterMode, KeymapMode, PreviewStrategy, SearchMode, Settings, UiColumn, }, }; use crate::command::client::search::history_list::HistoryHighlighter; use crate::command::client::search::keybindings::KeymapSet; use crate::command::client::theme::{Meaning, Theme}; use crate::{VERSION, command::client::search::engines}; use ratatui::{ Frame, Terminal, TerminalOptions, Viewport, backend::{CrosstermBackend, FromCrossterm}, crossterm::{ cursor::SetCursorStyle, event::{self, Event, KeyEvent, MouseEvent}, execute, queue, terminal, }, layout::{Alignment, Constraint, Direction, Layout}, prelude::*, style::{Modifier, Style}, text::{Line, Span, Text}, widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Tabs}, }; #[cfg(not(target_os = "windows"))] use ratatui::crossterm::event::{ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; const TAB_TITLES: [&str; 2] = ["Search", "Inspect"]; pub enum InputAction { Accept(usize), AcceptInspecting, Copy(usize), Delete(usize), ReturnOriginal, ReturnQuery, Continue, Redraw, SwitchContext(Option), } #[derive(Clone)] pub struct InspectingState { current: Option, next: Option, previous: Option, } impl InspectingState { pub fn move_to_previous(&mut self) { let previous = self.previous.clone(); self.reset(); self.current = previous; } pub fn move_to_next(&mut self) { let next = self.next.clone(); self.reset(); self.current = next; } pub fn reset(&mut self) { self.current = None; self.next = None; self.previous = None; } } pub fn to_compactness(f: &Frame, settings: &Settings) -> Compactness { if match settings.style { atuin_client::settings::Style::Auto => f.area().height < 14, atuin_client::settings::Style::Compact => true, atuin_client::settings::Style::Full => false, } { if settings.auto_hide_height != 0 && f.area().height <= settings.auto_hide_height { Compactness::Ultracompact } else { Compactness::Compact } } else { Compactness::Full } } #[allow(clippy::struct_field_names)] #[allow(clippy::struct_excessive_bools)] pub struct State { history_count: i64, update_needed: Option, results_state: ListState, switched_search_mode: bool, search_mode: SearchMode, results_len: usize, accept: bool, keymap_mode: KeymapMode, prefix: bool, current_cursor: Option, tab_index: usize, pending_vim_key: Option, original_input_empty: bool, pub inspecting_state: InspectingState, keymaps: KeymapSet, search: SearchState, engine: Box, now: Box OffsetDateTime + Send>, } #[derive(Clone, Copy)] pub enum Compactness { Ultracompact, Compact, Full, } #[derive(Clone, Copy)] struct StyleState { compactness: Compactness, invert: bool, inner_width: usize, } impl State { async fn query_results( &mut self, db: &mut dyn Database, smart_sort: bool, ) -> Result> { let results = self.engine.query(&self.search, db).await?; self.inspecting_state = InspectingState { current: None, next: None, previous: None, }; self.results_state.select(0); self.results_len = results.len(); if smart_sort { Ok(atuin_history::sort::sort( self.search.input.as_str(), results, )) } else { Ok(results) } } fn handle_input(&mut self, settings: &Settings, input: &Event) -> InputAction { match input { Event::Key(k) => self.handle_key_input(settings, k), Event::Mouse(m) => self.handle_mouse_input(*m), Event::Paste(d) => self.handle_paste_input(d), _ => InputAction::Continue, } } fn handle_mouse_input(&mut self, input: MouseEvent) -> InputAction { match input.kind { event::MouseEventKind::ScrollDown => { self.scroll_down(1); } event::MouseEventKind::ScrollUp => { self.scroll_up(1); } _ => {} } InputAction::Continue } fn handle_paste_input(&mut self, input: &str) -> InputAction { for i in input.chars() { self.search.input.insert(i); } InputAction::Continue } fn cast_cursor_style(style: CursorStyle) -> SetCursorStyle { match style { CursorStyle::DefaultUserShape => SetCursorStyle::DefaultUserShape, CursorStyle::BlinkingBlock => SetCursorStyle::BlinkingBlock, CursorStyle::SteadyBlock => SetCursorStyle::SteadyBlock, CursorStyle::BlinkingUnderScore => SetCursorStyle::BlinkingUnderScore, CursorStyle::SteadyUnderScore => SetCursorStyle::SteadyUnderScore, CursorStyle::BlinkingBar => SetCursorStyle::BlinkingBar, CursorStyle::SteadyBar => SetCursorStyle::SteadyBar, } } fn set_keymap_cursor(&mut self, settings: &Settings, keymap_name: &str) { let cursor_style = if keymap_name == "__clear__" { None } else { settings.keymap_cursor.get(keymap_name).copied() } .or_else(|| self.current_cursor.map(|_| CursorStyle::DefaultUserShape)); if cursor_style != self.current_cursor && let Some(style) = cursor_style { self.current_cursor = cursor_style; let _ = execute!(stdout(), Self::cast_cursor_style(style)); } } pub fn initialize_keymap_cursor(&mut self, settings: &Settings) { match self.keymap_mode { KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"), KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"), KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"), KeymapMode::Auto => {} } } pub fn finalize_keymap_cursor(&mut self, settings: &Settings) { match settings.keymap_mode_shell { KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"), KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"), KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"), KeymapMode::Auto => self.set_keymap_cursor(settings, "__clear__"), } } fn handle_key_exit(settings: &Settings) -> InputAction { match settings.exit_mode { ExitMode::ReturnOriginal => InputAction::ReturnOriginal, ExitMode::ReturnQuery => InputAction::ReturnQuery, } } /// Select the keymap for the current mode (ignoring prefix). fn mode_keymap(&self) -> &super::keybindings::Keymap { if self.tab_index == 1 { &self.keymaps.inspector } else { match self.keymap_mode { KeymapMode::Emacs | KeymapMode::Auto => &self.keymaps.emacs, KeymapMode::VimNormal => &self.keymaps.vim_normal, KeymapMode::VimInsert => &self.keymaps.vim_insert, } } } /// Whether the current mode supports character insertion on unmatched keys. fn is_insert_mode(&self) -> bool { matches!( self.keymap_mode, KeymapMode::Emacs | KeymapMode::Auto | KeymapMode::VimInsert ) } fn handle_key_input(&mut self, settings: &Settings, input: &KeyEvent) -> InputAction { use super::keybindings::Action; use super::keybindings::EvalContext; use super::keybindings::key::{KeyCodeValue, KeyInput, SingleKey}; // Skip release events if input.kind == event::KeyEventKind::Release { return InputAction::Continue; } // Reset switched_search_mode at start of each key event self.switched_search_mode = false; // Build evaluation context from current state let ctx = EvalContext { cursor_position: self.search.input.position(), input_width: UnicodeWidthStr::width(self.search.input.as_str()), input_byte_len: self.search.input.as_str().len(), selected_index: self.results_state.selected(), results_len: self.results_len, original_input_empty: self.original_input_empty, has_context: self.search.custom_context.is_some(), }; // Convert KeyEvent to SingleKey let Some(single) = SingleKey::from_event(input) else { return InputAction::Continue; }; // --- Phase 1: Resolve (take pending key first, then immutable borrows) --- // Take pending key before any immutable borrows of self let pending = self.pending_vim_key.take(); // If in prefix mode, try prefix keymap first (single keys only) let prefix_action = if self.prefix { let ki = KeyInput::Single(single.clone()); self.keymaps.prefix.resolve(&ki, &ctx) } else { None }; // The if-let/else-if chain here is clearer than map_or_else with nested closures. #[allow(clippy::option_if_let_else)] let (action, new_pending) = if prefix_action.is_some() { (prefix_action, None) } else { // Use mode keymap (handles both single and multi-key sequences) let keymap = self.mode_keymap(); if let Some(pending_char) = pending { // We have a pending key from a previous press (e.g., first 'g' of 'gg') let pending_single = SingleKey { code: KeyCodeValue::Char(pending_char), ctrl: false, alt: false, shift: false, super_key: false, }; let seq = KeyInput::Sequence(vec![pending_single, single.clone()]); let action = keymap .resolve(&seq, &ctx) .or_else(|| keymap.resolve(&KeyInput::Single(single.clone()), &ctx)); (action, None) } else if keymap.has_sequence_starting_with(&single) && matches!(single.code, KeyCodeValue::Char(_)) && !single.ctrl && !single.alt { // This key starts a multi-key sequence; wait for next key let KeyCodeValue::Char(c) = single.code else { unreachable!() }; (Some(Action::Noop), Some(c)) } else { ( keymap.resolve(&KeyInput::Single(single.clone()), &ctx), None, ) } }; // --- Phase 2: Apply mutations --- self.pending_vim_key = new_pending; // Reset prefix (before execute, so EnterPrefixMode can re-set it) self.prefix = false; if let Some(action) = action { self.execute_action(&action, settings) } else { // No action matched. In insert-capable modes, insert the character. if self.is_insert_mode() && !single.ctrl && !single.alt { match single.code { KeyCodeValue::Char(c) => { self.search.input.insert(c); } KeyCodeValue::Space => { self.search.input.insert(' '); } _ => {} } } InputAction::Continue } } fn scroll_down(&mut self, scroll_len: usize) { let i = self.results_state.selected().saturating_sub(scroll_len); self.inspecting_state.reset(); self.results_state.select(i); } fn scroll_up(&mut self, scroll_len: usize) { let i = self.results_state.selected() + scroll_len; self.results_state .select(i.min(self.results_len.saturating_sub(1))); self.inspecting_state.reset(); } /// Execute a resolved action, performing all side effects and returning the /// appropriate `InputAction` for the event loop. /// /// This is the "do it" half of the resolve+execute pipeline. The resolver /// decides *what* to do (which `Action`), and this function carries it out. /// /// Invert handling: scroll actions (`SelectNext`, `ScrollPageDown`, etc.) account /// for `settings.invert` so that keybindings are always in "visual" terms — /// users never need to think about invert in their keybinding config. #[allow(clippy::too_many_lines)] pub(crate) fn execute_action( &mut self, action: &super::keybindings::Action, settings: &Settings, ) -> InputAction { use crate::command::client::search::keybindings::Action; match action { // -- Cursor movement -- Action::CursorLeft => { self.search.input.left(); InputAction::Continue } Action::CursorRight => { self.search.input.right(); InputAction::Continue } Action::CursorWordLeft => { self.search .input .prev_word(&settings.word_chars, settings.word_jump_mode); InputAction::Continue } Action::CursorWordRight => { self.search .input .next_word(&settings.word_chars, settings.word_jump_mode); InputAction::Continue } Action::CursorWordEnd => { self.search.input.word_end(&settings.word_chars); InputAction::Continue } Action::CursorStart => { self.search.input.start(); InputAction::Continue } Action::CursorEnd => { self.search.input.end(); InputAction::Continue } // -- Editing -- Action::DeleteCharBefore => { self.search.input.back(); InputAction::Continue } Action::DeleteCharAfter => { self.search.input.remove(); InputAction::Continue } Action::DeleteWordBefore => { self.search .input .remove_prev_word(&settings.word_chars, settings.word_jump_mode); InputAction::Continue } Action::DeleteWordAfter => { self.search .input .remove_next_word(&settings.word_chars, settings.word_jump_mode); InputAction::Continue } Action::DeleteToWordBoundary => { // ctrl-w: remove trailing whitespace, then delete to word boundary while matches!(self.search.input.back(), Some(c) if c.is_whitespace()) {} while self.search.input.left() { if self.search.input.char().unwrap().is_whitespace() { self.search.input.right(); break; } self.search.input.remove(); } InputAction::Continue } Action::ClearLine => { self.search.input.clear(); InputAction::Continue } Action::ClearToStart => { self.search.input.clear_to_start(); InputAction::Continue } Action::ClearToEnd => { self.search.input.clear_to_end(); InputAction::Continue } // -- List navigation (invert-aware) -- Action::SelectNext => { if settings.invert { self.scroll_up(1); } else { self.scroll_down(1); } InputAction::Continue } Action::SelectPrevious => { if settings.invert { self.scroll_down(1); } else { self.scroll_up(1); } InputAction::Continue } // -- Page/half-page scroll (invert-aware) -- Action::ScrollHalfPageUp => { let scroll_len = self .results_state .max_entries() .saturating_sub(settings.scroll_context_lines) / 2; if settings.invert { self.scroll_down(scroll_len); } else { self.scroll_up(scroll_len); } InputAction::Continue } Action::ScrollHalfPageDown => { let scroll_len = self .results_state .max_entries() .saturating_sub(settings.scroll_context_lines) / 2; if settings.invert { self.scroll_up(scroll_len); } else { self.scroll_down(scroll_len); } InputAction::Continue } Action::ScrollPageUp => { let scroll_len = self .results_state .max_entries() .saturating_sub(settings.scroll_context_lines); if settings.invert { self.scroll_down(scroll_len); } else { self.scroll_up(scroll_len); } InputAction::Continue } Action::ScrollPageDown => { let scroll_len = self .results_state .max_entries() .saturating_sub(settings.scroll_context_lines); if settings.invert { self.scroll_up(scroll_len); } else { self.scroll_down(scroll_len); } InputAction::Continue } // -- Absolute jumps (invert-aware) -- Action::ScrollToTop => { // Visual top of history if settings.invert { self.results_state.select(0); } else { let last_idx = self.results_len.saturating_sub(1); self.results_state.select(last_idx); } self.inspecting_state.reset(); InputAction::Continue } Action::ScrollToBottom => { // Visual bottom of history if settings.invert { let last_idx = self.results_len.saturating_sub(1); self.results_state.select(last_idx); } else { self.results_state.select(0); } self.inspecting_state.reset(); InputAction::Continue } Action::ScrollToScreenTop => { // H — jump to top of visible screen let top = self.results_state.offset(); let visible = self.results_state.max_entries().min(self.results_len); let bottom = top + visible.saturating_sub(1); self.results_state .select(bottom.min(self.results_len.saturating_sub(1))); self.inspecting_state.reset(); InputAction::Continue } Action::ScrollToScreenMiddle => { // M — jump to middle of visible screen let top = self.results_state.offset(); let visible = self.results_state.max_entries().min(self.results_len); let middle = top + visible / 2; self.results_state .select(middle.min(self.results_len.saturating_sub(1))); self.inspecting_state.reset(); InputAction::Continue } Action::ScrollToScreenBottom => { // L — jump to bottom of visible screen let top_visible = self.results_state.offset(); self.results_state.select(top_visible); self.inspecting_state.reset(); InputAction::Continue } // -- Commands -- Action::Accept => { if self.tab_index == 1 { return InputAction::AcceptInspecting; } self.accept = true; InputAction::Accept(self.results_state.selected()) } Action::AcceptNth(n) => { self.accept = true; InputAction::Accept(self.results_state.selected() + *n as usize) } Action::ReturnSelection => { if self.tab_index == 1 { return InputAction::AcceptInspecting; } InputAction::Accept(self.results_state.selected()) } Action::ReturnSelectionNth(n) => { InputAction::Accept(self.results_state.selected() + *n as usize) } Action::Copy => InputAction::Copy(self.results_state.selected()), Action::Delete => InputAction::Delete(self.results_state.selected()), Action::ReturnOriginal => InputAction::ReturnOriginal, Action::ReturnQuery => InputAction::ReturnQuery, Action::Exit => Self::handle_key_exit(settings), Action::Redraw => InputAction::Redraw, Action::CycleFilterMode => { self.search.rotate_filter_mode(settings, 1); InputAction::Continue } Action::CycleSearchMode => { self.switched_search_mode = true; self.search_mode = self.search_mode.next(settings); self.engine = engines::engine(self.search_mode, settings); InputAction::Continue } Action::SwitchContext => { InputAction::SwitchContext(Some(self.results_state.selected())) } Action::ClearContext => InputAction::SwitchContext(None), Action::ToggleTab => { self.tab_index = (self.tab_index + 1) % TAB_TITLES.len(); InputAction::Continue } // -- Mode changes -- Action::VimEnterNormal => { self.set_keymap_cursor(settings, "vim_normal"); self.keymap_mode = KeymapMode::VimNormal; InputAction::Continue } Action::VimEnterInsert => { self.set_keymap_cursor(settings, "vim_insert"); self.keymap_mode = KeymapMode::VimInsert; InputAction::Continue } Action::VimEnterInsertAfter => { self.search.input.right(); self.set_keymap_cursor(settings, "vim_insert"); self.keymap_mode = KeymapMode::VimInsert; InputAction::Continue } Action::VimEnterInsertAtStart => { self.search.input.start(); self.set_keymap_cursor(settings, "vim_insert"); self.keymap_mode = KeymapMode::VimInsert; InputAction::Continue } Action::VimEnterInsertAtEnd => { self.search.input.end(); self.set_keymap_cursor(settings, "vim_insert"); self.keymap_mode = KeymapMode::VimInsert; InputAction::Continue } Action::VimSearchInsert => { self.search.input.clear(); self.set_keymap_cursor(settings, "vim_insert"); self.keymap_mode = KeymapMode::VimInsert; InputAction::Continue } Action::VimChangeToEnd => { self.search.input.clear_to_end(); self.set_keymap_cursor(settings, "vim_insert"); self.keymap_mode = KeymapMode::VimInsert; InputAction::Continue } Action::EnterPrefixMode => { self.prefix = true; InputAction::Continue } // -- Inspector -- Action::InspectPrevious => { self.inspecting_state.move_to_previous(); InputAction::Redraw } Action::InspectNext => { self.inspecting_state.move_to_next(); InputAction::Redraw } // -- Special -- Action::Noop => InputAction::Continue, } } #[allow(clippy::cast_possible_truncation)] #[allow(clippy::bool_to_int_with_if)] fn calc_preview_height( settings: &Settings, results: &[History], selected: usize, tab_index: usize, compactness: Compactness, border_size: u16, preview_width: u16, ) -> u16 { if settings.show_preview && settings.preview.strategy == PreviewStrategy::Auto && tab_index == 0 && !results.is_empty() { let length_current_cmd = results[selected].command.len() as u16; // calculate the number of newlines in the command let num_newlines = results[selected] .command .chars() .filter(|&c| c == '\n') .count() as u16; if num_newlines > 0 { std::cmp::min( settings.max_preview_height, results[selected] .command .split('\n') .map(|line| { (line.len() as u16 + preview_width - 1 - border_size) / (preview_width - border_size) }) .sum(), ) + border_size * 2 } // The '- 19' takes the characters before the command (duration and time) into account else if length_current_cmd > preview_width - 19 { std::cmp::min( settings.max_preview_height, (length_current_cmd + preview_width - 1 - border_size) / (preview_width - border_size), ) + border_size * 2 } else { 1 } } else if settings.show_preview && settings.preview.strategy == PreviewStrategy::Static && tab_index == 0 { let longest_command = results .iter() .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len())); longest_command.map_or(0, |v| { std::cmp::min( settings.max_preview_height, v.command .split('\n') .map(|line| { (line.len() as u16 + preview_width - 1 - border_size) / (preview_width - border_size) }) .sum(), ) }) + border_size * 2 } else if settings.show_preview && settings.preview.strategy == PreviewStrategy::Fixed { settings.max_preview_height + border_size * 2 } else if !matches!(compactness, Compactness::Full) || tab_index == 1 { 0 } else { 1 } } #[allow(clippy::bool_to_int_with_if)] #[allow(clippy::too_many_lines)] #[allow(clippy::too_many_arguments)] fn draw( &mut self, f: &mut Frame, results: &[History], stats: Option, inspecting: Option<&History>, settings: &Settings, theme: &Theme, popup_mode: bool, ) { let area = f.area(); if popup_mode { f.render_widget(Clear, area); } self.draw_inner(f, area, results, stats, inspecting, settings, theme); } #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_lines)] #[allow(clippy::bool_to_int_with_if)] fn draw_inner( &mut self, f: &mut Frame, area: Rect, results: &[History], stats: Option, inspecting: Option<&History>, settings: &Settings, theme: &Theme, ) { let compactness = to_compactness(f, settings); let invert = settings.invert; let border_size = match compactness { Compactness::Full => 1, _ => 0, }; let preview_width = area.width.saturating_sub(2); let preview_height = Self::calc_preview_height( settings, results, self.results_state.selected(), self.tab_index, compactness, border_size, preview_width, ); let show_help = settings.show_help && (matches!(compactness, Compactness::Full) || area.height > 1); // This is an OR, as it seems more likely for someone to wish to override // tabs unexpectedly being missed, than unexpectedly present. let show_tabs = settings.show_tabs && !matches!(compactness, Compactness::Ultracompact); let chunks = Layout::default() .direction(Direction::Vertical) .margin(0) .horizontal_margin(1) .constraints::<&[Constraint]>( if invert { [ Constraint::Length(1 + border_size), // input Constraint::Min(1), // results list Constraint::Length(preview_height), // preview Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs Constraint::Length(if show_help { 1 } else { 0 }), // header (sic) ] } else { match compactness { Compactness::Ultracompact => [ Constraint::Length(if show_help { 1 } else { 0 }), // header Constraint::Length(0), // tabs Constraint::Min(1), // results list Constraint::Length(0), Constraint::Length(0), ], _ => [ Constraint::Length(if show_help { 1 } else { 0 }), // header Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs Constraint::Min(1), // results list Constraint::Length(1 + border_size), // input Constraint::Length(preview_height), // preview ], } } .as_ref(), ) .split(area); let input_chunk = if invert { chunks[0] } else { chunks[3] }; let results_list_chunk = if invert { chunks[1] } else { chunks[2] }; let preview_chunk = if invert { chunks[2] } else { chunks[4] }; let tabs_chunk = if invert { chunks[3] } else { chunks[1] }; let header_chunk = if invert { chunks[4] } else { chunks[0] }; // TODO: this should be split so that we have one interactive search container that is // EITHER a search box or an inspector. But I'm not doing that now, way too much atm. // also allocate less 🙈 let titles: Vec<_> = TAB_TITLES.iter().copied().map(Line::from).collect(); if show_tabs { let tabs = Tabs::new(titles) .block(Block::default().borders(Borders::NONE)) .select(self.tab_index) .style(Style::default()) .highlight_style(Style::from_crossterm(theme.as_style(Meaning::Important))); f.render_widget(tabs, tabs_chunk); } let style = StyleState { compactness, invert, inner_width: input_chunk.width.into(), }; let header_chunks = Layout::default() .direction(Direction::Horizontal) .constraints::<&[Constraint]>( [ Constraint::Ratio(1, 5), Constraint::Ratio(3, 5), Constraint::Ratio(1, 5), ] .as_ref(), ) .split(header_chunk); let title = self.build_title(theme); f.render_widget(title, header_chunks[0]); let help = self.build_help(settings, theme); f.render_widget(help, header_chunks[1]); let stats_tab = self.build_stats(theme); f.render_widget(stats_tab, header_chunks[2]); let indicator: String = match compactness { Compactness::Ultracompact => { if self.switched_search_mode { format!("S{}>", self.search_mode.as_str().chars().next().unwrap()) } else if self.search.custom_context.is_some() { format!( "C{}>", self.search.filter_mode.as_str().chars().next().unwrap() ) } else { format!( "{}> ", self.search.filter_mode.as_str().chars().next().unwrap() ) } } _ => " > ".to_string(), }; match self.tab_index { 0 => { let history_highlighter = HistoryHighlighter { engine: self.engine.as_ref(), search_input: self.search.input.as_str(), }; let results_list = Self::build_results_list( style, results, self.keymap_mode, &self.now, indicator.as_str(), theme, history_highlighter, settings.show_numeric_shortcuts, &settings.ui.columns, ); f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state); } 1 => { if results.is_empty() { let message = Paragraph::new("Nothing to inspect") .block( Block::new() .title(Line::from(" Info ".to_string())) .title_alignment(Alignment::Center) .borders(Borders::ALL) .padding(Padding::vertical(2)), ) .alignment(Alignment::Center); f.render_widget(message, results_list_chunk); } else { let inspecting = match inspecting { Some(inspecting) => inspecting, None => &results[self.results_state.selected()], }; super::inspector::draw( f, results_list_chunk, inspecting, &stats.expect("Drawing inspector, but no stats"), settings, theme, settings.timezone, ); } // HACK: I'm following up with abstracting this into the UI container, with a // sub-widget for search + for inspector let feedback = Paragraph::new( "The inspector is new - please give feedback (good, or bad) at https://forum.atuin.sh", ); f.render_widget(feedback, input_chunk); return; } _ => { panic!("invalid tab index"); } } if !matches!(compactness, Compactness::Ultracompact) { let preview_width = match compactness { Compactness::Full => preview_width - 2, _ => preview_width, }; let preview = self.build_preview( results, compactness, preview_width, preview_chunk.width.into(), theme, ); #[allow(clippy::cast_possible_truncation)] let prefix_width = settings .ui .columns .iter() .take_while(|col| !col.expand) .map(|col| col.width + 1) .sum::() + " > ".len() as u16; #[allow(clippy::cast_possible_truncation)] let min_prefix_width = "[ SRCH: FULLTXT ] ".len() as u16; self.draw_preview( f, style, input_chunk, compactness, preview_chunk, preview, std::cmp::max(prefix_width, min_prefix_width), ); } } #[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)] fn draw_preview( &self, f: &mut Frame, style: StyleState, input_chunk: Rect, compactness: Compactness, preview_chunk: Rect, preview: Paragraph, prefix_width: u16, ) { let input = self.build_input(style, prefix_width); f.render_widget(input, input_chunk); f.render_widget(preview, preview_chunk); let extra_width = UnicodeWidthStr::width(self.search.input.substring()); let cursor_offset = match compactness { Compactness::Full => 1, _ => 0, }; f.set_cursor_position(( // Put cursor past the end of the input text input_chunk.x + extra_width as u16 + prefix_width + cursor_offset, input_chunk.y + cursor_offset, )); } fn build_title(&self, theme: &Theme) -> Paragraph<'_> { let title = if self.update_needed.is_some() { let error_style: Style = Style::from_crossterm(theme.get_error()); Paragraph::new(Text::from(Span::styled( format!("Atuin v{VERSION} - UPDATE"), error_style.add_modifier(Modifier::BOLD), ))) } else { let style: Style = Style::from_crossterm(theme.as_style(Meaning::Base)); Paragraph::new(Text::from(Span::styled( format!("Atuin v{VERSION}"), style.add_modifier(Modifier::BOLD), ))) }; title.alignment(Alignment::Left) } #[allow(clippy::unused_self)] fn build_help(&self, settings: &Settings, theme: &Theme) -> Paragraph<'_> { match self.tab_index { // search 0 => Paragraph::new(Text::from(Line::from(vec![ Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(": exit"), Span::raw(", "), Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(": edit"), Span::raw(", "), Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(if settings.enter_accept { ": run" } else { ": edit" }), Span::raw(", "), Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(": inspect"), ]))), 1 => Paragraph::new(Text::from(Line::from(vec![ Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(": exit"), Span::raw(", "), Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(": search"), Span::raw(", "), Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(": delete"), ]))), _ => unreachable!("invalid tab index"), } .style(Style::from_crossterm(theme.as_style(Meaning::Annotation))) .alignment(Alignment::Center) } fn build_stats(&self, theme: &Theme) -> Paragraph<'_> { Paragraph::new(Text::from(Span::raw(format!( "history count: {}", self.history_count, )))) .style(Style::from_crossterm(theme.as_style(Meaning::Annotation))) .alignment(Alignment::Right) } #[allow(clippy::too_many_arguments)] fn build_results_list<'a>( style: StyleState, results: &'a [History], keymap_mode: KeymapMode, now: &'a dyn Fn() -> OffsetDateTime, indicator: &'a str, theme: &'a Theme, history_highlighter: HistoryHighlighter<'a>, show_numeric_shortcuts: bool, columns: &'a [UiColumn], ) -> HistoryList<'a> { let results_list = HistoryList::new( results, style.invert, keymap_mode == KeymapMode::VimNormal, now, indicator, theme, history_highlighter, show_numeric_shortcuts, columns, ); match style.compactness { Compactness::Full => { if style.invert { results_list.block( Block::default() .borders(Borders::LEFT | Borders::RIGHT) .border_type(BorderType::Rounded) .title(format!("{:─>width$}", "", width = style.inner_width - 2)), ) } else { results_list.block( Block::default() .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) .border_type(BorderType::Rounded), ) } } _ => results_list, } } fn build_input(&self, style: StyleState, prefix_width: u16) -> Paragraph<'_> { let (pref, mode) = if self.switched_search_mode { (" SRCH:", self.search_mode.as_str()) } else if self.search.custom_context.is_some() { (" CTX:", self.search.filter_mode.as_str()) } else { ("", self.search.filter_mode.as_str()) }; // 3: surrounding "[" "] " let mode_width = usize::from(prefix_width) - pref.len() - 3; // sanity check to ensure we don't exceed the layout limits debug_assert!(mode_width >= mode.len(), "mode name '{mode}' is too long!"); let input = format!("[{pref}{mode:^mode_width$}] {}", self.search.input.as_str(),); let input = Paragraph::new(input); match style.compactness { Compactness::Full => { if style.invert { input.block( Block::default() .borders(Borders::LEFT | Borders::RIGHT | Borders::TOP) .border_type(BorderType::Rounded), ) } else { input.block( Block::default() .borders(Borders::LEFT | Borders::RIGHT) .border_type(BorderType::Rounded) .title(format!("{:─>width$}", "", width = style.inner_width - 2)), ) } } _ => input, } } fn build_preview( &self, results: &[History], compactness: Compactness, preview_width: u16, chunk_width: usize, theme: &Theme, ) -> Paragraph<'_> { let selected = self.results_state.selected(); let command = if results.is_empty() { String::new() } else { use itertools::Itertools as _; let s = &results[selected].command; s.split('\n') .flat_map(|line| { line.char_indices() .step_by(preview_width.into()) .map(|(i, _)| i) .chain(Some(line.len())) .tuple_windows() .map(|(a, b)| (&line[a..b]).escape_control().to_string()) }) .join("\n") }; match compactness { Compactness::Full => Paragraph::new(command).block( Block::default() .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT) .border_type(BorderType::Rounded) .title(format!("{:─>width$}", "", width = chunk_width - 2)), ), _ => Paragraph::new(command) .style(Style::from_crossterm(theme.as_style(Meaning::Annotation))), } } } /// The writer used for terminal output - either stdout or /dev/tty enum TerminalWriter { Stdout(std::io::Stdout), #[cfg(unix)] Tty(std::fs::File), } impl Write for TerminalWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { match self { TerminalWriter::Stdout(stdout) => stdout.write(buf), #[cfg(unix)] TerminalWriter::Tty(file) => file.write(buf), } } fn flush(&mut self) -> std::io::Result<()> { match self { TerminalWriter::Stdout(stdout) => stdout.flush(), #[cfg(unix)] TerminalWriter::Tty(file) => file.flush(), } } } /// Screen state captured from atuin-hex's screen server. #[cfg(unix)] struct SavedScreen { #[allow(dead_code)] rows: u16, #[allow(dead_code)] cols: u16, cursor_row: u16, cursor_col: u16, /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout. rows_data: Vec>, } /// Connect to atuin-hex's Unix socket and fetch the current screen state. /// /// The wire format is: /// ```text /// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE] /// [row_0_len: u32 BE][row_0_bytes...] /// [row_1_len: u32 BE][row_1_bytes...] /// ... /// ``` #[cfg(unix)] fn fetch_screen_state(socket_path: &str) -> Option { use std::os::unix::net::UnixStream; let mut stream = UnixStream::connect(socket_path).ok()?; stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; let mut data = Vec::new(); stream.read_to_end(&mut data).ok()?; if data.len() < 8 { return None; } let rows = u16::from_be_bytes([data[0], data[1]]); let cols = u16::from_be_bytes([data[2], data[3]]); let cursor_row = u16::from_be_bytes([data[4], data[5]]); let cursor_col = u16::from_be_bytes([data[6], data[7]]); // Parse length-prefixed rows let mut rows_data = Vec::with_capacity(rows as usize); let mut offset = 8; while offset + 4 <= data.len() { let row_len = u32::from_be_bytes([ data[offset], data[offset + 1], data[offset + 2], data[offset + 3], ]) as usize; offset += 4; if offset + row_len > data.len() { break; } rows_data.push(data[offset..offset + row_len].to_vec()); offset += row_len; } Some(SavedScreen { rows, cols, cursor_row, cursor_col, rows_data, }) } /// Restore the screen area that was covered by the popup. /// /// Writes the pre-formatted per-row ANSI bytes received from atuin-hex /// directly to stdout, which correctly handles wide characters, colors, and /// all text attributes without needing a client-side vt100 parser. #[cfg(unix)] fn restore_popup_area(saved: &SavedScreen, popup_rect: Rect, scroll_offset: u16) { use ratatui::crossterm::cursor::MoveTo; let mut stdout = stdout(); for dy in 0..popup_rect.height { let target_row = popup_rect.y + dy; let source_row = (target_row + scroll_offset) as usize; // Clear only the popup region. The server-side rows_formatted() skips // default cells (spaces with default attributes) using cursor jumps, so // any popup content at those positions would remain if not cleared // beforehand. We write `popup_rect.width` spaces instead of // ClearType::CurrentLine so that only the popup area is cleared, not // the entire terminal line. let _ = execute!( stdout, MoveTo(popup_rect.x, target_row), ratatui::crossterm::style::SetAttribute(ratatui::crossterm::style::Attribute::Reset), ); let _ = write!(stdout, "{:width$}", "", width = popup_rect.width as usize); let _ = execute!(stdout, MoveTo(popup_rect.x, target_row)); if let Some(row_bytes) = saved.rows_data.get(source_row) { let _ = stdout.write_all(row_bytes); } } let _ = execute!( stdout, MoveTo( saved.cursor_col, saved.cursor_row.saturating_sub(scroll_offset) ) ); let _ = stdout.flush(); } struct Stdout { writer: TerminalWriter, inline_mode: bool, } impl Stdout { pub fn new(inline_mode: bool, stdout_is_terminal: bool) -> std::io::Result { terminal::enable_raw_mode()?; // If stdout is not a terminal (e.g., captured by command substitution), // fall back to /dev/tty so the TUI can still render. // This allows usage like: VAR=$(atuin search -i) let mut writer = if stdout_is_terminal { TerminalWriter::Stdout(stdout()) } else { #[cfg(unix)] { TerminalWriter::Tty( std::fs::File::options() .read(true) .write(true) .open("/dev/tty")?, ) } #[cfg(not(unix))] { return Err(std::io::Error::new( std::io::ErrorKind::Unsupported, "Interactive mode requires a terminal", )); } }; if !inline_mode { execute!(writer, terminal::EnterAlternateScreen)?; } execute!( writer, event::EnableMouseCapture, event::EnableBracketedPaste, )?; #[cfg(not(target_os = "windows"))] execute!( writer, PushKeyboardEnhancementFlags( KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS ), )?; Ok(Self { writer, inline_mode, }) } } impl Drop for Stdout { fn drop(&mut self) { #[cfg(not(target_os = "windows"))] execute!(self.writer, PopKeyboardEnhancementFlags).unwrap(); if !self.inline_mode { execute!(self.writer, terminal::LeaveAlternateScreen).unwrap(); } execute!( self.writer, event::DisableMouseCapture, event::DisableBracketedPaste, ) .unwrap(); terminal::disable_raw_mode().unwrap(); } } impl Write for Stdout { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.writer.write(buf) } fn flush(&mut self) -> std::io::Result<()> { self.writer.flush() } } // this is a big blob of horrible! clean it up! /// Compute the popup position and any scroll offset needed to make room. /// /// Given the cursor row, terminal dimensions, and desired popup height, /// returns `(popup_rect, scroll_offset)` where `scroll_offset` is the number /// of lines the caller should scroll the terminal up before rendering. /// /// This function performs no I/O — it is a pure computation. fn compute_popup_placement( cursor_row: u16, term_rows: u16, term_cols: u16, inline_height: u16, ) -> (Rect, u16) { let popup_w = term_cols; let popup_h = inline_height.min(term_rows); let space_below = term_rows.saturating_sub(cursor_row); let (popup_y, scroll) = if popup_h <= space_below { // Fits below cursor (cursor_row, 0u16) } else if cursor_row >= term_rows / 2 { // Bottom half — render above cursor (overlay on existing text) (cursor_row.saturating_sub(popup_h), 0u16) } else { // Top half, not enough space — scroll terminal to make room let scroll = popup_h.saturating_sub(space_below); let popup_y = cursor_row.saturating_sub(scroll); (popup_y, scroll) }; (Rect::new(0, popup_y, popup_w, popup_h), scroll) } // for now, it works. But it'd be great if it were more easily readable, and // modular. I'd like to add some more stats and stuff at some point #[allow( clippy::cast_possible_truncation, clippy::too_many_lines, clippy::cognitive_complexity )] pub async fn history( query: &[String], settings: &Settings, mut db: impl Database, history_store: &HistoryStore, theme: &Theme, ) -> Result { let inline_height = if settings.shell_up_key_binding { settings .inline_height_shell_up_key_binding .unwrap_or(settings.inline_height) } else { settings.inline_height }; // Check if stdout is a terminal - if not (e.g., command substitution like VAR=$(atuin search -i)), // we need to use /dev/tty for the TUI and force fullscreen mode (inline mode requires // cursor position queries that don't work when stdout is captured) let stdout_is_terminal = stdout().is_terminal(); // Use fullscreen mode if the inline height doesn't fit in the terminal, // this will preserve the scroll position upon exit. // Also force fullscreen when stdout isn't a terminal (inline mode won't work). let inline_height = if !stdout_is_terminal { 0 } else if let Ok(size) = terminal::size() && inline_height >= size.1 { 0 } else { inline_height }; // Popup mode: if running under atuin-hex and inline mode is requested, // fetch the screen state and render as a centered overlay. #[cfg(unix)] let (saved_screen, popup_rect, popup_scroll_offset) = { let socket_path = std::env::var("ATUIN_HEX_SOCKET").ok(); if let Some(ref path) = socket_path && inline_height > 0 { let saved = fetch_screen_state(path); if let Some(ref s) = saved { let (term_cols, term_rows) = terminal::size().unwrap_or((s.cols, s.rows)); let (popup_rect, scroll) = compute_popup_placement(s.cursor_row, term_rows, term_cols, inline_height); // Scroll terminal content up to make room if needed if scroll > 0 { use ratatui::crossterm::cursor::MoveTo; let mut stdout = stdout(); let _ = execute!(stdout, MoveTo(0, term_rows - 1)); for _ in 0..scroll { let _ = writeln!(stdout); } let _ = stdout.flush(); } (saved, popup_rect, scroll) } else { (None, Rect::default(), 0u16) } } else { (None, Rect::default(), 0u16) } }; #[cfg(not(unix))] let (saved_screen, popup_rect, popup_scroll_offset): (Option<()>, Rect, u16) = (None, Rect::default(), 0); let popup_mode = saved_screen.is_some(); let stdout = Stdout::new(inline_height > 0, stdout_is_terminal)?; // In popup mode, clear the popup region on the physical terminal before // ratatui takes over. Ratatui's diff-based rendering compares against an // initially-empty buffer, so cells that remain "empty" (spaces with default // style) won't be written — leaving underlying terminal text visible. // By pre-clearing with spaces, those cells are already correct on screen. if popup_mode { use ratatui::crossterm::cursor::MoveTo; let mut raw_stdout = std::io::stdout(); // Queue all commands without flushing so the terminal receives them // as a single write — no intermediate cursor positions are visible. let _ = queue!( raw_stdout, ratatui::crossterm::style::SetAttribute(ratatui::crossterm::style::Attribute::Reset) ); for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) { let _ = queue!(raw_stdout, MoveTo(popup_rect.x, row)); let _ = write!( raw_stdout, "{:width$}", "", width = popup_rect.width as usize ); } let _ = raw_stdout.flush(); } let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::with_options( backend, TerminalOptions { viewport: if popup_mode { Viewport::Fixed(popup_rect) } else if inline_height > 0 { Viewport::Inline(inline_height) } else { Viewport::Fullscreen }, }, )?; let original_query = query.join(" "); // Check if this is a command chaining scenario let is_command_chaining = if settings.command_chaining { let trimmed = original_query.trim_end(); trimmed.ends_with("&&") || trimmed.ends_with('|') } else { false }; // For command chaining, start with empty input to allow searching for new commands let search_input = if is_command_chaining { String::new() } else { original_query.clone() }; let mut input = Cursor::from(search_input); // Put the cursor at the end of the query by default input.end(); let settings2 = settings.clone(); let update_needed = tokio::spawn(async move { settings2.needs_update().await }).fuse(); tokio::pin!(update_needed); let initial_context = current_context().await?; let history_count = db.history_count(false).await?; let search_mode = if settings.shell_up_key_binding { settings .search_mode_shell_up_key_binding .unwrap_or(settings.search_mode) } else { settings.search_mode }; let default_filter_mode = settings .filter_mode_shell_up_key_binding .filter(|_| settings.shell_up_key_binding) .unwrap_or_else(|| settings.default_filter_mode(initial_context.git_root.is_some())); let mut app = State { history_count, results_state: ListState::default(), update_needed: None, switched_search_mode: false, search_mode, tab_index: 0, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::from_settings(settings), search: SearchState { input, filter_mode: default_filter_mode, context: initial_context.clone(), custom_context: None, }, engine: engines::engine(search_mode, settings), results_len: 0, accept: false, keymap_mode: match settings.keymap_mode { KeymapMode::Auto => KeymapMode::Emacs, value => value, }, current_cursor: None, now: if settings.prefers_reduced_motion { let now = OffsetDateTime::now_utc(); Box::new(move || now) } else { Box::new(OffsetDateTime::now_utc) }, prefix: false, pending_vim_key: None, original_input_empty: original_query.is_empty(), }; app.initialize_keymap_cursor(settings); let mut results = app.query_results(&mut db, settings.smart_sort).await?; if inline_height > 0 && !popup_mode { terminal.clear()?; } let mut stats: Option = None; let mut inspecting: Option = None; let accept; let result = 'render: loop { terminal.draw(|f| { app.draw( f, &results, stats.clone(), inspecting.as_ref(), settings, theme, popup_mode, ); })?; let initial_input = app.search.input.as_str().to_owned(); let initial_filter_mode = app.search.filter_mode; let initial_search_mode = app.search_mode; let initial_custom_context = app.search.custom_context.clone(); let event_ready = tokio::task::spawn_blocking(|| event::poll(Duration::from_millis(250))); tokio::select! { event_ready = event_ready => { if event_ready?? { loop { match app.handle_input(settings, &event::read()?) { InputAction::Continue => {}, InputAction::Delete(index) => { if results.is_empty() { break; } app.results_len -= 1; let selected = app.results_state.selected(); if selected == app.results_len { app.inspecting_state.reset(); app.results_state.select(selected - 1); } let entry = results.remove(index); if settings.sync.records { let (id, _) = history_store.delete(entry.id).await?; history_store.incremental_build(&db, &[id]).await?; } else { db.delete(entry.clone()).await?; } app.tab_index = 0; }, InputAction::SwitchContext(index) => { if let Some(index) = index && let Some(entry) = results.get(index) { app.search.custom_context = Some(entry.id.clone()); app.search.context = Context::from_history(entry); app.search.filter_mode = FilterMode::Session; app.search.input = Cursor::from(String::new()); app.results_state = ListState::default(); } else { app.search.custom_context = None; app.search.context = initial_context.clone(); app.search.filter_mode = default_filter_mode; } }, InputAction::Redraw => { if !popup_mode { terminal.clear()?; } terminal.draw(|f| { app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, theme, popup_mode); })?; }, r => { accept = app.accept; break 'render r; }, } if !event::poll(Duration::ZERO)? { break; } } } } update_needed = &mut update_needed => { // Don't fail interactive search if update check fails // The update check is a nice-to-have feature, not critical app.update_needed = update_needed.ok().flatten(); } } if initial_input != app.search.input.as_str() || initial_filter_mode != app.search.filter_mode || initial_search_mode != app.search_mode || initial_custom_context != app.search.custom_context { results = app.query_results(&mut db, settings.smart_sort).await?; } // In custom context mode, when no filter is applied, highlight the entry which was used // to enter the context when changing modes. This helps to find your way around. if app.search.custom_context.is_some() && app.search.input.as_str().is_empty() && (initial_custom_context != app.search.custom_context || initial_filter_mode != app.search.filter_mode) && let Some(history_id) = app.search.custom_context.clone() && let Some(pos) = results.iter().position(|entry| entry.id == history_id) { app.results_state.select(pos); } let inspecting_id = app.inspecting_state.clone().current; // If inspecting ID is not the current inspecting History, update it. match inspecting_id { Some(inspecting_id) => { if inspecting.is_none() || inspecting_id != inspecting.clone().unwrap().id { inspecting = db.load(inspecting_id.0.as_str()).await?; } } _ => { inspecting = None; } } stats = if app.tab_index == 0 { None } else if !results.is_empty() { // If we have stats, then we can indicate next available IDs. This avoids passing // around a database object, or a full stats object. let selected = match inspecting.clone() { Some(insp) => insp, None => results[app.results_state.selected()].clone(), }; let stats = db.stats(&selected).await?; app.inspecting_state.current = Some(selected.id); app.inspecting_state.previous = match stats.previous.clone() { Some(p) => Some(p.id), _ => None, }; app.inspecting_state.next = match stats.next.clone() { Some(p) => Some(p.id), _ => None, }; Some(stats) } else { None }; }; app.finalize_keymap_cursor(settings); if popup_mode { // In popup mode, restore the screen area that was covered by the popup. // This must happen before Stdout is dropped (which disables raw mode). #[cfg(unix)] if let Some(ref saved) = saved_screen { restore_popup_area(saved, popup_rect, popup_scroll_offset); } } else if inline_height > 0 { terminal.clear()?; } let accept = accept && matches!( Shell::from_env(), Shell::Zsh | Shell::Fish | Shell::Bash | Shell::Xonsh | Shell::Nu | Shell::Powershell ); let accept_prefix = "__atuin_accept__:"; match result { InputAction::AcceptInspecting => { match inspecting { Some(result) => { let mut command = result.command; if accept { command = String::from(accept_prefix) + &command; } // index is in bounds so we return that entry Ok(command) } None => Ok(String::new()), } } InputAction::Accept(index) if index < results.len() => { let mut command = results.swap_remove(index).command; if is_command_chaining { command = format!("{} {}", original_query.trim_end(), command); } else if accept { command = String::from(accept_prefix) + &command; } // index is in bounds so we return that entry Ok(command) } InputAction::ReturnOriginal => Ok(String::new()), InputAction::Copy(index) => { let cmd = results.swap_remove(index).command; set_clipboard(cmd); Ok(String::new()) } InputAction::ReturnQuery | InputAction::Accept(_) => { // Either: // * index == RETURN_QUERY, in which case we should return the input // * out of bounds -> usually implies no selected entry so we return the input Ok(app.search.input.into_inner()) } InputAction::Continue | InputAction::Redraw | InputAction::Delete(_) | InputAction::SwitchContext(_) => { unreachable!("should have been handled!") } } } // cli-clipboard only works on Windows, Mac, and Linux. #[cfg(all( feature = "clipboard", any(target_os = "windows", target_os = "macos", target_os = "linux") ))] fn set_clipboard(s: String) { let mut ctx = arboard::Clipboard::new().unwrap(); ctx.set_text(s).unwrap(); // Use the clipboard context to make sure it is saved ctx.get_text().unwrap(); } #[cfg(not(all( feature = "clipboard", any(target_os = "windows", target_os = "macos", target_os = "linux") )))] fn set_clipboard(_s: String) {} #[cfg(test)] mod tests { use atuin_client::database::Context; use atuin_client::history::History; use atuin_client::settings::{ FilterMode, KeymapMode, Preview, PreviewStrategy, SearchMode, Settings, }; use time::OffsetDateTime; use crate::command::client::search::engines::{self, SearchState}; use crate::command::client::search::history_list::ListState; use super::{Compactness, InspectingState, KeymapSet, State}; #[test] #[allow(clippy::too_many_lines)] fn calc_preview_height_test() { let settings_preview_auto = Settings { preview: Preview { strategy: PreviewStrategy::Auto, }, show_preview: true, ..Settings::utc() }; let settings_preview_auto_h2 = Settings { preview: Preview { strategy: PreviewStrategy::Auto, }, show_preview: true, max_preview_height: 2, ..Settings::utc() }; let settings_preview_h4 = Settings { preview: Preview { strategy: PreviewStrategy::Static, }, show_preview: true, max_preview_height: 4, ..Settings::utc() }; let settings_preview_fixed = Settings { preview: Preview { strategy: PreviewStrategy::Fixed, }, show_preview: true, max_preview_height: 15, ..Settings::utc() }; let cmd_60: History = History::capture() .timestamp(time::OffsetDateTime::now_utc()) .command("for i in $(seq -w 10); do echo \"item number $i - abcd\"; done") .cwd("/") .build() .into(); let cmd_124: History = History::capture() .timestamp(time::OffsetDateTime::now_utc()) .command("echo 'Aurea prima sata est aetas, quae vindice nullo, sponte sua, sine lege fidem rectumque colebat. Poena metusque aberant'") .cwd("/") .build() .into(); let cmd_200: History = History::capture() .timestamp(time::OffsetDateTime::now_utc()) .command("CREATE USER atuin WITH ENCRYPTED PASSWORD 'supersecretpassword'; CREATE DATABASE atuin WITH OWNER = atuin; \\c atuin; REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC; echo 'All done. 200 characters'") .cwd("/") .build() .into(); let results: Vec = vec![cmd_60, cmd_124, cmd_200]; // the selected command does not require a preview let no_preview = State::calc_preview_height( &settings_preview_auto, &results, 0_usize, 0_usize, Compactness::Full, 1, 80, ); // the selected command requires 2 lines let preview_h2 = State::calc_preview_height( &settings_preview_auto, &results, 1_usize, 0_usize, Compactness::Full, 1, 80, ); // the selected command requires 3 lines let preview_h3 = State::calc_preview_height( &settings_preview_auto, &results, 2_usize, 0_usize, Compactness::Full, 1, 80, ); // the selected command requires a preview of 1 line (happens when the command is between preview_width-19 and preview_width) let preview_one_line = State::calc_preview_height( &settings_preview_auto, &results, 0_usize, 0_usize, Compactness::Full, 1, 66, ); // the selected command requires 3 lines, but we have a max preview height limit of 2 let preview_limit_at_2 = State::calc_preview_height( &settings_preview_auto_h2, &results, 2_usize, 0_usize, Compactness::Full, 1, 80, ); // the longest command requires 3 lines let preview_static_h3 = State::calc_preview_height( &settings_preview_h4, &results, 1_usize, 0_usize, Compactness::Full, 1, 80, ); // the longest command requires 10 lines, but we have a max preview height limit of 4 let preview_static_limit_at_4 = State::calc_preview_height( &settings_preview_h4, &results, 1_usize, 0_usize, Compactness::Full, 1, 20, ); // the longest command requires 10 lines, but we have a max preview height of 15 and a fixed preview strategy let settings_preview_fixed = State::calc_preview_height( &settings_preview_fixed, &results, 1_usize, 0_usize, Compactness::Full, 1, 20, ); assert_eq!(no_preview, 1); // 1 * 2 is the space for the border let border_space = 2; assert_eq!(preview_h2, 2 + border_space); assert_eq!(preview_h3, 3 + border_space); assert_eq!(preview_one_line, 1 + border_space); assert_eq!(preview_limit_at_2, 2 + border_space); assert_eq!(preview_static_h3, 3 + border_space); assert_eq!(preview_static_limit_at_4, 4 + border_space); assert_eq!(settings_preview_fixed, 15 + border_space); } // Test when there's no results, scrolling up or down doesn't underflow #[test] fn state_scroll_up_underflow() { let settings = Settings::utc(); let mut state = State { history_count: 0, update_needed: None, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 0, accept: false, keymap_mode: KeymapMode::Auto, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Directory, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; state.scroll_up(1); state.scroll_down(1); } #[test] fn test_accept_keybindings() { use atuin_client::settings::Keys; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let mut settings = Settings::utc(); settings.keys = Keys { scroll_exits: true, exit_past_line_start: false, accept_past_line_end: true, accept_past_line_start: false, accept_with_backspace: false, prefix: "a".to_string(), }; let mut state = State { history_count: 1, update_needed: None, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 1, accept: false, keymap_mode: KeymapMode::Emacs, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &tab_event); assert!( matches!(result, super::InputAction::Accept(_)), "Tab should always accept" ); // Test left arrow with accept_past_line_start disabled (should continue) let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &left_event); assert!( matches!(result, super::InputAction::Continue), "Left arrow should continue when disabled" ); // Test left arrow with accept_past_line_start enabled (should accept at start of line) settings.keys.accept_past_line_start = true; state.keymaps = KeymapSet::defaults(&settings); let result = state.handle_key_input(&settings, &left_event); assert!( matches!(result, super::InputAction::Accept(_)), "Left arrow should accept at start of line when enabled" ); settings.keys.accept_past_line_start = false; state.keymaps = KeymapSet::defaults(&settings); let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &backspace_event); assert!( matches!(result, super::InputAction::Continue), "Backspace should continue when disabled" ); settings.keys.accept_with_backspace = true; state.keymaps = KeymapSet::defaults(&settings); let result = state.handle_key_input(&settings, &backspace_event); assert!( matches!(result, super::InputAction::Accept(_)), "Backspace should accept at start of line when enabled" ); state.search.input.insert('t'); state.search.input.insert('e'); state.search.input.insert('s'); state.search.input.insert('t'); state.search.input.end(); let right_event = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &right_event); assert!( matches!(result, super::InputAction::Accept(_)), "Right arrow should accept at end of line when enabled" ); settings.keys.accept_past_line_start = true; state.keymaps = KeymapSet::defaults(&settings); let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &left_event); assert!( matches!(result, super::InputAction::Continue), "Left arrow should continue and end of line, even when enabled" ); settings.keys.accept_past_line_start = false; state.keymaps = KeymapSet::defaults(&settings); settings.keys.accept_with_backspace = true; state.keymaps = KeymapSet::defaults(&settings); let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &backspace_event); assert!( matches!(result, super::InputAction::Continue), "Backspace should continue at end of line, even when enabled" ); settings.keys.accept_with_backspace = false; state.keymaps = KeymapSet::defaults(&settings); } #[test] fn test_vim_gg_multikey_sequence() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let settings = Settings::utc(); let mut state = State { history_count: 100, update_needed: None, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 100, accept: false, keymap_mode: KeymapMode::VimNormal, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; // Start in the middle of the list state.results_state.select(50); // First 'g' should set pending state let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE); let result = state.handle_key_input(&settings, &g_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.pending_vim_key, Some('g')); assert_eq!(state.results_state.selected(), 50); // Position unchanged // Second 'g' should jump to end (visual top in non-inverted mode) let result = state.handle_key_input(&settings, &g_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.pending_vim_key, None); assert_eq!(state.results_state.selected(), 99); // Jumped to last index (visual top) } #[test] fn test_vim_g_key_clears_on_other_input() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let settings = Settings::utc(); let mut state = State { history_count: 100, update_needed: None, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 100, accept: false, keymap_mode: KeymapMode::VimNormal, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; state.results_state.select(50); // Press 'g' to set pending state let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE); state.handle_key_input(&settings, &g_event); assert_eq!(state.pending_vim_key, Some('g')); // Press 'j' - should clear pending state let j_event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); state.handle_key_input(&settings, &j_event); assert_eq!(state.pending_vim_key, None); } #[test] fn test_vim_big_g_jump_to_bottom() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let settings = Settings::utc(); let mut state = State { history_count: 100, update_needed: None, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 100, accept: false, keymap_mode: KeymapMode::VimNormal, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; state.results_state.select(50); // 'G' should jump to visual bottom (index 0 in non-inverted mode) let big_g_event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE); let result = state.handle_key_input(&settings, &big_g_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.results_state.selected(), 0); } #[test] fn test_vim_ctrl_u_d_half_page_scroll() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let settings = Settings::utc(); let mut state = State { history_count: 100, update_needed: None, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 100, accept: false, keymap_mode: KeymapMode::VimNormal, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; state.results_state.select(50); // Ctrl+d should return Continue and clear pending key // (scroll amount depends on max_entries which is 0 in tests) state.pending_vim_key = Some('g'); let ctrl_d_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL); let result = state.handle_key_input(&settings, &ctrl_d_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.pending_vim_key, None); // Ctrl+u should return Continue and clear pending key state.pending_vim_key = Some('g'); let ctrl_u_event = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL); let result = state.handle_key_input(&settings, &ctrl_u_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.pending_vim_key, None); } #[test] fn test_vim_ctrl_f_b_full_page_scroll() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let settings = Settings::utc(); let mut state = State { history_count: 100, update_needed: None, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 100, accept: false, keymap_mode: KeymapMode::VimNormal, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; state.results_state.select(50); // Ctrl+f should return Continue and clear pending key // (scroll amount depends on max_entries which is 0 in tests) state.pending_vim_key = Some('g'); let ctrl_f_event = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL); let result = state.handle_key_input(&settings, &ctrl_f_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.pending_vim_key, None); // Ctrl+b should return Continue and clear pending key state.pending_vim_key = Some('g'); let ctrl_b_event = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL); let result = state.handle_key_input(&settings, &ctrl_b_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.pending_vim_key, None); } // ----------------------------------------------------------------------- // Executor tests (execute_action) // ----------------------------------------------------------------------- /// Helper to build a State for executor tests. fn make_executor_state(results_len: usize, selected: usize) -> State { let settings = Settings::utc(); let mut state = State { history_count: results_len as i64, update_needed: None, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len, accept: false, keymap_mode: KeymapMode::Emacs, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; state.results_state.select(selected); state } #[test] fn execute_select_next_no_invert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let settings = Settings::utc(); let result = state.execute_action(&Action::SelectNext, &settings); assert!(matches!(result, super::InputAction::Continue)); // Non-inverted: SelectNext = scroll_down = selected - 1 assert_eq!(state.results_state.selected(), 49); } #[test] fn execute_select_next_with_invert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let mut settings = Settings::utc(); settings.invert = true; let result = state.execute_action(&Action::SelectNext, &settings); assert!(matches!(result, super::InputAction::Continue)); // Inverted: SelectNext = scroll_up = selected + 1 assert_eq!(state.results_state.selected(), 51); } #[test] fn execute_select_previous_no_invert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let settings = Settings::utc(); let result = state.execute_action(&Action::SelectPrevious, &settings); assert!(matches!(result, super::InputAction::Continue)); // Non-inverted: SelectPrevious = scroll_up = selected + 1 assert_eq!(state.results_state.selected(), 51); } #[test] fn execute_vim_enter_normal() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::utc(); let result = state.execute_action(&Action::VimEnterNormal, &settings); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.keymap_mode, KeymapMode::VimNormal); } #[test] fn execute_vim_enter_insert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); state.keymap_mode = KeymapMode::VimNormal; let settings = Settings::utc(); let result = state.execute_action(&Action::VimEnterInsert, &settings); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.keymap_mode, KeymapMode::VimInsert); } #[test] fn execute_accept_sets_accept_flag() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 5); let mut settings = Settings::utc(); settings.enter_accept = true; let result = state.execute_action(&Action::Accept, &settings); assert!(matches!(result, super::InputAction::Accept(5))); assert!(state.accept); } #[test] fn execute_return_selection_does_not_set_accept() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 5); let settings = Settings::utc(); let result = state.execute_action(&Action::ReturnSelection, &settings); assert!(matches!(result, super::InputAction::Accept(5))); assert!(!state.accept); } #[test] fn execute_accept_nth() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 5); let settings = Settings::utc(); let result = state.execute_action(&Action::AcceptNth(3), &settings); assert!(matches!(result, super::InputAction::Accept(8))); } #[test] fn execute_scroll_to_top_no_invert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let settings = Settings::utc(); let result = state.execute_action(&Action::ScrollToTop, &settings); assert!(matches!(result, super::InputAction::Continue)); // Non-inverted: visual top = highest index assert_eq!(state.results_state.selected(), 99); } #[test] fn execute_scroll_to_top_with_invert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let mut settings = Settings::utc(); settings.invert = true; let result = state.execute_action(&Action::ScrollToTop, &settings); assert!(matches!(result, super::InputAction::Continue)); // Inverted: visual top = index 0 assert_eq!(state.results_state.selected(), 0); } #[test] fn execute_scroll_to_bottom_no_invert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let settings = Settings::utc(); let result = state.execute_action(&Action::ScrollToBottom, &settings); assert!(matches!(result, super::InputAction::Continue)); // Non-inverted: visual bottom = index 0 assert_eq!(state.results_state.selected(), 0); } #[test] fn execute_toggle_tab() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::utc(); assert_eq!(state.tab_index, 0); state.execute_action(&Action::ToggleTab, &settings); assert_eq!(state.tab_index, 1); state.execute_action(&Action::ToggleTab, &settings); assert_eq!(state.tab_index, 0); } #[test] fn execute_enter_prefix_mode() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::utc(); assert!(!state.prefix); state.execute_action(&Action::EnterPrefixMode, &settings); assert!(state.prefix); } #[test] fn execute_exit_returns_based_on_exit_mode() { use crate::command::client::search::keybindings::Action; use atuin_client::settings::ExitMode; let mut state = make_executor_state(100, 0); let mut settings = Settings::utc(); settings.exit_mode = ExitMode::ReturnOriginal; let result = state.execute_action(&Action::Exit, &settings); assert!(matches!(result, super::InputAction::ReturnOriginal)); settings.exit_mode = ExitMode::ReturnQuery; let result = state.execute_action(&Action::Exit, &settings); assert!(matches!(result, super::InputAction::ReturnQuery)); } #[test] fn execute_return_original() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::utc(); let result = state.execute_action(&Action::ReturnOriginal, &settings); assert!(matches!(result, super::InputAction::ReturnOriginal)); } #[test] fn execute_copy() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 7); let settings = Settings::utc(); let result = state.execute_action(&Action::Copy, &settings); assert!(matches!(result, super::InputAction::Copy(7))); } #[test] fn execute_delete() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 7); let settings = Settings::utc(); let result = state.execute_action(&Action::Delete, &settings); assert!(matches!(result, super::InputAction::Delete(7))); } #[test] fn execute_switch_context() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 7); let settings = Settings::utc(); let result = state.execute_action(&Action::SwitchContext, &settings); assert!(matches!(result, super::InputAction::SwitchContext(Some(7)))); } #[test] fn execute_clear_context() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 7); let settings = Settings::utc(); let result = state.execute_action(&Action::ClearContext, &settings); assert!(matches!(result, super::InputAction::SwitchContext(None))); } #[test] fn execute_noop() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let settings = Settings::utc(); let result = state.execute_action(&Action::Noop, &settings); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.results_state.selected(), 50); } #[test] fn execute_accept_in_inspector_tab() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 5); state.tab_index = 1; let settings = Settings::utc(); let result = state.execute_action(&Action::Accept, &settings); assert!(matches!(result, super::InputAction::AcceptInspecting)); } #[test] fn execute_cycle_search_mode() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::utc(); let original_mode = state.search_mode; let result = state.execute_action(&Action::CycleSearchMode, &settings); assert!(matches!(result, super::InputAction::Continue)); assert!(state.switched_search_mode); assert_ne!(state.search_mode, original_mode); } #[test] fn execute_vim_search_insert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); state.search.input.insert('h'); state.search.input.insert('i'); state.keymap_mode = KeymapMode::VimNormal; let settings = Settings::utc(); let result = state.execute_action(&Action::VimSearchInsert, &settings); assert!(matches!(result, super::InputAction::Continue)); // Should clear input and switch to insert mode assert_eq!(state.search.input.as_str(), ""); assert_eq!(state.keymap_mode, KeymapMode::VimInsert); } #[test] fn execute_cursor_movement() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::utc(); // Insert some text state.search.input.insert('h'); state.search.input.insert('e'); state.search.input.insert('l'); state.search.input.insert('l'); state.search.input.insert('o'); // cursor is at end (position 5) // CursorLeft state.execute_action(&Action::CursorLeft, &settings); assert_eq!(state.search.input.position(), 4); // CursorStart state.execute_action(&Action::CursorStart, &settings); assert_eq!(state.search.input.position(), 0); // CursorEnd state.execute_action(&Action::CursorEnd, &settings); assert_eq!(state.search.input.position(), 5); // CursorRight at end does nothing state.execute_action(&Action::CursorRight, &settings); assert_eq!(state.search.input.position(), 5); } #[test] fn execute_editing() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::utc(); // Insert "hello" state.search.input.insert('h'); state.search.input.insert('e'); state.search.input.insert('l'); state.search.input.insert('l'); state.search.input.insert('o'); // DeleteCharBefore (backspace) state.execute_action(&Action::DeleteCharBefore, &settings); assert_eq!(state.search.input.as_str(), "hell"); // ClearLine state.execute_action(&Action::ClearLine, &settings); assert_eq!(state.search.input.as_str(), ""); } #[test] fn keymap_config_return_query() { use atuin_client::settings::KeyBindingConfig; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::collections::HashMap; let mut settings = Settings::utc(); // Configure tab to return-query settings.keymap.emacs = HashMap::from([( "tab".to_string(), KeyBindingConfig::Simple("return-query".to_string()), )]); let mut state = State { history_count: 100, update_needed: None, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 100, accept: false, keymap_mode: KeymapMode::Emacs, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::from_settings(&settings), search: SearchState { input: "test query".to_string().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &tab_event); assert!( matches!(result, super::InputAction::ReturnQuery), "Tab configured as return-query should return InputAction::ReturnQuery" ); } } ================================================ FILE: crates/atuin/src/command/client/search/keybindings/actions.rs ================================================ use std::fmt; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// All possible actions that can be triggered by a keybinding. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { // Cursor movement CursorLeft, CursorRight, CursorWordLeft, CursorWordRight, CursorWordEnd, CursorStart, CursorEnd, // Editing DeleteCharBefore, DeleteCharAfter, DeleteWordBefore, DeleteWordAfter, DeleteToWordBoundary, ClearLine, ClearToStart, ClearToEnd, // List navigation SelectNext, SelectPrevious, ScrollHalfPageUp, ScrollHalfPageDown, ScrollPageUp, ScrollPageDown, ScrollToTop, ScrollToBottom, ScrollToScreenTop, ScrollToScreenMiddle, ScrollToScreenBottom, // Commands — accept selection and execute immediately Accept, AcceptNth(u8), // Commands — return selection to command line without executing ReturnSelection, ReturnSelectionNth(u8), // Commands — other Copy, Delete, ReturnOriginal, ReturnQuery, Exit, Redraw, CycleFilterMode, CycleSearchMode, SwitchContext, ClearContext, ToggleTab, // Mode changes VimEnterNormal, VimEnterInsert, VimEnterInsertAfter, VimEnterInsertAtStart, VimEnterInsertAtEnd, VimSearchInsert, VimChangeToEnd, EnterPrefixMode, // Inspector InspectPrevious, InspectNext, // Special Noop, } impl Action { /// Convert from a kebab-case string. pub fn from_str(s: &str) -> Result { // Handle accept-N and return-selection-N patterns if let Some(rest) = s.strip_prefix("accept-") && let Ok(n) = rest.parse::() && (1..=9).contains(&n) { return Ok(Action::AcceptNth(n)); } if let Some(rest) = s.strip_prefix("return-selection-") && let Ok(n) = rest.parse::() && (1..=9).contains(&n) { return Ok(Action::ReturnSelectionNth(n)); } match s { "cursor-left" => Ok(Action::CursorLeft), "cursor-right" => Ok(Action::CursorRight), "cursor-word-left" => Ok(Action::CursorWordLeft), "cursor-word-right" => Ok(Action::CursorWordRight), "cursor-word-end" => Ok(Action::CursorWordEnd), "cursor-start" => Ok(Action::CursorStart), "cursor-end" => Ok(Action::CursorEnd), "delete-char-before" => Ok(Action::DeleteCharBefore), "delete-char-after" => Ok(Action::DeleteCharAfter), "delete-word-before" => Ok(Action::DeleteWordBefore), "delete-word-after" => Ok(Action::DeleteWordAfter), "delete-to-word-boundary" => Ok(Action::DeleteToWordBoundary), "clear-line" => Ok(Action::ClearLine), "clear-to-start" => Ok(Action::ClearToStart), "clear-to-end" => Ok(Action::ClearToEnd), "select-next" => Ok(Action::SelectNext), "select-previous" => Ok(Action::SelectPrevious), "scroll-half-page-up" => Ok(Action::ScrollHalfPageUp), "scroll-half-page-down" => Ok(Action::ScrollHalfPageDown), "scroll-page-up" => Ok(Action::ScrollPageUp), "scroll-page-down" => Ok(Action::ScrollPageDown), "scroll-to-top" => Ok(Action::ScrollToTop), "scroll-to-bottom" => Ok(Action::ScrollToBottom), "scroll-to-screen-top" => Ok(Action::ScrollToScreenTop), "scroll-to-screen-middle" => Ok(Action::ScrollToScreenMiddle), "scroll-to-screen-bottom" => Ok(Action::ScrollToScreenBottom), "accept" => Ok(Action::Accept), "return-selection" => Ok(Action::ReturnSelection), "copy" => Ok(Action::Copy), "delete" => Ok(Action::Delete), "return-original" => Ok(Action::ReturnOriginal), "return-query" => Ok(Action::ReturnQuery), "exit" => Ok(Action::Exit), "redraw" => Ok(Action::Redraw), "cycle-filter-mode" => Ok(Action::CycleFilterMode), "cycle-search-mode" => Ok(Action::CycleSearchMode), "switch-context" => Ok(Action::SwitchContext), "clear-context" => Ok(Action::ClearContext), "toggle-tab" => Ok(Action::ToggleTab), "vim-enter-normal" => Ok(Action::VimEnterNormal), "vim-enter-insert" => Ok(Action::VimEnterInsert), "vim-enter-insert-after" => Ok(Action::VimEnterInsertAfter), "vim-enter-insert-at-start" => Ok(Action::VimEnterInsertAtStart), "vim-enter-insert-at-end" => Ok(Action::VimEnterInsertAtEnd), "vim-search-insert" => Ok(Action::VimSearchInsert), "vim-change-to-end" => Ok(Action::VimChangeToEnd), "enter-prefix-mode" => Ok(Action::EnterPrefixMode), "inspect-previous" => Ok(Action::InspectPrevious), "inspect-next" => Ok(Action::InspectNext), "noop" => Ok(Action::Noop), _ => Err(format!("unknown action: {s}")), } } /// Convert to a kebab-case string. pub fn as_str(&self) -> String { match self { Action::CursorLeft => "cursor-left".to_string(), Action::CursorRight => "cursor-right".to_string(), Action::CursorWordLeft => "cursor-word-left".to_string(), Action::CursorWordRight => "cursor-word-right".to_string(), Action::CursorWordEnd => "cursor-word-end".to_string(), Action::CursorStart => "cursor-start".to_string(), Action::CursorEnd => "cursor-end".to_string(), Action::DeleteCharBefore => "delete-char-before".to_string(), Action::DeleteCharAfter => "delete-char-after".to_string(), Action::DeleteWordBefore => "delete-word-before".to_string(), Action::DeleteWordAfter => "delete-word-after".to_string(), Action::DeleteToWordBoundary => "delete-to-word-boundary".to_string(), Action::ClearLine => "clear-line".to_string(), Action::ClearToStart => "clear-to-start".to_string(), Action::ClearToEnd => "clear-to-end".to_string(), Action::SelectNext => "select-next".to_string(), Action::SelectPrevious => "select-previous".to_string(), Action::ScrollHalfPageUp => "scroll-half-page-up".to_string(), Action::ScrollHalfPageDown => "scroll-half-page-down".to_string(), Action::ScrollPageUp => "scroll-page-up".to_string(), Action::ScrollPageDown => "scroll-page-down".to_string(), Action::ScrollToTop => "scroll-to-top".to_string(), Action::ScrollToBottom => "scroll-to-bottom".to_string(), Action::ScrollToScreenTop => "scroll-to-screen-top".to_string(), Action::ScrollToScreenMiddle => "scroll-to-screen-middle".to_string(), Action::ScrollToScreenBottom => "scroll-to-screen-bottom".to_string(), Action::Accept => "accept".to_string(), Action::AcceptNth(n) => format!("accept-{n}"), Action::ReturnSelection => "return-selection".to_string(), Action::ReturnSelectionNth(n) => format!("return-selection-{n}"), Action::Copy => "copy".to_string(), Action::Delete => "delete".to_string(), Action::ReturnOriginal => "return-original".to_string(), Action::ReturnQuery => "return-query".to_string(), Action::Exit => "exit".to_string(), Action::Redraw => "redraw".to_string(), Action::CycleFilterMode => "cycle-filter-mode".to_string(), Action::CycleSearchMode => "cycle-search-mode".to_string(), Action::SwitchContext => "switch-context".to_string(), Action::ClearContext => "clear-context".to_string(), Action::ToggleTab => "toggle-tab".to_string(), Action::VimEnterNormal => "vim-enter-normal".to_string(), Action::VimEnterInsert => "vim-enter-insert".to_string(), Action::VimEnterInsertAfter => "vim-enter-insert-after".to_string(), Action::VimEnterInsertAtStart => "vim-enter-insert-at-start".to_string(), Action::VimEnterInsertAtEnd => "vim-enter-insert-at-end".to_string(), Action::VimSearchInsert => "vim-search-insert".to_string(), Action::VimChangeToEnd => "vim-change-to-end".to_string(), Action::EnterPrefixMode => "enter-prefix-mode".to_string(), Action::InspectPrevious => "inspect-previous".to_string(), Action::InspectNext => "inspect-next".to_string(), Action::Noop => "noop".to_string(), } } } impl fmt::Display for Action { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.as_str()) } } impl Serialize for Action { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(&self.as_str()) } } impl<'de> Deserialize<'de> for Action { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; Action::from_str(&s).map_err(serde::de::Error::custom) } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_basic_actions() { assert_eq!(Action::from_str("cursor-left").unwrap(), Action::CursorLeft); assert_eq!(Action::from_str("accept").unwrap(), Action::Accept); assert_eq!(Action::from_str("exit").unwrap(), Action::Exit); assert_eq!(Action::from_str("noop").unwrap(), Action::Noop); assert_eq!( Action::from_str("vim-enter-normal").unwrap(), Action::VimEnterNormal ); } #[test] fn parse_accept_nth() { assert_eq!(Action::from_str("accept-1").unwrap(), Action::AcceptNth(1)); assert_eq!(Action::from_str("accept-9").unwrap(), Action::AcceptNth(9)); } #[test] fn parse_return_selection() { assert_eq!( Action::from_str("return-selection").unwrap(), Action::ReturnSelection ); assert_eq!( Action::from_str("return-selection-1").unwrap(), Action::ReturnSelectionNth(1) ); assert_eq!( Action::from_str("return-selection-9").unwrap(), Action::ReturnSelectionNth(9) ); } #[test] fn parse_unknown_action() { assert!(Action::from_str("unknown-action").is_err()); assert!(Action::from_str("accept-0").is_err()); assert!(Action::from_str("accept-10").is_err()); assert!(Action::from_str("return-selection-0").is_err()); assert!(Action::from_str("return-selection-10").is_err()); } #[test] fn round_trip() { let actions = vec![ Action::CursorLeft, Action::Accept, Action::AcceptNth(5), Action::ReturnSelection, Action::ReturnSelectionNth(3), Action::VimSearchInsert, Action::ScrollToScreenMiddle, ]; for action in actions { let s = action.as_str(); let parsed = Action::from_str(&s).unwrap(); assert_eq!(action, parsed); } } #[test] fn serde_round_trip() { let action = Action::CursorLeft; let json = serde_json::to_string(&action).unwrap(); assert_eq!(json, "\"cursor-left\""); let parsed: Action = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, Action::CursorLeft); let action = Action::AcceptNth(3); let json = serde_json::to_string(&action).unwrap(); assert_eq!(json, "\"accept-3\""); let parsed: Action = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, Action::AcceptNth(3)); } } ================================================ FILE: crates/atuin/src/command/client/search/keybindings/conditions.rs ================================================ use std::fmt; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// Atomic (leaf) conditions that can be evaluated against state. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConditionAtom { CursorAtStart, CursorAtEnd, InputEmpty, OriginalInputEmpty, ListAtEnd, ListAtStart, NoResults, HasResults, HasContext, } /// Boolean expression tree over condition atoms. /// /// Supports negation, conjunction, and disjunction with standard precedence: /// `!` binds tightest, then `&&`, then `||`. /// /// Examples of valid expression strings: /// - `"cursor-at-start"` (bare atom) /// - `"!no-results"` (negation) /// - `"cursor-at-start && input-empty"` (conjunction) /// - `"list-at-start || no-results"` (disjunction) /// - `"(cursor-at-start && !input-empty) || no-results"` (grouping) #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConditionExpr { Atom(ConditionAtom), Not(Box), And(Box, Box), Or(Box, Box), } /// Context needed to evaluate conditions. This is a pure snapshot of state — /// no references to mutable data. pub struct EvalContext { /// Current cursor position (unicode width units). pub cursor_position: usize, /// Width of the input string in unicode width units. pub input_width: usize, /// Byte length of the input string. pub input_byte_len: usize, /// Currently selected index in the results list. pub selected_index: usize, /// Total number of results. pub results_len: usize, /// Whether the original input (query passed to the TUI) was empty. pub original_input_empty: bool, /// Whether we use a search context of a command from the history. pub has_context: bool, } // --------------------------------------------------------------------------- // ConditionAtom // --------------------------------------------------------------------------- impl ConditionAtom { /// Evaluate this atom against the given context. pub fn evaluate(&self, ctx: &EvalContext) -> bool { match self { ConditionAtom::CursorAtStart => ctx.cursor_position == 0, ConditionAtom::CursorAtEnd => ctx.cursor_position == ctx.input_width, ConditionAtom::InputEmpty => ctx.input_byte_len == 0, ConditionAtom::OriginalInputEmpty => ctx.original_input_empty, ConditionAtom::ListAtEnd => { ctx.results_len == 0 || ctx.selected_index >= ctx.results_len.saturating_sub(1) } ConditionAtom::ListAtStart => ctx.results_len == 0 || ctx.selected_index == 0, ConditionAtom::NoResults => ctx.results_len == 0, ConditionAtom::HasResults => ctx.results_len > 0, ConditionAtom::HasContext => ctx.has_context, } } /// Parse from a kebab-case string. pub fn from_str(s: &str) -> Result { match s { "cursor-at-start" => Ok(ConditionAtom::CursorAtStart), "cursor-at-end" => Ok(ConditionAtom::CursorAtEnd), "input-empty" => Ok(ConditionAtom::InputEmpty), "original-input-empty" => Ok(ConditionAtom::OriginalInputEmpty), "list-at-end" => Ok(ConditionAtom::ListAtEnd), "list-at-start" => Ok(ConditionAtom::ListAtStart), "no-results" => Ok(ConditionAtom::NoResults), "has-results" => Ok(ConditionAtom::HasResults), "has-context" => Ok(ConditionAtom::HasContext), _ => Err(format!("unknown condition: {s}")), } } /// Convert to a kebab-case string. pub fn as_str(&self) -> &'static str { match self { ConditionAtom::CursorAtStart => "cursor-at-start", ConditionAtom::CursorAtEnd => "cursor-at-end", ConditionAtom::InputEmpty => "input-empty", ConditionAtom::OriginalInputEmpty => "original-input-empty", ConditionAtom::ListAtEnd => "list-at-end", ConditionAtom::ListAtStart => "list-at-start", ConditionAtom::NoResults => "no-results", ConditionAtom::HasResults => "has-results", ConditionAtom::HasContext => "has-context", } } } impl fmt::Display for ConditionAtom { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.as_str()) } } // --------------------------------------------------------------------------- // ConditionExpr — evaluation // --------------------------------------------------------------------------- impl ConditionExpr { /// Evaluate this expression against the given context. pub fn evaluate(&self, ctx: &EvalContext) -> bool { match self { ConditionExpr::Atom(atom) => atom.evaluate(ctx), ConditionExpr::Not(inner) => !inner.evaluate(ctx), ConditionExpr::And(lhs, rhs) => lhs.evaluate(ctx) && rhs.evaluate(ctx), ConditionExpr::Or(lhs, rhs) => lhs.evaluate(ctx) || rhs.evaluate(ctx), } } } // --------------------------------------------------------------------------- // ConditionExpr — ergonomic builders // --------------------------------------------------------------------------- impl From for ConditionExpr { fn from(atom: ConditionAtom) -> Self { ConditionExpr::Atom(atom) } } #[allow(dead_code)] impl ConditionExpr { /// Negate this expression: `!self`. pub fn not(self) -> Self { ConditionExpr::Not(Box::new(self)) } /// Conjoin with another expression: `self && other`. pub fn and(self, other: ConditionExpr) -> Self { ConditionExpr::And(Box::new(self), Box::new(other)) } /// Disjoin with another expression: `self || other`. pub fn or(self, other: ConditionExpr) -> Self { ConditionExpr::Or(Box::new(self), Box::new(other)) } } // --------------------------------------------------------------------------- // ConditionExpr — parser // --------------------------------------------------------------------------- /// Recursive descent parser for boolean condition expressions. /// /// Grammar (standard boolean precedence): /// ```text /// expr = or_expr /// or_expr = and_expr ("||" and_expr)* /// and_expr = unary ("&&" unary)* /// unary = "!" unary | primary /// primary = atom | "(" expr ")" /// atom = [a-z][a-z0-9-]* /// ``` struct ExprParser<'a> { input: &'a str, pos: usize, } impl<'a> ExprParser<'a> { fn new(input: &'a str) -> Self { Self { input, pos: 0 } } fn skip_whitespace(&mut self) { while self.pos < self.input.len() && self.input.as_bytes()[self.pos].is_ascii_whitespace() { self.pos += 1; } } fn starts_with(&mut self, s: &str) -> bool { self.skip_whitespace(); self.input[self.pos..].starts_with(s) } fn consume(&mut self, s: &str) -> bool { self.skip_whitespace(); if self.input[self.pos..].starts_with(s) { self.pos += s.len(); true } else { false } } /// Parse a full expression, expecting to consume all input. fn parse(mut self) -> Result { let expr = self.parse_or()?; self.skip_whitespace(); if self.pos < self.input.len() { return Err(format!( "unexpected input at position {}: {:?}", self.pos, &self.input[self.pos..] )); } Ok(expr) } /// `or_expr` = `and_expr` ("||" `and_expr`)* fn parse_or(&mut self) -> Result { let mut left = self.parse_and()?; while self.starts_with("||") { self.consume("||"); let right = self.parse_and()?; left = ConditionExpr::Or(Box::new(left), Box::new(right)); } Ok(left) } /// `and_expr` = unary ("&&" unary)* fn parse_and(&mut self) -> Result { let mut left = self.parse_unary()?; while self.starts_with("&&") { self.consume("&&"); let right = self.parse_unary()?; left = ConditionExpr::And(Box::new(left), Box::new(right)); } Ok(left) } /// unary = "!" unary | primary fn parse_unary(&mut self) -> Result { if self.consume("!") { let inner = self.parse_unary()?; Ok(ConditionExpr::Not(Box::new(inner))) } else { self.parse_primary() } } /// primary = "(" expr ")" | atom fn parse_primary(&mut self) -> Result { if self.consume("(") { let expr = self.parse_or()?; if !self.consume(")") { return Err(format!("expected ')' at position {}", self.pos)); } Ok(expr) } else { self.parse_atom() } } /// atom = [a-z][a-z0-9-]* fn parse_atom(&mut self) -> Result { self.skip_whitespace(); let start = self.pos; while self.pos < self.input.len() { let b = self.input.as_bytes()[self.pos]; if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' { self.pos += 1; } else { break; } } if self.pos == start { return Err(format!("expected condition name at position {}", self.pos)); } let name = &self.input[start..self.pos]; let atom = ConditionAtom::from_str(name)?; Ok(ConditionExpr::Atom(atom)) } } impl ConditionExpr { /// Parse a condition expression from a string. pub fn parse(s: &str) -> Result { let parser = ExprParser::new(s); parser.parse() } } // --------------------------------------------------------------------------- // ConditionExpr — Display // --------------------------------------------------------------------------- /// Precedence levels for minimal-parentheses display. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] enum Prec { Or = 0, And = 1, Not = 2, Atom = 3, } impl ConditionExpr { fn prec(&self) -> Prec { match self { ConditionExpr::Or(..) => Prec::Or, ConditionExpr::And(..) => Prec::And, ConditionExpr::Not(..) => Prec::Not, ConditionExpr::Atom(..) => Prec::Atom, } } fn fmt_with_prec(&self, f: &mut fmt::Formatter<'_>, parent_prec: Prec) -> fmt::Result { let needs_parens = self.prec() < parent_prec; if needs_parens { write!(f, "(")?; } match self { ConditionExpr::Atom(atom) => write!(f, "{atom}")?, ConditionExpr::Not(inner) => { write!(f, "!")?; inner.fmt_with_prec(f, Prec::Not)?; } ConditionExpr::And(lhs, rhs) => { lhs.fmt_with_prec(f, Prec::And)?; write!(f, " && ")?; rhs.fmt_with_prec(f, Prec::And)?; } ConditionExpr::Or(lhs, rhs) => { lhs.fmt_with_prec(f, Prec::Or)?; write!(f, " || ")?; rhs.fmt_with_prec(f, Prec::Or)?; } } if needs_parens { write!(f, ")")?; } Ok(()) } } impl fmt::Display for ConditionExpr { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.fmt_with_prec(f, Prec::Or) } } // --------------------------------------------------------------------------- // Serde // --------------------------------------------------------------------------- impl Serialize for ConditionExpr { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(&self.to_string()) } } impl<'de> Deserialize<'de> for ConditionExpr { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; ConditionExpr::parse(&s).map_err(serde::de::Error::custom) } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; fn ctx( cursor: usize, width: usize, byte_len: usize, selected: usize, len: usize, ) -> EvalContext { ctx_with_original(cursor, width, byte_len, selected, len, false) } fn ctx_with_original( cursor: usize, width: usize, byte_len: usize, selected: usize, len: usize, original_input_empty: bool, ) -> EvalContext { EvalContext { cursor_position: cursor, input_width: width, input_byte_len: byte_len, selected_index: selected, results_len: len, original_input_empty, has_context: false, } } // -- Atom evaluation (carried over from Phase 0) -- #[test] fn atom_cursor_at_start() { assert!(ConditionAtom::CursorAtStart.evaluate(&ctx(0, 5, 5, 0, 10))); assert!(!ConditionAtom::CursorAtStart.evaluate(&ctx(3, 5, 5, 0, 10))); } #[test] fn atom_cursor_at_end() { assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(5, 5, 5, 0, 10))); assert!(!ConditionAtom::CursorAtEnd.evaluate(&ctx(3, 5, 5, 0, 10))); assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(0, 0, 0, 0, 10))); } #[test] fn atom_input_empty() { assert!(ConditionAtom::InputEmpty.evaluate(&ctx(0, 0, 0, 0, 10))); assert!(!ConditionAtom::InputEmpty.evaluate(&ctx(0, 5, 5, 0, 10))); } #[test] fn atom_original_input_empty() { // original_input_empty = true assert!( ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, true)) ); // original_input_empty = false assert!( !ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, false)) ); // original_input_empty is independent of current input state assert!( ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 5, 5, 0, 10, true)) ); } #[test] fn atom_list_at_end() { assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 99, 100))); assert!(!ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 50, 100))); assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 0, 0))); } #[test] fn atom_list_at_start() { assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 100))); assert!(!ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 50, 100))); assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 0))); } #[test] fn atom_no_results_and_has_results() { assert!(ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 0))); assert!(!ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 5))); assert!(ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 5))); assert!(!ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 0))); } #[test] fn atom_has_context() { let mut context = ctx(0, 0, 0, 0, 0); assert!(!ConditionAtom::HasContext.evaluate(&context)); context.has_context = true; assert!(ConditionAtom::HasContext.evaluate(&context)); } #[test] fn atom_parse_round_trip() { let conditions = [ "cursor-at-start", "cursor-at-end", "input-empty", "original-input-empty", "list-at-end", "list-at-start", "no-results", "has-results", ]; for s in conditions { let c = ConditionAtom::from_str(s).unwrap(); assert_eq!(c.as_str(), s); } } #[test] fn atom_parse_unknown() { assert!(ConditionAtom::from_str("unknown-condition").is_err()); } // -- Parser tests -- #[test] fn parse_bare_atom() { let expr = ConditionExpr::parse("cursor-at-start").unwrap(); assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart)); } #[test] fn parse_negation() { let expr = ConditionExpr::parse("!no-results").unwrap(); assert_eq!( expr, ConditionExpr::Not(Box::new(ConditionExpr::Atom(ConditionAtom::NoResults))) ); } #[test] fn parse_double_negation() { let expr = ConditionExpr::parse("!!no-results").unwrap(); assert_eq!( expr, ConditionExpr::Not(Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom( ConditionAtom::NoResults ))))) ); } #[test] fn parse_and() { let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap(); assert_eq!( expr, ConditionExpr::And( Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)), Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)), ) ); } #[test] fn parse_or() { let expr = ConditionExpr::parse("list-at-start || no-results").unwrap(); assert_eq!( expr, ConditionExpr::Or( Box::new(ConditionExpr::Atom(ConditionAtom::ListAtStart)), Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)), ) ); } #[test] fn parse_precedence_and_binds_tighter_than_or() { // "a || b && c" should parse as "a || (b && c)" let expr = ConditionExpr::parse("cursor-at-start || input-empty && no-results").unwrap(); assert_eq!( expr, ConditionExpr::Or( Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)), Box::new(ConditionExpr::And( Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)), Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)), )), ) ); } #[test] fn parse_parens_override_precedence() { // "(a || b) && c" let expr = ConditionExpr::parse("(cursor-at-start || input-empty) && no-results").unwrap(); assert_eq!( expr, ConditionExpr::And( Box::new(ConditionExpr::Or( Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)), Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)), )), Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)), ) ); } #[test] fn parse_complex_nested() { // "(a && !b) || c" let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap(); assert_eq!( expr, ConditionExpr::Or( Box::new(ConditionExpr::And( Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)), Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom( ConditionAtom::InputEmpty )))), )), Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)), ) ); } #[test] fn parse_whitespace_tolerance() { let a = ConditionExpr::parse("cursor-at-start||input-empty").unwrap(); let b = ConditionExpr::parse("cursor-at-start || input-empty").unwrap(); let c = ConditionExpr::parse(" cursor-at-start || input-empty ").unwrap(); assert_eq!(a, b); assert_eq!(b, c); } #[test] fn parse_error_unknown_atom() { assert!(ConditionExpr::parse("unknown-thing").is_err()); } #[test] fn parse_error_trailing_input() { assert!(ConditionExpr::parse("cursor-at-start blah").is_err()); } #[test] fn parse_error_unmatched_paren() { assert!(ConditionExpr::parse("(cursor-at-start").is_err()); } #[test] fn parse_error_empty() { assert!(ConditionExpr::parse("").is_err()); } // -- Expression evaluation -- #[test] fn eval_not() { let expr = ConditionExpr::parse("!no-results").unwrap(); // Has results → !no-results is true assert!(expr.evaluate(&ctx(0, 0, 0, 0, 5))); // No results → !no-results is false assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 0))); } #[test] fn eval_and() { let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap(); // Both true assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10))); // First true, second false (non-empty input) assert!(!expr.evaluate(&ctx(0, 5, 5, 0, 10))); // First false (cursor not at start) assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10))); } #[test] fn eval_or() { let expr = ConditionExpr::parse("list-at-start || no-results").unwrap(); // list at bottom (selected=0) assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10))); // no results assert!(expr.evaluate(&ctx(0, 0, 0, 0, 0))); // neither assert!(!expr.evaluate(&ctx(0, 0, 0, 5, 10))); } #[test] fn eval_complex_nested() { // (cursor-at-start && !input-empty) || no-results let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap(); // cursor at start, input not empty → true (left branch) assert!(expr.evaluate(&ctx(0, 5, 5, 0, 10))); // no results → true (right branch) assert!(expr.evaluate(&ctx(3, 5, 5, 0, 0))); // cursor not at start, has results → false assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10))); // cursor at start, input empty → false (left: && fails; right: has results) assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 10))); } // -- Display -- #[test] fn display_atom() { let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart); assert_eq!(expr.to_string(), "cursor-at-start"); } #[test] fn display_not() { let expr = ConditionExpr::Atom(ConditionAtom::NoResults).not(); assert_eq!(expr.to_string(), "!no-results"); } #[test] fn display_and() { let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart) .and(ConditionExpr::Atom(ConditionAtom::InputEmpty)); assert_eq!(expr.to_string(), "cursor-at-start && input-empty"); } #[test] fn display_or() { let expr = ConditionExpr::Atom(ConditionAtom::ListAtStart) .or(ConditionExpr::Atom(ConditionAtom::NoResults)); assert_eq!(expr.to_string(), "list-at-start || no-results"); } #[test] fn display_parens_when_needed() { // (a || b) && c — the Or inside And needs parens let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart) .or(ConditionExpr::Atom(ConditionAtom::InputEmpty)) .and(ConditionExpr::Atom(ConditionAtom::NoResults)); assert_eq!( expr.to_string(), "(cursor-at-start || input-empty) && no-results" ); } #[test] fn display_no_parens_when_not_needed() { // a || b && c — no parens needed (and binds tighter) let inner_and = ConditionExpr::Atom(ConditionAtom::InputEmpty) .and(ConditionExpr::Atom(ConditionAtom::NoResults)); let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart).or(inner_and); assert_eq!( expr.to_string(), "cursor-at-start || input-empty && no-results" ); } // -- Display round-trip -- #[test] fn display_round_trip() { let cases = [ "cursor-at-start", "!no-results", "cursor-at-start && input-empty", "list-at-start || no-results", "(cursor-at-start || input-empty) && no-results", "(cursor-at-start && !input-empty) || no-results", ]; for s in cases { let expr = ConditionExpr::parse(s).unwrap(); let displayed = expr.to_string(); let reparsed = ConditionExpr::parse(&displayed).unwrap(); assert_eq!(expr, reparsed, "round-trip failed for: {s}"); } } // -- Serde -- #[test] fn serde_simple_atom() { let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart); let json = serde_json::to_string(&expr).unwrap(); assert_eq!(json, "\"cursor-at-start\""); let parsed: ConditionExpr = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, expr); } #[test] fn serde_compound_expression() { let json = "\"cursor-at-start && !input-empty\""; let parsed: ConditionExpr = serde_json::from_str(json).unwrap(); let expected = ConditionExpr::And( Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)), Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom( ConditionAtom::InputEmpty, )))), ); assert_eq!(parsed, expected); } #[test] fn serde_round_trip() { let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap(); let json = serde_json::to_string(&expr).unwrap(); let parsed: ConditionExpr = serde_json::from_str(&json).unwrap(); assert_eq!(expr, parsed); } // -- From -- #[test] fn from_atom_into_expr() { let expr: ConditionExpr = ConditionAtom::CursorAtStart.into(); assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart)); } // -- Builder helpers -- #[test] fn builder_chain() { let expr = ConditionExpr::from(ConditionAtom::CursorAtStart) .and(ConditionExpr::from(ConditionAtom::InputEmpty).not()) .or(ConditionExpr::from(ConditionAtom::NoResults)); // And binds tighter than Or, so no parens needed around the And assert_eq!( expr.to_string(), "cursor-at-start && !input-empty || no-results" ); } } ================================================ FILE: crates/atuin/src/command/client/search/keybindings/defaults.rs ================================================ use std::collections::HashMap; use atuin_client::settings::{KeyBindingConfig, Settings}; use tracing::warn; use super::actions::Action; use super::conditions::{ConditionAtom, ConditionExpr}; use super::key::KeyInput; use super::keymap::{KeyBinding, KeyRule, Keymap}; /// Helper to bind a scroll key with optional exit behavior. /// /// When `scroll_exits` is true AND the key scrolls toward index 0 (the newest /// entry), we add a conditional rule: at `ListAtStart` → `Exit`, otherwise → /// the scroll action. /// /// Whether a key scrolls toward index 0 depends on the `invert` setting: /// - Non-inverted: "down" / "j" move toward index 0, "up" / "k" move away /// - Inverted: "up" / "k" move toward index 0, "down" / "j" move away /// /// If `toward_index_zero` is false, or `scroll_exits` is false, we just bind /// the key to the plain scroll action (no exit). fn bind_scroll_key( km: &mut Keymap, key_str: &str, action: Action, toward_index_zero: bool, scroll_exits: bool, ) { let k = key(key_str); if scroll_exits && toward_index_zero { km.bind_conditional( k, vec![ KeyRule::when(ConditionAtom::ListAtStart, Action::Exit), KeyRule::always(action), ], ); } else { km.bind(k, action); } } /// Helper to parse a key string, panicking on invalid keys (these are all /// compile-time-known strings). fn key(s: &str) -> KeyInput { KeyInput::parse(s).unwrap_or_else(|e| panic!("invalid default key {s:?}: {e}")) } /// All five keymaps bundled together. #[derive(Debug, Clone)] pub struct KeymapSet { pub emacs: Keymap, pub vim_normal: Keymap, pub vim_insert: Keymap, pub inspector: Keymap, pub prefix: Keymap, } // --------------------------------------------------------------------------- // Common bindings shared across search-tab keymaps // --------------------------------------------------------------------------- /// Add the bindings that are common to all search-tab keymaps: /// ctrl-c, ctrl-g, ctrl-o, and tab. /// /// Note: `esc`/`ctrl-[` are NOT included here because their behavior differs /// between emacs (exit), vim-normal (exit), and vim-insert (enter normal mode). fn add_common_bindings(km: &mut Keymap) { km.bind(key("ctrl-c"), Action::ReturnOriginal); km.bind(key("ctrl-g"), Action::ReturnOriginal); km.bind(key("ctrl-o"), Action::ToggleTab); // Tab: always returns selection without executing (unlike Enter which respects enter_accept) km.bind(key("tab"), Action::ReturnSelection); } /// Returns `Accept` or `ReturnSelection` based on the `enter_accept` setting. fn accept_action(settings: &Settings) -> Action { if settings.enter_accept { Action::Accept } else { Action::ReturnSelection } } // --------------------------------------------------------------------------- // Emacs keymap (also base for vim-insert) // --------------------------------------------------------------------------- /// Build the default emacs keymap. This encodes the behavior from /// `handle_key_input` common section + `handle_search_input` shared section. /// /// The `settings` parameter is used for: /// - `keys.prefix` — which ctrl-key enters prefix mode /// - `keys.scroll_exits`, `invert` — scroll-at-boundary exit behavior /// - `keys.accept_past_line_end` — right arrow at end of line accepts /// - `keys.exit_past_line_start` — left arrow at start of line exits /// - `keys.accept_past_line_start` — left arrow at start accepts (overrides exit) /// - `keys.accept_with_backspace` — backspace at start of line accepts /// - `ctrl_n_shortcuts` — whether alt or ctrl is used for numeric shortcuts // Keymap builder that enumerates every default binding; not worth splitting. #[allow(clippy::too_many_lines)] pub fn default_emacs_keymap(settings: &Settings) -> Keymap { let mut km = Keymap::new(); add_common_bindings(&mut km); let accept = accept_action(settings); // esc / ctrl-[ → exit km.bind(key("esc"), Action::Exit); km.bind(key("ctrl-["), Action::Exit); // Prefix key: ctrl- → enter prefix mode let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a'); km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode); // --- Accept / navigation edge behaviors (from [keys] settings) --- // right: behavior at end of line if settings.keys.accept_past_line_end { km.bind_conditional( key("right"), vec![ KeyRule::when(ConditionAtom::CursorAtEnd, Action::ReturnSelection), KeyRule::always(Action::CursorRight), ], ); } else { km.bind(key("right"), Action::CursorRight); } // left: behavior at start of line // accept_past_line_start takes precedence over exit_past_line_start if settings.keys.accept_past_line_start { km.bind_conditional( key("left"), vec![ KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection), KeyRule::always(Action::CursorLeft), ], ); } else if settings.keys.exit_past_line_start { km.bind_conditional( key("left"), vec![ KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit), KeyRule::always(Action::CursorLeft), ], ); } else { km.bind(key("left"), Action::CursorLeft); } // down/up: scroll with optional exit at boundary. // Non-inverted: down moves toward index 0 (can exit); up moves away (no exit). // Inverted: up moves toward index 0 (can exit); down moves away (no exit). let scroll_exits = settings.keys.scroll_exits; let invert = settings.invert; bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits); bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits); // backspace: behavior at start of line if settings.keys.accept_with_backspace { km.bind_conditional( key("backspace"), vec![ KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection), KeyRule::always(Action::DeleteCharBefore), ], ); } else { km.bind(key("backspace"), Action::DeleteCharBefore); } // --- Accept --- km.bind(key("enter"), accept.clone()); km.bind(key("ctrl-m"), accept); // --- Copy --- km.bind(key("ctrl-y"), Action::Copy); // --- Numeric shortcuts (alt-1..9 by default, ctrl-1..9 if ctrl_n_shortcuts) --- // These return the selection without executing, regardless of enter_accept. let num_mod = if settings.ctrl_n_shortcuts { "ctrl" } else { "alt" }; for n in 1..=9u8 { km.bind( key(&format!("{num_mod}-{n}")), Action::ReturnSelectionNth(n), ); } // --- Cursor movement --- km.bind(key("ctrl-left"), Action::CursorWordLeft); km.bind(key("alt-b"), Action::CursorWordLeft); km.bind(key("ctrl-b"), Action::CursorLeft); km.bind(key("ctrl-right"), Action::CursorWordRight); km.bind(key("alt-f"), Action::CursorWordRight); km.bind(key("ctrl-f"), Action::CursorRight); km.bind(key("home"), Action::CursorStart); // ctrl-a → CursorStart only if prefix char is NOT 'a' // (otherwise ctrl-a is already bound to EnterPrefixMode above) if prefix_char != 'a' { km.bind(key("ctrl-a"), Action::CursorStart); } km.bind(key("ctrl-e"), Action::CursorEnd); km.bind(key("end"), Action::CursorEnd); // --- Editing --- km.bind(key("ctrl-backspace"), Action::DeleteWordBefore); km.bind(key("ctrl-h"), Action::DeleteCharBefore); km.bind(key("ctrl-?"), Action::DeleteCharBefore); km.bind(key("ctrl-delete"), Action::DeleteWordAfter); km.bind(key("delete"), Action::DeleteCharAfter); // ctrl-d: if input empty → return original, otherwise delete char km.bind_conditional( key("ctrl-d"), vec![ KeyRule::when(ConditionAtom::InputEmpty, Action::ReturnOriginal), KeyRule::always(Action::DeleteCharAfter), ], ); km.bind(key("ctrl-w"), Action::DeleteToWordBoundary); km.bind(key("ctrl-u"), Action::ClearLine); // --- Search mode --- km.bind(key("ctrl-r"), Action::CycleFilterMode); km.bind(key("ctrl-s"), Action::CycleSearchMode); // --- Scroll (no exit) --- km.bind(key("ctrl-n"), Action::SelectNext); km.bind(key("ctrl-j"), Action::SelectNext); km.bind(key("ctrl-p"), Action::SelectPrevious); km.bind(key("ctrl-k"), Action::SelectPrevious); // --- Redraw --- km.bind(key("ctrl-l"), Action::Redraw); // --- Page scroll --- km.bind(key("pagedown"), Action::ScrollPageDown); km.bind(key("pageup"), Action::ScrollPageUp); km } // --------------------------------------------------------------------------- // Vim Normal keymap // --------------------------------------------------------------------------- /// Build the default vim-normal keymap. pub fn default_vim_normal_keymap(settings: &Settings) -> Keymap { let mut km = Keymap::new(); add_common_bindings(&mut km); // esc / ctrl-[ → exit (vim-normal exits, unlike vim-insert) km.bind(key("esc"), Action::Exit); km.bind(key("ctrl-["), Action::Exit); // Prefix key let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a'); km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode); // --- Vim navigation --- // j/k: scroll with optional exit at boundary. let scroll_exits = settings.keys.scroll_exits; let invert = settings.invert; bind_scroll_key(&mut km, "j", Action::SelectNext, !invert, scroll_exits); bind_scroll_key(&mut km, "k", Action::SelectPrevious, invert, scroll_exits); km.bind(key("h"), Action::CursorLeft); km.bind(key("l"), Action::CursorRight); // --- Vim cursor movement --- km.bind(key("0"), Action::CursorStart); km.bind(key("$"), Action::CursorEnd); km.bind(key("w"), Action::CursorWordRight); km.bind(key("b"), Action::CursorWordLeft); km.bind(key("e"), Action::CursorWordEnd); // --- Vim editing --- km.bind(key("x"), Action::DeleteCharAfter); km.bind(key("d d"), Action::ClearLine); km.bind(key("D"), Action::ClearToEnd); km.bind(key("C"), Action::VimChangeToEnd); // --- Mode switching --- km.bind(key("?"), Action::VimSearchInsert); km.bind(key("/"), Action::VimSearchInsert); km.bind(key("a"), Action::VimEnterInsertAfter); km.bind(key("A"), Action::VimEnterInsertAtEnd); km.bind(key("i"), Action::VimEnterInsert); km.bind(key("I"), Action::VimEnterInsertAtStart); // --- Numeric shortcuts (return selection without executing) --- for n in 1..=9u8 { km.bind(key(&n.to_string()), Action::ReturnSelectionNth(n)); } // --- Half/full page scroll --- km.bind(key("ctrl-u"), Action::ScrollHalfPageUp); km.bind(key("ctrl-d"), Action::ScrollHalfPageDown); km.bind(key("ctrl-b"), Action::ScrollPageUp); km.bind(key("ctrl-f"), Action::ScrollPageDown); // --- Jump --- km.bind(key("G"), Action::ScrollToBottom); km.bind(key("g g"), Action::ScrollToTop); km.bind(key("H"), Action::ScrollToScreenTop); km.bind(key("M"), Action::ScrollToScreenMiddle); km.bind(key("L"), Action::ScrollToScreenBottom); // --- Arrow keys (same as emacs for convenience) --- bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits); bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits); // --- Page scroll --- km.bind(key("pagedown"), Action::ScrollPageDown); km.bind(key("pageup"), Action::ScrollPageUp); // --- Accept --- let accept = accept_action(settings); km.bind(key("enter"), accept); km } // --------------------------------------------------------------------------- // Vim Insert keymap // --------------------------------------------------------------------------- /// Build the default vim-insert keymap. This clones the emacs keymap and /// overlays vim-insert-specific bindings (esc → enter normal mode). pub fn default_vim_insert_keymap(settings: &Settings) -> Keymap { let mut km = default_emacs_keymap(settings); // Override esc and ctrl-[ to enter normal mode instead of exiting km.bind(key("esc"), Action::VimEnterNormal); km.bind(key("ctrl-["), Action::VimEnterNormal); km } // --------------------------------------------------------------------------- // Inspector keymap // --------------------------------------------------------------------------- /// Build the default inspector keymap (tab index 1). /// /// The inspector shows details about the selected history item and has no /// text input, so we build a minimal keymap with only inspector-relevant /// bindings. We respect the user's `keymap_mode` to provide vim-style j/k /// navigation for vim users. pub fn default_inspector_keymap(settings: &Settings) -> Keymap { use atuin_client::settings::KeymapMode; let mut km = Keymap::new(); // Common bindings (same as search tab) km.bind(key("ctrl-c"), Action::ReturnOriginal); km.bind(key("ctrl-g"), Action::ReturnOriginal); km.bind(key("esc"), Action::Exit); km.bind(key("ctrl-["), Action::Exit); km.bind(key("tab"), Action::ReturnSelection); km.bind(key("ctrl-o"), Action::ToggleTab); // Accept behavior respects enter_accept setting let accept = if settings.enter_accept { Action::Accept } else { Action::ReturnSelection }; km.bind(key("enter"), accept); // Inspector-specific: delete history entry km.bind(key("ctrl-d"), Action::Delete); // Inspector navigation km.bind(key("up"), Action::InspectPrevious); km.bind(key("down"), Action::InspectNext); km.bind(key("pageup"), Action::InspectPrevious); km.bind(key("pagedown"), Action::InspectNext); // For vim users, add j/k navigation if matches!( settings.keymap_mode, KeymapMode::VimNormal | KeymapMode::VimInsert ) { km.bind(key("j"), Action::InspectNext); km.bind(key("k"), Action::InspectPrevious); } km } // --------------------------------------------------------------------------- // Prefix keymap // --------------------------------------------------------------------------- /// Build the default prefix keymap (active after ctrl-a prefix). pub fn default_prefix_keymap() -> Keymap { let mut km = Keymap::new(); km.bind(key("d"), Action::Delete); km.bind(key("a"), Action::CursorStart); km.bind_conditional( key("c"), vec![ KeyRule::when(ConditionAtom::HasContext, Action::ClearContext), KeyRule::always(Action::SwitchContext), ], ); km } // --------------------------------------------------------------------------- // KeymapSet construction // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Config → Keymap conversion // --------------------------------------------------------------------------- /// Convert a `KeyBindingConfig` (from TOML) into a `KeyBinding`. /// Returns `Err` if an action name or condition expression is invalid. fn parse_binding_config(config: &KeyBindingConfig) -> Result { match config { KeyBindingConfig::Simple(action_str) => { let action = Action::from_str(action_str)?; Ok(KeyBinding::simple(action)) } KeyBindingConfig::Rules(rules) => { let mut parsed_rules = Vec::with_capacity(rules.len()); for rule_cfg in rules { let action = Action::from_str(&rule_cfg.action)?; let rule = match &rule_cfg.when { None => KeyRule::always(action), Some(cond_str) => { let cond = ConditionExpr::parse(cond_str)?; KeyRule::when(cond, action) } }; parsed_rules.push(rule); } Ok(KeyBinding::conditional(parsed_rules)) } } } /// Apply a map of key-string → binding-config overrides to a keymap. /// Per-key override replaces the entire rule list for that key. /// Invalid keys or action names are logged and skipped. fn apply_config_to_keymap(keymap: &mut Keymap, overrides: &HashMap) { for (key_str, binding_cfg) in overrides { let key = match KeyInput::parse(key_str) { Ok(k) => k, Err(e) => { warn!("invalid key in keymap config: {key_str:?}: {e}"); continue; } }; match parse_binding_config(binding_cfg) { Ok(binding) => { keymap.bindings.insert(key, binding); } Err(e) => { warn!("invalid binding for {key_str:?} in keymap config: {e}"); } } } } impl KeymapSet { /// Build the complete set of default keymaps from settings. pub fn defaults(settings: &Settings) -> Self { KeymapSet { emacs: default_emacs_keymap(settings), vim_normal: default_vim_normal_keymap(settings), vim_insert: default_vim_insert_keymap(settings), inspector: default_inspector_keymap(settings), prefix: default_prefix_keymap(), } } /// Build keymaps from settings, applying any user `[keymap]` overrides. /// /// Precedence rules: /// - If `[keymap]` has any entries, `[keys]` is **ignored entirely**. /// Defaults are built with standard `[keys]` values, then `[keymap]` /// overrides are applied per-key. /// - If `[keymap]` is empty/absent, `[keys]` customizes the defaults /// (current behavior for backward compatibility). pub fn from_settings(settings: &Settings) -> Self { use atuin_client::settings::Keys; if settings.keymap.is_empty() { // No [keymap] section → use [keys] to customize defaults Self::defaults(settings) } else { // [keymap] present → ignore [keys], use standard defaults as base let mut base_settings = settings.clone(); base_settings.keys = Keys::standard_defaults(); let mut set = Self::defaults(&base_settings); set.apply_config(settings); set } } /// Apply user keymap config overrides to all modes. fn apply_config(&mut self, settings: &Settings) { let config = &settings.keymap; apply_config_to_keymap(&mut self.emacs, &config.emacs); apply_config_to_keymap(&mut self.vim_normal, &config.vim_normal); apply_config_to_keymap(&mut self.vim_insert, &config.vim_insert); apply_config_to_keymap(&mut self.inspector, &config.inspector); apply_config_to_keymap(&mut self.prefix, &config.prefix); } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::command::client::search::keybindings::conditions::EvalContext; fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext { EvalContext { cursor_position: cursor, input_width: width, input_byte_len: width, selected_index: selected, results_len: len, original_input_empty: false, has_context: false, } } fn default_settings() -> Settings { Settings::utc() } // -- Emacs keymap tests -- #[test] fn emacs_ctrl_c_returns_original() { let km = default_emacs_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!( km.resolve(&key("ctrl-c"), &ctx), Some(Action::ReturnOriginal) ); } #[test] fn emacs_esc_exits() { let km = default_emacs_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit)); } #[test] fn emacs_tab_returns_selection() { // enter_accept=false in test defaults → ReturnSelection let km = default_emacs_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection)); } #[test] fn emacs_enter_returns_selection() { // enter_accept=false in test defaults → ReturnSelection let km = default_emacs_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!( km.resolve(&key("enter"), &ctx), Some(Action::ReturnSelection) ); } #[test] fn emacs_enter_accept_true_uses_accept() { let mut settings = default_settings(); settings.enter_accept = true; let km = default_emacs_keymap(&settings); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept)); assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection)); } #[test] fn emacs_right_at_end_returns_selection() { let km = default_emacs_keymap(&default_settings()); // cursor at end of "hello" (width 5) let ctx = make_ctx(5, 5, 0, 10); assert_eq!( km.resolve(&key("right"), &ctx), Some(Action::ReturnSelection) ); } #[test] fn emacs_right_not_at_end_moves() { let km = default_emacs_keymap(&default_settings()); let ctx = make_ctx(2, 5, 0, 10); assert_eq!(km.resolve(&key("right"), &ctx), Some(Action::CursorRight)); } #[test] fn emacs_left_at_start_exits() { let km = default_emacs_keymap(&default_settings()); let ctx = make_ctx(0, 5, 0, 10); assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::Exit)); } #[test] fn emacs_left_not_at_start_moves() { let km = default_emacs_keymap(&default_settings()); let ctx = make_ctx(3, 5, 0, 10); assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::CursorLeft)); } #[test] fn emacs_down_at_start_exits() { let km = default_emacs_keymap(&default_settings()); // selected=0 → ListAtStart → Exit let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::Exit)); } #[test] fn emacs_down_not_at_start_selects_next() { let km = default_emacs_keymap(&default_settings()); // selected=5 → not at start → SelectNext let ctx = make_ctx(0, 0, 5, 10); assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::SelectNext)); } #[test] fn emacs_up_selects_previous() { let km = default_emacs_keymap(&default_settings()); // Non-inverted: up never exits (moves away from index 0) let ctx = make_ctx(0, 0, 5, 10); assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::SelectPrevious)); } #[test] fn emacs_ctrl_d_empty_returns_original() { let km = default_emacs_keymap(&default_settings()); // input empty (byte_len = 0) let ctx = make_ctx(0, 0, 0, 10); assert_eq!( km.resolve(&key("ctrl-d"), &ctx), Some(Action::ReturnOriginal) ); } #[test] fn emacs_ctrl_d_nonempty_deletes() { let km = default_emacs_keymap(&default_settings()); let ctx = make_ctx(2, 5, 0, 10); assert_eq!( km.resolve(&key("ctrl-d"), &ctx), Some(Action::DeleteCharAfter) ); } #[test] fn emacs_ctrl_n_selects_next_no_exit_condition() { let km = default_emacs_keymap(&default_settings()); // at start, but ctrl-n should NOT exit (no exit condition bound) let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("ctrl-n"), &ctx), Some(Action::SelectNext)); } #[test] fn emacs_prefix_key_enters_prefix() { let km = default_emacs_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!( km.resolve(&key("ctrl-a"), &ctx), Some(Action::EnterPrefixMode) ); } #[test] fn emacs_home_cursor_start() { let km = default_emacs_keymap(&default_settings()); let ctx = make_ctx(5, 10, 0, 10); assert_eq!(km.resolve(&key("home"), &ctx), Some(Action::CursorStart)); } // -- Vim Normal keymap tests -- #[test] fn vim_normal_j_at_start_exits() { let km = default_vim_normal_keymap(&default_settings()); // selected=0 → ListAtStart → Exit (non-inverted: j moves toward index 0) let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::Exit)); } #[test] fn vim_normal_j_not_at_start_selects_next() { let km = default_vim_normal_keymap(&default_settings()); let ctx = make_ctx(0, 0, 5, 10); assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::SelectNext)); } #[test] fn vim_normal_k_selects_previous() { let km = default_vim_normal_keymap(&default_settings()); // Non-inverted: k never exits (moves away from index 0) let ctx = make_ctx(0, 0, 5, 10); assert_eq!(km.resolve(&key("k"), &ctx), Some(Action::SelectPrevious)); } #[test] fn vim_normal_i_enters_insert() { let km = default_vim_normal_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("i"), &ctx), Some(Action::VimEnterInsert)); } #[test] fn vim_normal_slash_search_insert() { let km = default_vim_normal_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("/"), &ctx), Some(Action::VimSearchInsert)); } #[test] fn vim_normal_gg_scroll_to_top() { let km = default_vim_normal_keymap(&default_settings()); let ctx = make_ctx(0, 0, 50, 100); assert_eq!(km.resolve(&key("g g"), &ctx), Some(Action::ScrollToTop)); } #[test] fn vim_normal_big_g_scroll_to_bottom() { let km = default_vim_normal_keymap(&default_settings()); let ctx = make_ctx(0, 0, 50, 100); assert_eq!(km.resolve(&key("G"), &ctx), Some(Action::ScrollToBottom)); } #[test] fn vim_normal_numeric_returns_selection() { let km = default_vim_normal_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!( km.resolve(&key("3"), &ctx), Some(Action::ReturnSelectionNth(3)) ); } #[test] fn vim_normal_ctrl_u_half_page_up() { let km = default_vim_normal_keymap(&default_settings()); let ctx = make_ctx(0, 0, 50, 100); assert_eq!( km.resolve(&key("ctrl-u"), &ctx), Some(Action::ScrollHalfPageUp) ); } #[test] fn vim_normal_screen_jumps() { let km = default_vim_normal_keymap(&default_settings()); let ctx = make_ctx(0, 0, 50, 100); assert_eq!(km.resolve(&key("H"), &ctx), Some(Action::ScrollToScreenTop)); assert_eq!( km.resolve(&key("M"), &ctx), Some(Action::ScrollToScreenMiddle) ); assert_eq!( km.resolve(&key("L"), &ctx), Some(Action::ScrollToScreenBottom) ); } #[test] fn vim_normal_enter_returns_selection() { // enter_accept=false in test defaults → ReturnSelection let km = default_vim_normal_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!( km.resolve(&key("enter"), &ctx), Some(Action::ReturnSelection) ); } #[test] fn vim_normal_enter_accept_true_uses_accept() { let mut settings = default_settings(); settings.enter_accept = true; let km = default_vim_normal_keymap(&settings); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept)); } // -- Vim Insert keymap tests -- #[test] fn vim_insert_inherits_emacs_enter() { let km = default_vim_insert_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); // enter_accept=false → ReturnSelection assert_eq!( km.resolve(&key("enter"), &ctx), Some(Action::ReturnSelection) ); } #[test] fn vim_insert_esc_enters_normal() { let km = default_vim_insert_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::VimEnterNormal)); } #[test] fn vim_insert_ctrl_bracket_enters_normal() { let km = default_vim_insert_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!( km.resolve(&key("ctrl-["), &ctx), Some(Action::VimEnterNormal) ); } #[test] fn vim_insert_inherits_emacs_ctrl_d() { let km = default_vim_insert_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); // input empty → return original assert_eq!( km.resolve(&key("ctrl-d"), &ctx), Some(Action::ReturnOriginal) ); } // -- Inspector keymap tests -- #[test] fn inspector_ctrl_d_deletes() { let km = default_inspector_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("ctrl-d"), &ctx), Some(Action::Delete)); } #[test] fn inspector_up_inspects_previous() { let km = default_inspector_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::InspectPrevious)); } #[test] fn inspector_down_inspects_next() { let km = default_inspector_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::InspectNext)); } #[test] fn inspector_esc_exits() { let km = default_inspector_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit)); } #[test] fn inspector_tab_returns_selection() { // enter_accept=false → ReturnSelection let km = default_inspector_keymap(&default_settings()); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection)); } // -- Prefix keymap tests -- #[test] fn prefix_d_deletes() { let km = default_prefix_keymap(); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("d"), &ctx), Some(Action::Delete)); } #[test] fn prefix_a_cursor_start() { let km = default_prefix_keymap(); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("a"), &ctx), Some(Action::CursorStart)); } #[test] fn prefix_unknown_key_returns_none() { let km = default_prefix_keymap(); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(km.resolve(&key("x"), &ctx), None); } // -- KeymapSet tests -- #[test] fn keymap_set_defaults_builds() { let settings = default_settings(); let set = KeymapSet::defaults(&settings); let ctx = make_ctx(0, 0, 0, 10); // Sanity check each keymap has bindings assert!(set.emacs.resolve(&key("ctrl-c"), &ctx).is_some()); assert!(set.vim_normal.resolve(&key("ctrl-c"), &ctx).is_some()); assert!(set.vim_insert.resolve(&key("ctrl-c"), &ctx).is_some()); assert!(set.inspector.resolve(&key("ctrl-c"), &ctx).is_some()); assert!(set.prefix.resolve(&key("d"), &ctx).is_some()); } // -- Settings-dependent behavior -- #[test] fn custom_prefix_char() { let mut settings = default_settings(); settings.keys.prefix = "x".to_string(); let km = default_emacs_keymap(&settings); let ctx = make_ctx(0, 0, 0, 10); // ctrl-x should be prefix mode assert_eq!( km.resolve(&key("ctrl-x"), &ctx), Some(Action::EnterPrefixMode) ); // ctrl-a should now be CursorStart (not prefix) assert_eq!(km.resolve(&key("ctrl-a"), &ctx), Some(Action::CursorStart)); } #[test] fn ctrl_n_shortcuts_changes_numeric_modifier() { let mut settings = default_settings(); settings.ctrl_n_shortcuts = true; let km = default_emacs_keymap(&settings); let ctx = make_ctx(0, 0, 0, 10); // ctrl-1 should work assert_eq!( km.resolve(&key("ctrl-1"), &ctx), Some(Action::ReturnSelectionNth(1)) ); // alt-1 should NOT be bound assert_eq!(km.resolve(&key("alt-1"), &ctx), None); } #[test] fn default_alt_numeric_shortcuts() { let settings = default_settings(); let km = default_emacs_keymap(&settings); let ctx = make_ctx(0, 0, 0, 10); // alt-1 should work by default assert_eq!( km.resolve(&key("alt-1"), &ctx), Some(Action::ReturnSelectionNth(1)) ); } // ----------------------------------------------------------------------- // Config parsing and merging tests // ----------------------------------------------------------------------- #[test] fn parse_simple_binding_config() { use atuin_client::settings::KeyBindingConfig; let cfg = KeyBindingConfig::Simple("accept".to_string()); let binding = super::parse_binding_config(&cfg).unwrap(); assert_eq!(binding.rules.len(), 1); assert!(binding.rules[0].condition.is_none()); assert_eq!(binding.rules[0].action, Action::Accept); } #[test] fn parse_conditional_binding_config() { use atuin_client::settings::{KeyBindingConfig, KeyRuleConfig}; let cfg = KeyBindingConfig::Rules(vec![ KeyRuleConfig { when: Some("cursor-at-start".to_string()), action: "exit".to_string(), }, KeyRuleConfig { when: None, action: "cursor-left".to_string(), }, ]); let binding = super::parse_binding_config(&cfg).unwrap(); assert_eq!(binding.rules.len(), 2); assert!(binding.rules[0].condition.is_some()); assert_eq!(binding.rules[0].action, Action::Exit); assert!(binding.rules[1].condition.is_none()); assert_eq!(binding.rules[1].action, Action::CursorLeft); } #[test] fn parse_binding_config_invalid_action() { use atuin_client::settings::KeyBindingConfig; let cfg = KeyBindingConfig::Simple("not-a-real-action".to_string()); assert!(super::parse_binding_config(&cfg).is_err()); } #[test] fn parse_binding_config_invalid_condition() { use atuin_client::settings::{KeyBindingConfig, KeyRuleConfig}; let cfg = KeyBindingConfig::Rules(vec![KeyRuleConfig { when: Some("not-a-real-condition".to_string()), action: "exit".to_string(), }]); assert!(super::parse_binding_config(&cfg).is_err()); } #[test] fn config_override_replaces_key() { use atuin_client::settings::KeyBindingConfig; use std::collections::HashMap; let mut settings = default_settings(); let set = KeymapSet::defaults(&settings); // Default: ctrl-c → ReturnOriginal let ctx = make_ctx(0, 0, 0, 10); assert_eq!( set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::ReturnOriginal) ); // Override ctrl-c → Exit via config settings.keymap.emacs = HashMap::from([( "ctrl-c".to_string(), KeyBindingConfig::Simple("exit".to_string()), )]); let set = KeymapSet::from_settings(&settings); assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit)); } #[test] fn config_override_preserves_unoverridden_keys() { use atuin_client::settings::KeyBindingConfig; use std::collections::HashMap; let mut settings = default_settings(); // Override only ctrl-c; enter should keep its default settings.keymap.emacs = HashMap::from([( "ctrl-c".to_string(), KeyBindingConfig::Simple("exit".to_string()), )]); let set = KeymapSet::from_settings(&settings); let ctx = make_ctx(0, 0, 0, 10); // ctrl-c overridden assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit)); // enter still has default (enter_accept=false → ReturnSelection) assert_eq!( set.emacs.resolve(&key("enter"), &ctx), Some(Action::ReturnSelection) ); } #[test] fn config_conditional_override() { use atuin_client::settings::{KeyBindingConfig, KeyRuleConfig}; use std::collections::HashMap; let mut settings = default_settings(); // Override "up" with a custom conditional settings.keymap.emacs = HashMap::from([( "up".to_string(), KeyBindingConfig::Rules(vec![ KeyRuleConfig { when: Some("no-results".to_string()), action: "exit".to_string(), }, KeyRuleConfig { when: None, action: "select-previous".to_string(), }, ]), )]); let set = KeymapSet::from_settings(&settings); // With no results → exit let ctx = make_ctx(0, 0, 0, 0); assert_eq!(set.emacs.resolve(&key("up"), &ctx), Some(Action::Exit)); // With results → select-previous let ctx = make_ctx(0, 0, 0, 10); assert_eq!( set.emacs.resolve(&key("up"), &ctx), Some(Action::SelectPrevious) ); } #[test] fn from_settings_with_empty_config_equals_defaults() { let settings = default_settings(); let defaults = KeymapSet::defaults(&settings); let from_settings = KeymapSet::from_settings(&settings); // Verify a sample of keys produce the same results let ctx = make_ctx(0, 0, 0, 10); let test_keys = [ "ctrl-c", "enter", "esc", "tab", "up", "down", "left", "right", ]; for k in &test_keys { assert_eq!( defaults.emacs.resolve(&key(k), &ctx), from_settings.emacs.resolve(&key(k), &ctx), "mismatch for emacs key {k}" ); } } // ----------------------------------------------------------------------- // Phase 5: [keys] vs [keymap] backward compatibility // ----------------------------------------------------------------------- #[test] fn keymap_overrides_ignore_keys_section() { use atuin_client::settings::KeyBindingConfig; // Set up: [keys] disables scroll_exits, but [keymap] is present let mut settings = default_settings(); settings.keys.scroll_exits = false; // Without [keymap], scroll_exits=false means no exit condition on down let set_legacy = KeymapSet::defaults(&settings); // At list-at-start (selected=0), down should still be SelectNext (no exit) let ctx_at_boundary = make_ctx(0, 0, 0, 10); assert_eq!( set_legacy.emacs.resolve(&key("down"), &ctx_at_boundary), Some(Action::SelectNext), "legacy: down at boundary should be SelectNext with scroll_exits=false" ); // With [keymap] present (even just one override), [keys] is ignored // so the standard defaults (scroll_exits=true) apply settings.keymap.emacs = HashMap::from([( "ctrl-c".to_string(), KeyBindingConfig::Simple("exit".to_string()), )]); let set_keymap = KeymapSet::from_settings(&settings); // Not at boundary (selected=5): should SelectNext normally let ctx_not_at_boundary = make_ctx(0, 0, 5, 10); assert_eq!( set_keymap.emacs.resolve(&key("down"), &ctx_not_at_boundary), Some(Action::SelectNext), "keymap: down not at boundary should SelectNext" ); // At list-at-start (selected=0): should Exit (standard scroll_exits=true) assert_eq!( set_keymap.emacs.resolve(&key("down"), &ctx_at_boundary), Some(Action::Exit), "keymap: down at boundary should Exit (standard defaults restored)" ); } #[test] fn keymap_present_resets_to_standard_keys_defaults() { use atuin_client::settings::KeyBindingConfig; let mut settings = default_settings(); // Disable all [keys] behaviors settings.keys.exit_past_line_start = false; settings.keys.accept_past_line_end = false; // Without [keymap], left should be plain CursorLeft let set_legacy = KeymapSet::defaults(&settings); let ctx_at_start = make_ctx(0, 5, 0, 10); assert_eq!( set_legacy.emacs.resolve(&key("left"), &ctx_at_start), Some(Action::CursorLeft), "legacy: left should be plain CursorLeft without exit_past_line_start" ); // Add a [keymap] entry (for a different key) settings.keymap.emacs = HashMap::from([( "ctrl-c".to_string(), KeyBindingConfig::Simple("exit".to_string()), )]); let set_keymap = KeymapSet::from_settings(&settings); // Now left should use standard defaults (exit_past_line_start=true) // At cursor start → Exit assert_eq!( set_keymap.emacs.resolve(&key("left"), &ctx_at_start), Some(Action::Exit), "keymap: left at cursor start should exit (standard defaults)" ); // Right at cursor end should return selection (standard defaults: accept_past_line_end=true, enter_accept=false) let ctx_at_end = make_ctx(5, 5, 0, 10); assert_eq!( set_keymap.emacs.resolve(&key("right"), &ctx_at_end), Some(Action::ReturnSelection), "keymap: right at cursor end should return selection (standard defaults)" ); } #[test] fn keys_has_non_default_values_detection() { use atuin_client::settings::Keys; let standard = Keys::standard_defaults(); assert!(!standard.has_non_default_values()); let mut modified = Keys::standard_defaults(); modified.scroll_exits = false; assert!(modified.has_non_default_values()); let mut modified = Keys::standard_defaults(); modified.prefix = "x".to_string(); assert!(modified.has_non_default_values()); } #[test] fn original_input_empty_condition_in_config() { use atuin_client::settings::{KeyBindingConfig, KeyRuleConfig}; use std::collections::HashMap; let mut settings = default_settings(); // Configure esc to: if original-input-empty -> return-query, else return-original settings.keymap.emacs = HashMap::from([( "esc".to_string(), KeyBindingConfig::Rules(vec![ KeyRuleConfig { when: Some("original-input-empty".to_string()), action: "return-query".to_string(), }, KeyRuleConfig { when: None, action: "return-original".to_string(), }, ]), )]); let set = KeymapSet::from_settings(&settings); // When original input was empty, should return-query let ctx_original_empty = EvalContext { cursor_position: 0, input_width: 5, input_byte_len: 5, selected_index: 0, results_len: 10, original_input_empty: true, has_context: false, }; assert_eq!( set.emacs.resolve(&key("esc"), &ctx_original_empty), Some(Action::ReturnQuery), "esc with original_input_empty=true should return-query" ); // When original input was not empty, should return-original let ctx_original_not_empty = EvalContext { cursor_position: 0, input_width: 5, input_byte_len: 5, selected_index: 0, results_len: 10, original_input_empty: false, has_context: false, }; assert_eq!( set.emacs.resolve(&key("esc"), &ctx_original_not_empty), Some(Action::ReturnOriginal), "esc with original_input_empty=false should return-original" ); } } ================================================ FILE: crates/atuin/src/command/client/search/keybindings/key.rs ================================================ use std::fmt; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// A single key press with modifiers (e.g. `ctrl-c`, `alt-f`, `enter`). #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[allow(clippy::struct_excessive_bools)] pub struct SingleKey { pub code: KeyCodeValue, pub ctrl: bool, pub alt: bool, pub shift: bool, pub super_key: bool, } /// The key code portion of a key press. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KeyCodeValue { Char(char), Enter, Esc, Tab, Backspace, Delete, Insert, Up, Down, Left, Right, Home, End, PageUp, PageDown, Space, F(u8), Media(MediaKeyCode), } /// A key input that may be a single key or a multi-key sequence (e.g. `g g`). #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum KeyInput { Single(SingleKey), Sequence(Vec), } impl SingleKey { /// Convert a crossterm `KeyEvent` into a `SingleKey`. pub fn from_event(event: &KeyEvent) -> Option { let ctrl = event.modifiers.contains(KeyModifiers::CONTROL); let alt = event.modifiers.contains(KeyModifiers::ALT); let shift = event.modifiers.contains(KeyModifiers::SHIFT); let super_key = event.modifiers.contains(KeyModifiers::SUPER); let code = match event.code { KeyCode::Char(' ') => KeyCodeValue::Space, KeyCode::Char(c) => { // If shift is the only modifier and it's an uppercase letter, // we store the uppercase char directly and clear the shift flag // since the case already encodes it. if shift && !ctrl && !alt && !super_key && c.is_ascii_uppercase() { return Some(SingleKey { code: KeyCodeValue::Char(c), ctrl: false, alt: false, shift: false, super_key: false, }); } KeyCodeValue::Char(c) } KeyCode::Enter => KeyCodeValue::Enter, KeyCode::Esc => KeyCodeValue::Esc, KeyCode::Tab => KeyCodeValue::Tab, // BackTab is sent by many terminals for Shift+Tab KeyCode::BackTab => { return Some(SingleKey { code: KeyCodeValue::Tab, ctrl, alt, shift: true, super_key, }); } KeyCode::Backspace => KeyCodeValue::Backspace, KeyCode::Delete => KeyCodeValue::Delete, KeyCode::Insert => KeyCodeValue::Insert, KeyCode::Up => KeyCodeValue::Up, KeyCode::Down => KeyCodeValue::Down, KeyCode::Left => KeyCodeValue::Left, KeyCode::Right => KeyCodeValue::Right, KeyCode::Home => KeyCodeValue::Home, KeyCode::End => KeyCodeValue::End, KeyCode::PageUp => KeyCodeValue::PageUp, KeyCode::PageDown => KeyCodeValue::PageDown, KeyCode::F(n) => KeyCodeValue::F(n), KeyCode::Media(m) => KeyCodeValue::Media(m), _ => return None, }; Some(SingleKey { code, ctrl, alt, shift: if matches!(code, KeyCodeValue::Char(_)) { false } else { shift }, super_key, }) } /// Parse a key string like `"ctrl-c"`, `"alt-f"`, `"enter"`, `"G"`. pub fn parse(s: &str) -> Result { let s = s.trim(); let parts: Vec<&str> = s.split('-').collect(); let mut ctrl = false; let mut alt = false; let mut shift = false; let mut super_key = false; // All parts except the last are modifiers for &part in &parts[..parts.len() - 1] { match part.to_lowercase().as_str() { "ctrl" => ctrl = true, "alt" => alt = true, "shift" => shift = true, "super" | "cmd" | "win" => super_key = true, _ => return Err(format!("unknown modifier: {part}")), } } let key_part = parts[parts.len() - 1]; let code = match key_part.to_lowercase().as_str() { "enter" | "return" => KeyCodeValue::Enter, "esc" | "escape" => KeyCodeValue::Esc, "tab" => KeyCodeValue::Tab, "backspace" => KeyCodeValue::Backspace, "delete" | "del" => KeyCodeValue::Delete, "insert" | "ins" => KeyCodeValue::Insert, "up" => KeyCodeValue::Up, "down" => KeyCodeValue::Down, "left" => KeyCodeValue::Left, "right" => KeyCodeValue::Right, "home" => KeyCodeValue::Home, "end" => KeyCodeValue::End, "pageup" => KeyCodeValue::PageUp, "pagedown" => KeyCodeValue::PageDown, "space" => KeyCodeValue::Space, s if s.starts_with('f') && s.len() > 1 => { // Parse function keys like "f1", "f12" if let Ok(n) = s[1..].parse::() { if (1..=24).contains(&n) { KeyCodeValue::F(n) } else { return Err(format!("function key out of range: {key_part}")); } } else { return Err(format!("unknown key: {key_part}")); } } "[" => KeyCodeValue::Char('['), "]" => KeyCodeValue::Char(']'), "?" => KeyCodeValue::Char('?'), "/" => KeyCodeValue::Char('/'), "$" => KeyCodeValue::Char('$'), // Media keys (no dashes - the parser splits on dash for modifiers) "play" => KeyCodeValue::Media(MediaKeyCode::Play), "pause" => KeyCodeValue::Media(MediaKeyCode::Pause), "playpause" => KeyCodeValue::Media(MediaKeyCode::PlayPause), "stop" => KeyCodeValue::Media(MediaKeyCode::Stop), "fastforward" => KeyCodeValue::Media(MediaKeyCode::FastForward), "rewind" => KeyCodeValue::Media(MediaKeyCode::Rewind), "tracknext" => KeyCodeValue::Media(MediaKeyCode::TrackNext), "trackprevious" => KeyCodeValue::Media(MediaKeyCode::TrackPrevious), "record" => KeyCodeValue::Media(MediaKeyCode::Record), "lowervolume" => KeyCodeValue::Media(MediaKeyCode::LowerVolume), "raisevolume" => KeyCodeValue::Media(MediaKeyCode::RaiseVolume), "mutevolume" | "mute" => KeyCodeValue::Media(MediaKeyCode::MuteVolume), _ => { let chars: Vec = key_part.chars().collect(); if chars.len() == 1 { let c = chars[0]; // An uppercase letter implies shift (unless shift already specified) if c.is_ascii_uppercase() && !ctrl && !alt && !super_key { return Ok(SingleKey { code: KeyCodeValue::Char(c), ctrl: false, alt: false, shift: false, super_key: false, }); } KeyCodeValue::Char(c) } else { return Err(format!("unknown key: {key_part}")); } } }; Ok(SingleKey { code, ctrl, alt, shift, super_key, }) } } impl fmt::Display for SingleKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.super_key { write!(f, "super-")?; } if self.ctrl { write!(f, "ctrl-")?; } if self.alt { write!(f, "alt-")?; } if self.shift { write!(f, "shift-")?; } match &self.code { KeyCodeValue::Char(c) => write!(f, "{c}"), KeyCodeValue::Enter => write!(f, "enter"), KeyCodeValue::Esc => write!(f, "esc"), KeyCodeValue::Tab => write!(f, "tab"), KeyCodeValue::Backspace => write!(f, "backspace"), KeyCodeValue::Delete => write!(f, "delete"), KeyCodeValue::Insert => write!(f, "insert"), KeyCodeValue::Up => write!(f, "up"), KeyCodeValue::Down => write!(f, "down"), KeyCodeValue::Left => write!(f, "left"), KeyCodeValue::Right => write!(f, "right"), KeyCodeValue::Home => write!(f, "home"), KeyCodeValue::End => write!(f, "end"), KeyCodeValue::PageUp => write!(f, "pageup"), KeyCodeValue::PageDown => write!(f, "pagedown"), KeyCodeValue::Space => write!(f, "space"), KeyCodeValue::F(n) => write!(f, "f{n}"), KeyCodeValue::Media(m) => match m { MediaKeyCode::Play => write!(f, "play"), MediaKeyCode::Pause => write!(f, "media-pause"), MediaKeyCode::PlayPause => write!(f, "playpause"), MediaKeyCode::Stop => write!(f, "stop"), MediaKeyCode::FastForward => write!(f, "fastforward"), MediaKeyCode::Rewind => write!(f, "rewind"), MediaKeyCode::TrackNext => write!(f, "tracknext"), MediaKeyCode::TrackPrevious => write!(f, "trackprevious"), MediaKeyCode::Record => write!(f, "record"), MediaKeyCode::LowerVolume => write!(f, "lowervolume"), MediaKeyCode::RaiseVolume => write!(f, "raisevolume"), MediaKeyCode::MuteVolume => write!(f, "mutevolume"), MediaKeyCode::Reverse => write!(f, "reverse"), }, } } } impl KeyInput { /// Parse a key input string. Supports multi-key sequences separated by spaces /// (e.g. `"g g"`). pub fn parse(s: &str) -> Result { let s = s.trim(); // Check for space-separated multi-key sequences // But don't split "space" or modifier combos like "ctrl-a" let parts: Vec<&str> = s.split_whitespace().collect(); if parts.len() > 1 { let keys: Result, String> = parts.iter().map(|p| SingleKey::parse(p)).collect(); Ok(KeyInput::Sequence(keys?)) } else { Ok(KeyInput::Single(SingleKey::parse(s)?)) } } } impl fmt::Display for KeyInput { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { KeyInput::Single(k) => write!(f, "{k}"), KeyInput::Sequence(keys) => { for (i, k) in keys.iter().enumerate() { if i > 0 { write!(f, " ")?; } write!(f, "{k}")?; } Ok(()) } } } } impl Serialize for KeyInput { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(&self.to_string()) } } impl<'de> Deserialize<'de> for KeyInput { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; KeyInput::parse(&s).map_err(serde::de::Error::custom) } } #[cfg(test)] mod tests { use super::*; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; #[test] fn parse_simple_keys() { let k = SingleKey::parse("a").unwrap(); assert_eq!(k.code, KeyCodeValue::Char('a')); assert!(!k.ctrl && !k.alt && !k.shift); let k = SingleKey::parse("enter").unwrap(); assert_eq!(k.code, KeyCodeValue::Enter); let k = SingleKey::parse("esc").unwrap(); assert_eq!(k.code, KeyCodeValue::Esc); let k = SingleKey::parse("tab").unwrap(); assert_eq!(k.code, KeyCodeValue::Tab); let k = SingleKey::parse("space").unwrap(); assert_eq!(k.code, KeyCodeValue::Space); } #[test] fn parse_modifiers() { let k = SingleKey::parse("ctrl-c").unwrap(); assert_eq!(k.code, KeyCodeValue::Char('c')); assert!(k.ctrl); assert!(!k.alt); let k = SingleKey::parse("alt-f").unwrap(); assert_eq!(k.code, KeyCodeValue::Char('f')); assert!(k.alt); assert!(!k.ctrl); let k = SingleKey::parse("ctrl-alt-x").unwrap(); assert_eq!(k.code, KeyCodeValue::Char('x')); assert!(k.ctrl && k.alt); } #[test] fn parse_uppercase_implies_no_shift_flag() { let k = SingleKey::parse("G").unwrap(); assert_eq!(k.code, KeyCodeValue::Char('G')); assert!(!k.shift); assert!(!k.ctrl); } #[test] fn parse_special_chars() { let k = SingleKey::parse("ctrl-[").unwrap(); assert_eq!(k.code, KeyCodeValue::Char('[')); assert!(k.ctrl); let k = SingleKey::parse("?").unwrap(); assert_eq!(k.code, KeyCodeValue::Char('?')); let k = SingleKey::parse("/").unwrap(); assert_eq!(k.code, KeyCodeValue::Char('/')); } #[test] fn parse_multi_key_sequence() { let ki = KeyInput::parse("g g").unwrap(); match ki { KeyInput::Sequence(keys) => { assert_eq!(keys.len(), 2); assert_eq!(keys[0].code, KeyCodeValue::Char('g')); assert_eq!(keys[1].code, KeyCodeValue::Char('g')); } _ => panic!("expected sequence"), } } #[test] fn display_round_trip() { let cases = ["ctrl-c", "alt-f", "enter", "G", "tab", "pageup"]; for s in cases { let k = KeyInput::parse(s).unwrap(); let display = k.to_string(); let k2 = KeyInput::parse(&display).unwrap(); assert_eq!(k, k2, "round-trip failed for {s}"); } let ki = KeyInput::parse("g g").unwrap(); assert_eq!(ki.to_string(), "g g"); } #[test] fn from_event_basic() { let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); let k = SingleKey::from_event(&event).unwrap(); assert_eq!(k.code, KeyCodeValue::Char('c')); assert!(k.ctrl); assert!(!k.alt); let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); let k = SingleKey::from_event(&event).unwrap(); assert_eq!(k.code, KeyCodeValue::Enter); } #[test] fn from_event_uppercase() { // Crossterm sends uppercase chars with SHIFT modifier let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT); let k = SingleKey::from_event(&event).unwrap(); assert_eq!(k.code, KeyCodeValue::Char('G')); // shift flag should be cleared since the case encodes it assert!(!k.shift); } #[test] fn from_event_matches_parsed() { // Verify that from_event and parse produce the same SingleKey let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); let from_event = SingleKey::from_event(&event).unwrap(); let parsed = SingleKey::parse("ctrl-c").unwrap(); assert_eq!(from_event, parsed); let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT); let from_event = SingleKey::from_event(&event).unwrap(); let parsed = SingleKey::parse("G").unwrap(); assert_eq!(from_event, parsed); } #[test] fn parse_super_modifier() { let k = SingleKey::parse("super-a").unwrap(); assert_eq!(k.code, KeyCodeValue::Char('a')); assert!(k.super_key); assert!(!k.ctrl && !k.alt && !k.shift); // "cmd" is an alias for "super" let k2 = SingleKey::parse("cmd-a").unwrap(); assert_eq!(k, k2); // "win" is an alias for "super" let k3 = SingleKey::parse("win-a").unwrap(); assert_eq!(k, k3); } #[test] fn parse_super_with_other_modifiers() { let k = SingleKey::parse("super-ctrl-c").unwrap(); assert_eq!(k.code, KeyCodeValue::Char('c')); assert!(k.super_key && k.ctrl); assert!(!k.alt && !k.shift); } #[test] fn display_super_modifier() { let k = SingleKey::parse("super-a").unwrap(); assert_eq!(k.to_string(), "super-a"); let k = SingleKey::parse("super-ctrl-x").unwrap(); assert_eq!(k.to_string(), "super-ctrl-x"); } #[test] fn display_round_trip_super() { let k = KeyInput::parse("super-a").unwrap(); let display = k.to_string(); let k2 = KeyInput::parse(&display).unwrap(); assert_eq!(k, k2, "round-trip failed for super-a"); } #[test] fn from_event_super() { let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER); let k = SingleKey::from_event(&event).unwrap(); assert_eq!(k.code, KeyCodeValue::Char('a')); assert!(k.super_key); assert!(!k.ctrl && !k.alt && !k.shift); } #[test] fn from_event_super_matches_parsed() { let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER); let from_event = SingleKey::from_event(&event).unwrap(); let parsed = SingleKey::parse("super-a").unwrap(); assert_eq!(from_event, parsed); } #[test] fn super_uppercase_preserves_super() { // super-G should keep the super flag (unlike bare "G" which clears shift) let k = SingleKey::parse("super-G").unwrap(); assert_eq!(k.code, KeyCodeValue::Char('G')); assert!(k.super_key); } #[test] fn parse_errors() { assert!(SingleKey::parse("ctrl-alt-shift-xxx").is_err()); assert!(SingleKey::parse("foobar-a").is_err()); } #[test] fn parse_function_keys() { let k = SingleKey::parse("f1").unwrap(); assert_eq!(k.code, KeyCodeValue::F(1)); assert!(!k.ctrl && !k.alt && !k.shift); let k = SingleKey::parse("F12").unwrap(); assert_eq!(k.code, KeyCodeValue::F(12)); let k = SingleKey::parse("ctrl-f5").unwrap(); assert_eq!(k.code, KeyCodeValue::F(5)); assert!(k.ctrl); // F24 is valid (some keyboards have extended function keys) let k = SingleKey::parse("f24").unwrap(); assert_eq!(k.code, KeyCodeValue::F(24)); // F0 and F25+ are invalid assert!(SingleKey::parse("f0").is_err()); assert!(SingleKey::parse("f25").is_err()); } #[test] fn from_event_function_keys() { let event = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE); let k = SingleKey::from_event(&event).unwrap(); assert_eq!(k.code, KeyCodeValue::F(1)); let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::CONTROL); let k = SingleKey::from_event(&event).unwrap(); assert_eq!(k.code, KeyCodeValue::F(12)); assert!(k.ctrl); } #[test] fn display_function_keys() { let k = SingleKey::parse("f1").unwrap(); assert_eq!(k.to_string(), "f1"); let k = SingleKey::parse("ctrl-f12").unwrap(); assert_eq!(k.to_string(), "ctrl-f12"); } #[test] fn function_key_round_trip() { let cases = ["f1", "f12", "ctrl-f5", "alt-f10"]; for s in cases { let k = KeyInput::parse(s).unwrap(); let display = k.to_string(); let k2 = KeyInput::parse(&display).unwrap(); assert_eq!(k, k2, "round-trip failed for {s}"); } } #[test] fn from_event_function_key_matches_parsed() { let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE); let from_event = SingleKey::from_event(&event).unwrap(); let parsed = SingleKey::parse("f12").unwrap(); assert_eq!(from_event, parsed); } #[test] fn from_event_backtab_becomes_shift_tab() { // Many terminals send BackTab for Shift+Tab let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE); let k = SingleKey::from_event(&event).unwrap(); assert_eq!(k.code, KeyCodeValue::Tab); assert!(k.shift); assert!(!k.ctrl && !k.alt); } #[test] fn from_event_backtab_matches_parsed_shift_tab() { let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE); let from_event = SingleKey::from_event(&event).unwrap(); let parsed = SingleKey::parse("shift-tab").unwrap(); assert_eq!(from_event, parsed); } #[test] fn from_event_backtab_with_ctrl() { // BackTab with ctrl modifier let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::CONTROL); let k = SingleKey::from_event(&event).unwrap(); assert_eq!(k.code, KeyCodeValue::Tab); assert!(k.shift); assert!(k.ctrl); } #[test] fn parse_insert_key() { let k = SingleKey::parse("insert").unwrap(); assert_eq!(k.code, KeyCodeValue::Insert); assert!(!k.ctrl && !k.alt && !k.shift); let k = SingleKey::parse("ins").unwrap(); assert_eq!(k.code, KeyCodeValue::Insert); let k = SingleKey::parse("ctrl-insert").unwrap(); assert_eq!(k.code, KeyCodeValue::Insert); assert!(k.ctrl); } #[test] fn from_event_insert_key() { let event = KeyEvent::new(KeyCode::Insert, KeyModifiers::NONE); let k = SingleKey::from_event(&event).unwrap(); assert_eq!(k.code, KeyCodeValue::Insert); } #[test] fn insert_key_round_trip() { let k = KeyInput::parse("insert").unwrap(); let display = k.to_string(); assert_eq!(display, "insert"); let k2 = KeyInput::parse(&display).unwrap(); assert_eq!(k, k2); } } ================================================ FILE: crates/atuin/src/command/client/search/keybindings/keymap.rs ================================================ use std::collections::HashMap; use super::actions::Action; use super::conditions::{ConditionExpr, EvalContext}; use super::key::{KeyInput, SingleKey}; /// A single rule within a keybinding: an optional condition and an action. /// If the condition is `None`, the rule always matches. #[derive(Debug, Clone)] pub struct KeyRule { pub condition: Option, pub action: Action, } /// A keybinding is an ordered list of rules. The first rule whose condition /// matches (or has no condition) wins. #[derive(Debug, Clone)] pub struct KeyBinding { pub rules: Vec, } /// A keymap is a collection of keybindings indexed by key input. #[derive(Debug, Clone)] pub struct Keymap { pub bindings: HashMap, } impl KeyRule { /// Create an unconditional rule. pub fn always(action: Action) -> Self { KeyRule { condition: None, action, } } /// Create a conditional rule. Accepts any type convertible to `ConditionExpr`, /// including bare `ConditionAtom` values. pub fn when(condition: impl Into, action: Action) -> Self { KeyRule { condition: Some(condition.into()), action, } } } impl KeyBinding { /// Create a simple (unconditional) binding. pub fn simple(action: Action) -> Self { KeyBinding { rules: vec![KeyRule::always(action)], } } /// Create a conditional binding from a list of rules. pub fn conditional(rules: Vec) -> Self { KeyBinding { rules } } } impl Keymap { /// Create an empty keymap. pub fn new() -> Self { Keymap { bindings: HashMap::new(), } } /// Bind a key input to a simple (unconditional) action. pub fn bind(&mut self, key: KeyInput, action: Action) { self.bindings.insert(key, KeyBinding::simple(action)); } /// Bind a key input to a conditional set of rules. pub fn bind_conditional(&mut self, key: KeyInput, rules: Vec) { self.bindings.insert(key, KeyBinding::conditional(rules)); } /// Resolve a key input to an action given the current evaluation context. /// Returns `None` if the key has no binding or no rule's condition matches. pub fn resolve(&self, key: &KeyInput, ctx: &EvalContext) -> Option { let binding = self.bindings.get(key)?; for rule in &binding.rules { match &rule.condition { None => return Some(rule.action.clone()), Some(cond) if cond.evaluate(ctx) => return Some(rule.action.clone()), Some(_) => {} } } None } /// Check if any binding starts with the given single key as the first key /// of a multi-key sequence. Used to detect pending multi-key sequences. pub fn has_sequence_starting_with(&self, prefix: &SingleKey) -> bool { self.bindings.keys().any(|ki| match ki { KeyInput::Sequence(keys) => keys.first() == Some(prefix), KeyInput::Single(_) => false, }) } /// Merge another keymap into this one. Keys from `other` override keys in `self`. #[allow(dead_code)] pub fn merge(&mut self, other: &Keymap) { for (key, binding) in &other.bindings { self.bindings.insert(key.clone(), binding.clone()); } } } impl Default for Keymap { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::super::conditions::ConditionAtom; use super::*; fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext { EvalContext { cursor_position: cursor, input_width: width, input_byte_len: width, selected_index: selected, results_len: len, original_input_empty: false, has_context: false, } } #[test] fn simple_binding_resolves() { let mut keymap = Keymap::new(); let key = KeyInput::parse("ctrl-c").unwrap(); keymap.bind(key.clone(), Action::ReturnOriginal); let ctx = make_ctx(0, 0, 0, 10); assert_eq!(keymap.resolve(&key, &ctx), Some(Action::ReturnOriginal)); } #[test] fn conditional_first_match_wins() { let mut keymap = Keymap::new(); let key = KeyInput::parse("left").unwrap(); keymap.bind_conditional( key.clone(), vec![ KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit), KeyRule::always(Action::CursorLeft), ], ); // Cursor at start → Exit let ctx = make_ctx(0, 5, 0, 10); assert_eq!(keymap.resolve(&key, &ctx), Some(Action::Exit)); // Cursor not at start → CursorLeft let ctx = make_ctx(3, 5, 0, 10); assert_eq!(keymap.resolve(&key, &ctx), Some(Action::CursorLeft)); } #[test] fn no_match_returns_none() { let keymap = Keymap::new(); let key = KeyInput::parse("ctrl-c").unwrap(); let ctx = make_ctx(0, 0, 0, 0); assert_eq!(keymap.resolve(&key, &ctx), None); } #[test] fn conditional_no_condition_matches_returns_none() { let mut keymap = Keymap::new(); let key = KeyInput::parse("left").unwrap(); // Only one rule with a condition that won't match keymap.bind_conditional( key.clone(), vec![KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit)], ); // Cursor not at start → no match let ctx = make_ctx(3, 5, 0, 10); assert_eq!(keymap.resolve(&key, &ctx), None); } #[test] fn has_sequence_starting_with() { let mut keymap = Keymap::new(); let seq = KeyInput::parse("g g").unwrap(); keymap.bind(seq, Action::ScrollToTop); let g = SingleKey::parse("g").unwrap(); assert!(keymap.has_sequence_starting_with(&g)); let h = SingleKey::parse("h").unwrap(); assert!(!keymap.has_sequence_starting_with(&h)); } #[test] fn merge_overrides() { let mut base = Keymap::new(); let key = KeyInput::parse("ctrl-c").unwrap(); base.bind(key.clone(), Action::ReturnOriginal); let mut overlay = Keymap::new(); overlay.bind(key.clone(), Action::Exit); base.merge(&overlay); let ctx = make_ctx(0, 0, 0, 0); assert_eq!(base.resolve(&key, &ctx), Some(Action::Exit)); } #[test] fn merge_preserves_unoverridden() { let mut base = Keymap::new(); let key1 = KeyInput::parse("ctrl-c").unwrap(); let key2 = KeyInput::parse("ctrl-d").unwrap(); base.bind(key1.clone(), Action::ReturnOriginal); base.bind(key2.clone(), Action::DeleteCharAfter); let mut overlay = Keymap::new(); overlay.bind(key1.clone(), Action::Exit); base.merge(&overlay); let ctx = make_ctx(0, 0, 0, 0); assert_eq!(base.resolve(&key1, &ctx), Some(Action::Exit)); assert_eq!(base.resolve(&key2, &ctx), Some(Action::DeleteCharAfter)); } } ================================================ FILE: crates/atuin/src/command/client/search/keybindings/mod.rs ================================================ pub mod actions; pub mod conditions; pub mod defaults; pub mod key; pub mod keymap; pub use actions::Action; #[allow(unused_imports)] pub use conditions::{ConditionAtom, ConditionExpr, EvalContext}; pub use defaults::KeymapSet; #[allow(unused_imports)] pub use key::{KeyCodeValue, KeyInput, SingleKey}; #[allow(unused_imports)] pub use keymap::{KeyBinding, KeyRule, Keymap}; ================================================ FILE: crates/atuin/src/command/client/search.rs ================================================ use std::fs::File; use std::io::{IsTerminal as _, Write, stderr, stdout}; use atuin_common::utils::{self, Escapable as _}; use clap::Parser; use eyre::Result; use atuin_client::{ database::Database, database::{OptFilters, current_context}, encryption, history::{History, store::HistoryStore}, record::sqlite_store::SqliteStore, settings::{FilterMode, KeymapMode, SearchMode, Settings, Timezone}, theme::Theme, }; use super::history::ListMode; mod cursor; mod duration; mod engines; mod history_list; mod inspector; mod interactive; pub mod keybindings; pub use duration::format_duration_into; #[allow(clippy::struct_excessive_bools, clippy::struct_field_names)] #[derive(Parser, Debug)] pub struct Cmd { /// Filter search result by directory #[arg(long, short)] cwd: Option, /// Exclude directory from results #[arg(long = "exclude-cwd")] exclude_cwd: Option, /// Filter search result by exit code #[arg(long, short)] exit: Option, /// Exclude results with this exit code #[arg(long = "exclude-exit")] exclude_exit: Option, /// Only include results added before this date #[arg(long, short)] before: Option, /// Only include results after this date #[arg(long)] after: Option, /// How many entries to return at most #[arg(long)] limit: Option, /// Offset from the start of the results #[arg(long)] offset: Option, /// Open interactive search UI #[arg(long, short)] interactive: bool, /// Allow overriding filter mode over config #[arg(long = "filter-mode")] filter_mode: Option, /// Allow overriding search mode over config #[arg(long = "search-mode")] search_mode: Option, /// Marker argument used to inform atuin that it was invoked from a shell up-key binding (hidden from help to avoid confusion) #[arg(long = "shell-up-key-binding", hide = true)] shell_up_key_binding: bool, /// Notify the keymap at the shell's side #[arg(long = "keymap-mode", default_value = "auto")] keymap_mode: KeymapMode, /// Use human-readable formatting for time #[arg(long)] human: bool, #[arg(allow_hyphen_values = true)] query: Option>, /// Show only the text of the command #[arg(long)] cmd_only: bool, /// Terminate the output with a null, for better multiline handling #[arg(long)] print0: bool, /// Delete anything matching this query. Will not print out the match #[arg(long)] delete: bool, /// Delete EVERYTHING! #[arg(long)] delete_it_all: bool, /// Reverse the order of results, oldest first #[arg(long, short)] reverse: bool, /// Display the command time in another timezone other than the configured default. /// /// This option takes one of the following kinds of values: /// - the special value "local" (or "l") which refers to the system time zone /// - an offset from UTC (e.g. "+9", "-2:30") #[arg(long, visible_alias = "tz")] #[arg(allow_hyphen_values = true)] // Clippy warns about `Option>`, but we suppress it because we need // this distinction for proper argument handling. #[allow(clippy::option_option)] timezone: Option>, /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {exit} and /// {relativetime}. /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" #[arg(long, short)] format: Option, /// Set the maximum number of lines Atuin's interface should take up. #[arg(long = "inline-height")] inline_height: Option, /// Include duplicate commands in the output (non-interactive only) #[arg(long)] include_duplicates: bool, /// File name to write the result to (hidden from help as this is meant to be used from a script) #[arg(long = "result-file", hide = true)] result_file: Option, } impl Cmd { /// Returns true if this search command will run in interactive (TUI) mode pub fn is_interactive(&self) -> bool { self.interactive } // clippy: please write this instead // clippy: now it has too many lines // me: I'll do it later OKAY #[allow(clippy::too_many_lines)] pub async fn run( self, db: impl Database, settings: &mut Settings, store: SqliteStore, theme: &Theme, ) -> Result<()> { let query = self.query.unwrap_or_else(|| { std::env::var("ATUIN_QUERY").map_or_else( |_| vec![], |query| { query .split(' ') .map(std::string::ToString::to_string) .collect() }, ) }); if (self.delete_it_all || self.delete) && self.limit.is_some() { // Because of how deletion is implemented, it will always delete all matches // and disregard the limit option. It is also not clear what deletion with a // limit would even mean. Deleting the LIMIT most recent entries that match // the search query would make sense, but that wouldn't match what's displayed // when running the equivalent search, but deleting those entries that are // displayed with the search would leave any duplicates of those lines which may // or may not have been intended to be deleted. eprintln!("\"--limit\" is not compatible with deletion."); return Ok(()); } if self.delete && query.is_empty() { eprintln!( "Please specify a query to match the items you wish to delete. If you wish to delete all history, pass --delete-it-all" ); return Ok(()); } if self.delete_it_all && !query.is_empty() { eprintln!( "--delete-it-all will delete ALL of your history! It does not require a query." ); return Ok(()); } if let Some(search_mode) = self.search_mode { settings.search_mode = search_mode; } if let Some(filter_mode) = self.filter_mode { settings.filter_mode = Some(filter_mode); } if let Some(inline_height) = self.inline_height { settings.inline_height = inline_height; } settings.shell_up_key_binding = self.shell_up_key_binding; // `keymap_mode` specified in config.toml overrides the `--keymap-mode` // option specified in the keybindings. settings.keymap_mode = match settings.keymap_mode { KeymapMode::Auto => self.keymap_mode, value => value, }; settings.keymap_mode_shell = self.keymap_mode; let encryption_key: [u8; 32] = encryption::load_key(settings)?.into(); let host_id = Settings::host_id().await?; let history_store = HistoryStore::new(store.clone(), host_id, encryption_key); if self.interactive { let item = interactive::history(&query, settings, db, &history_store, theme).await?; if let Some(result_file) = self.result_file { let mut file = File::create(result_file)?; write!(file, "{item}")?; } else if !stdout().is_terminal() { // stdout is not a terminal - likely command substitution like VAR=$(atuin search -i) // Write to stdout so it gets captured println!("{item}"); } else if stderr().is_terminal() { eprintln!("{}", item.escape_control()); } else { eprintln!("{item}"); } } else { let opt_filter = OptFilters { exit: self.exit, exclude_exit: self.exclude_exit, cwd: self.cwd, exclude_cwd: self.exclude_cwd, before: self.before, after: self.after, limit: self.limit, offset: self.offset, reverse: self.reverse, include_duplicates: self.include_duplicates, }; let mut entries = run_non_interactive(settings, opt_filter.clone(), &query, &db).await?; if entries.is_empty() { std::process::exit(1) } // if we aren't deleting, print it all if self.delete || self.delete_it_all { // delete it // it only took me _years_ to add this // sorry while !entries.is_empty() { for entry in &entries { eprintln!("deleting {}", entry.id); if settings.sync.records { let (id, _) = history_store.delete(entry.id.clone()).await?; history_store.incremental_build(&db, &[id]).await?; } else { db.delete(entry.clone()).await?; } } entries = run_non_interactive(settings, opt_filter.clone(), &query, &db).await?; } } else { let format = match self.format { None => Some(settings.history_format.as_str()), _ => self.format.as_deref(), }; let tz = match self.timezone { Some(Some(tz)) => tz, // User provided a value Some(None) | None => settings.timezone, // No value was provided }; super::history::print_list( &entries, ListMode::from_flags(self.human, self.cmd_only), format, self.print0, true, tz, ); } } Ok(()) } } // This is supposed to more-or-less mirror the command line version, so ofc // it is going to have a lot of args #[allow(clippy::too_many_arguments, clippy::cast_possible_truncation)] async fn run_non_interactive( settings: &Settings, filter_options: OptFilters, query: &[String], db: &impl Database, ) -> Result> { let dir = if filter_options.cwd.as_deref() == Some(".") { Some(utils::get_current_dir()) } else { filter_options.cwd }; let context = current_context().await?; let opt_filter = OptFilters { cwd: dir.clone(), ..filter_options }; let filter_mode = settings.default_filter_mode(context.git_root.is_some()); let results = db .search( settings.search_mode, filter_mode, &context, query.join(" ").as_str(), opt_filter, ) .await?; Ok(results) } #[cfg(test)] mod tests { use super::Cmd; use clap::Parser; #[test] fn search_for_triple_dash() { // Issue #3028: searching for `---` should not be treated as a CLI flag let cmd = Cmd::try_parse_from(["search", "---"]); assert!(cmd.is_ok(), "Failed to parse '---' as a query: {cmd:?}"); let cmd = cmd.unwrap(); assert_eq!(cmd.query, Some(vec!["---".to_string()])); } #[test] fn search_for_double_dash_value() { // Searching for strings starting with -- should also work let cmd = Cmd::try_parse_from(["search", "--", "--foo"]); assert!(cmd.is_ok()); let cmd = cmd.unwrap(); assert_eq!(cmd.query, Some(vec!["--foo".to_string()])); } } ================================================ FILE: crates/atuin/src/command/client/setup.rs ================================================ use atuin_client::settings::Settings; use colored::Colorize; use eyre::Result; use std::io::{self, Write}; use toml_edit::{DocumentMut, value}; pub async fn run(_settings: &Settings) -> Result<()> { let enable_ai = prompt( "Atuin AI", "This will enable command generation and other AI features via the question mark key", Some( "By default, Atuin AI only has access to the name and version of your operating system and shell - your shell history is not sent to the AI.", ), )?; let enable_daemon = prompt( "Atuin Daemon", "This will enable improved search and history sync using a persistent background process", None, )?; let config_file = Settings::get_config_path()?; let config_str = tokio::fs::read_to_string(&config_file).await?; let mut doc = config_str.parse::()?; let mut changed = false; if enable_ai { changed = true; if !doc.contains_key("ai") { doc["ai"] = toml_edit::table(); } doc["ai"]["enabled"] = value(true); } if enable_daemon { changed = true; if !doc.contains_key("daemon") { doc["daemon"] = toml_edit::table(); } doc["daemon"]["enabled"] = value(true); doc["daemon"]["autostart"] = value(true); doc["search_mode"] = value("daemon-fuzzy"); } if changed { tokio::fs::write(config_file, doc.to_string()).await?; println!( "{check} Settings updated successfully", check = "✓".bold().bright_green() ); } else { println!( "{check} No settings changed", check = "✓".bold().bright_green() ); } Ok(()) } pub fn prompt(feature: &str, description: &str, note: Option<&str>) -> Result { println!( "> Enable {feature}?", feature = feature.bold().bright_blue() ); if let Some(note) = note { println!(" {description}"); print!(" {note} {q} ", q = "[Y/n]".bold()); } else { print!(" {description} {q} ", q = "[Y/n]".bold()); } io::stdout().flush().ok(); let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_lowercase(); Ok(answer.is_empty() || answer == "y" || answer == "yes") } ================================================ FILE: crates/atuin/src/command/client/stats.rs ================================================ use clap::Parser; use eyre::Result; use interim::parse_date_string; use time::{Duration, OffsetDateTime, Time}; use atuin_client::{ database::{Database, current_context}, settings::Settings, theme::Theme, }; use atuin_history::stats::{compute, pretty_print}; fn parse_ngram_size(s: &str) -> Result { let value = s .parse::() .map_err(|_| format!("'{s}' is not a valid window size"))?; if value == 0 { return Err("ngram window size must be at least 1".to_string()); } Ok(value) } #[derive(Parser, Debug)] #[command(infer_subcommands = true)] pub struct Cmd { /// Compute statistics for the specified period, leave blank for statistics since the beginning. See [this](https://docs.atuin.sh/reference/stats/) for more details. period: Vec, /// How many top commands to list #[arg(long, short, default_value = "10")] count: usize, /// The number of consecutive commands to consider #[arg(long, short, default_value = "1", value_parser = parse_ngram_size)] ngram_size: usize, } impl Cmd { pub async fn run(&self, db: &impl Database, settings: &Settings, theme: &Theme) -> Result<()> { let context = current_context().await?; let words = if self.period.is_empty() { String::from("all") } else { self.period.join(" ") }; let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0); let last_night = now.replace_time(Time::MIDNIGHT); let history = if words.as_str() == "all" { db.list(&[], &context, None, false, false).await? } else if words.trim() == "today" { let start = last_night; let end = start + Duration::days(1); db.range(start, end).await? } else if words.trim() == "month" { let end = last_night; let start = end - Duration::days(31); db.range(start, end).await? } else if words.trim() == "week" { let end = last_night; let start = end - Duration::days(7); db.range(start, end).await? } else if words.trim() == "year" { let end = last_night; let start = end - Duration::days(365); db.range(start, end).await? } else { let start = parse_date_string(&words, now, settings.dialect.into())?; let end = start + Duration::days(1); db.range(start, end).await? }; let stats = compute(settings, &history, self.count, self.ngram_size); if let Some(stats) = stats { pretty_print(stats, self.ngram_size, theme); } Ok(()) } } ================================================ FILE: crates/atuin/src/command/client/store/pull.rs ================================================ use clap::Args; use eyre::Result; use atuin_client::{ database::Database, record::store::Store, record::sync::Operation, record::{sqlite_store::SqliteStore, sync}, settings::Settings, }; #[derive(Args, Debug)] pub struct Pull { /// The tag to push (eg, 'history'). Defaults to all tags #[arg(long, short)] pub tag: Option, /// Force push records /// This will first wipe the local store, and then download all records from the remote #[arg(long, default_value = "false")] pub force: bool, /// Page Size /// How many records to download at once. Defaults to 100 #[arg(long, default_value = "100")] pub page: u64, } impl Pull { pub async fn run( &self, settings: &Settings, store: SqliteStore, db: &dyn Database, ) -> Result<()> { if self.force { println!("Forcing local overwrite!"); println!("Clearing local store"); store.delete_all().await?; } // We can actually just use the existing diff/etc to push // 1. Diff // 2. Get operations // 3. Filter operations by // a) are they a download op? // b) are they for the host/tag we are pushing here? let (diff, _) = sync::diff(settings, &store).await?; let operations = sync::operations(diff, &store).await?; let operations = operations .into_iter() .filter(|op| match op { // No noops or downloads thx Operation::Noop { .. } | Operation::Upload { .. } => false, // pull, so yes plz to downloads! Operation::Download { tag, .. } => { if self.force { return true; } if let Some(t) = self.tag.clone() && t != *tag { return false; } true } }) .collect(); let (_, downloaded) = sync::sync_remote(operations, &store, settings, self.page).await?; println!("Downloaded {} records", downloaded.len()); crate::sync::build(settings, &store, db, Some(&downloaded)).await?; Ok(()) } } ================================================ FILE: crates/atuin/src/command/client/store/purge.rs ================================================ use clap::Args; use eyre::Result; use atuin_client::{ encryption::load_key, record::{sqlite_store::SqliteStore, store::Store}, settings::Settings, }; #[derive(Args, Debug)] pub struct Purge {} impl Purge { pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { println!("Purging local records that cannot be decrypted"); let key = load_key(settings)?; match store.purge(&key.into()).await { Ok(()) => println!("Local store purge completed OK"), Err(e) => println!("Failed to purge local store: {e:?}"), } Ok(()) } } ================================================ FILE: crates/atuin/src/command/client/store/push.rs ================================================ use atuin_common::record::HostId; use clap::Args; use eyre::Result; use uuid::Uuid; use atuin_client::{ api_client::Client, record::sync::Operation, record::{sqlite_store::SqliteStore, sync}, settings::Settings, }; #[derive(Args, Debug)] pub struct Push { /// The tag to push (eg, 'history'). Defaults to all tags #[arg(long, short)] pub tag: Option, /// The host to push, in the form of a UUID host ID. Defaults to the current host. #[arg(long)] pub host: Option, /// Force push records /// This will override both host and tag, to be all hosts and all tags. First clear the remote store, then upload all of the /// local store #[arg(long, default_value = "false")] pub force: bool, /// Page Size /// How many records to upload at once. Defaults to 100 #[arg(long, default_value = "100")] pub page: u64, } impl Push { pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { let host_id = Settings::host_id().await?; if self.force { println!("Forcing remote store overwrite!"); println!("Clearing remote store"); let client = Client::new( &settings.sync_address, settings.sync_auth_token().await?, settings.network_connect_timeout, settings.network_timeout * 10, // we may be deleting a lot of data... so up the // timeout ) .expect("failed to create client"); client.delete_store().await?; } // We can actually just use the existing diff/etc to push // 1. Diff // 2. Get operations // 3. Filter operations by // a) are they an upload op? // b) are they for the host/tag we are pushing here? let (diff, _) = sync::diff(settings, &store).await?; let operations = sync::operations(diff, &store).await?; let operations = operations .into_iter() .filter(|op| match op { // No noops or downloads thx Operation::Noop { .. } | Operation::Download { .. } => false, // push, so yes plz to uploads! Operation::Upload { host, tag, .. } => { if self.force { return true; } if let Some(h) = self.host { if HostId(h) != *host { return false; } } else if *host != host_id { return false; } if let Some(t) = self.tag.clone() && t != *tag { return false; } true } }) .collect(); let (uploaded, _) = sync::sync_remote(operations, &store, settings, self.page).await?; println!("Uploaded {uploaded} records"); Ok(()) } } ================================================ FILE: crates/atuin/src/command/client/store/rebuild.rs ================================================ use atuin_dotfiles::store::{AliasStore, var::VarStore}; use atuin_scripts::store::ScriptStore; use clap::Args; use eyre::{Result, bail}; #[cfg(feature = "daemon")] use atuin_daemon::emit_event; use atuin_client::{ database::Database, encryption, history::store::HistoryStore, record::sqlite_store::SqliteStore, settings::Settings, }; #[derive(Args, Debug)] pub struct Rebuild { pub tag: String, } impl Rebuild { pub async fn run( &self, settings: &Settings, store: SqliteStore, database: &dyn Database, ) -> Result<()> { // keep it as a string and not an enum atm // would be super cool to build this dynamically in the future // eg register handles for rebuilding various tags without having to make this part of the // binary big match self.tag.as_str() { "history" => { self.rebuild_history(settings, store.clone(), database) .await?; } "dotfiles" => { self.rebuild_dotfiles(settings, store.clone()).await?; } "scripts" => { self.rebuild_scripts(settings, store.clone()).await?; } tag => bail!("unknown tag: {tag}"), } Ok(()) } async fn rebuild_history( &self, settings: &Settings, store: SqliteStore, database: &dyn Database, ) -> Result<()> { let encryption_key: [u8; 32] = encryption::load_key(settings)?.into(); let host_id = Settings::host_id().await?; let history_store = HistoryStore::new(store, host_id, encryption_key); history_store.build(database).await?; #[cfg(feature = "daemon")] let _ = emit_event(atuin_daemon::DaemonEvent::HistoryRebuilt).await; Ok(()) } async fn rebuild_dotfiles(&self, settings: &Settings, store: SqliteStore) -> Result<()> { let encryption_key: [u8; 32] = encryption::load_key(settings)?.into(); let host_id = Settings::host_id().await?; let alias_store = AliasStore::new(store.clone(), host_id, encryption_key); let var_store = VarStore::new(store.clone(), host_id, encryption_key); alias_store.build().await?; var_store.build().await?; Ok(()) } async fn rebuild_scripts(&self, settings: &Settings, store: SqliteStore) -> Result<()> { let encryption_key: [u8; 32] = encryption::load_key(settings)?.into(); let host_id = Settings::host_id().await?; let script_store = ScriptStore::new(store, host_id, encryption_key); let database = atuin_scripts::database::Database::new(settings.scripts.db_path.clone(), 1.0).await?; script_store.build(database).await?; Ok(()) } } ================================================ FILE: crates/atuin/src/command/client/store/rekey.rs ================================================ use clap::Args; use eyre::{Result, bail}; use tokio::{fs::File, io::AsyncWriteExt}; use atuin_client::{ encryption::{Key, decode_key, encode_key, generate_encoded_key, load_key}, record::sqlite_store::SqliteStore, record::store::Store, settings::Settings, }; #[derive(Args, Debug)] pub struct Rekey { /// The new key to use for encryption. Omit for a randomly-generated key key: Option, } impl Rekey { pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { let key = if let Some(key) = self.key.clone() { println!("Re-encrypting store with specified key"); match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) { Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?, Err(err) => { match err { // assume they copied in the base64 key bip39::ErrorKind::InvalidWord(_) => key, bip39::ErrorKind::InvalidChecksum => { bail!("key mnemonic was not valid") } bip39::ErrorKind::InvalidKeysize(_) | bip39::ErrorKind::InvalidWordLength(_) | bip39::ErrorKind::InvalidEntropyLength(_, _) => { bail!("key was not the correct length") } } } } } else { println!("Re-encrypting store with freshly-generated key"); let (_, encoded) = generate_encoded_key()?; encoded }; let current_key: [u8; 32] = load_key(settings)?.into(); let new_key: [u8; 32] = decode_key(key.clone())?.into(); store.re_encrypt(¤t_key, &new_key).await?; println!("Store rewritten. Saving new key"); let mut file = File::create(settings.key_path.clone()).await?; file.write_all(key.as_bytes()).await?; Ok(()) } } ================================================ FILE: crates/atuin/src/command/client/store/verify.rs ================================================ use clap::Args; use eyre::Result; use atuin_client::{ encryption::load_key, record::{sqlite_store::SqliteStore, store::Store}, settings::Settings, }; #[derive(Args, Debug)] pub struct Verify {} impl Verify { pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { println!("Verifying local store can be decrypted with the current key"); let key = load_key(settings)?; match store.verify(&key.into()).await { Ok(()) => println!("Local store encryption verified OK"), Err(e) => println!("Failed to verify local store encryption: {e:?}"), } Ok(()) } } ================================================ FILE: crates/atuin/src/command/client/store.rs ================================================ use clap::Subcommand; use eyre::Result; use atuin_client::{ database::Database, record::{sqlite_store::SqliteStore, store::Store}, settings::Settings, }; use itertools::Itertools; use time::{OffsetDateTime, UtcOffset}; #[cfg(feature = "sync")] mod push; #[cfg(feature = "sync")] mod pull; mod purge; mod rebuild; mod rekey; mod verify; #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub enum Cmd { /// Print the current status of the record store Status, /// Rebuild a store (eg atuin store rebuild history) Rebuild(rebuild::Rebuild), /// Re-encrypt the store with a new key (potential for data loss!) Rekey(rekey::Rekey), /// Delete all records in the store that cannot be decrypted with the current key Purge(purge::Purge), /// Verify that all records in the store can be decrypted with the current key Verify(verify::Verify), /// Push all records to the remote sync server (one way sync) #[cfg(feature = "sync")] Push(push::Push), /// Pull records from the remote sync server (one way sync) #[cfg(feature = "sync")] Pull(pull::Pull), } impl Cmd { pub async fn run( &self, settings: &Settings, database: &dyn Database, store: SqliteStore, ) -> Result<()> { match self { Self::Status => self.status(store).await, Self::Rebuild(rebuild) => rebuild.run(settings, store, database).await, Self::Rekey(rekey) => rekey.run(settings, store).await, Self::Verify(verify) => verify.run(settings, store).await, Self::Purge(purge) => purge.run(settings, store).await, #[cfg(feature = "sync")] Self::Push(push) => push.run(settings, store).await, #[cfg(feature = "sync")] Self::Pull(pull) => pull.run(settings, store, database).await, } } pub async fn status(&self, store: SqliteStore) -> Result<()> { let host_id = Settings::host_id().await?; let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); let status = store.status().await?; // TODO: should probs build some data structure and then pretty-print it or smth for (host, st) in status.hosts.iter().sorted_by_key(|(h, _)| *h) { let host_string = if host == &host_id { format!("host: {} <- CURRENT HOST", host.0.as_hyphenated()) } else { format!("host: {}", host.0.as_hyphenated()) }; println!("{host_string}"); for (tag, idx) in st.iter().sorted_by_key(|(tag, _)| *tag) { println!("\tstore: {tag}"); let first = store.first(*host, tag).await?; let last = store.last(*host, tag).await?; println!("\t\tidx: {idx}"); if let Some(first) = first { println!("\t\tfirst: {}", first.id.0.as_hyphenated()); let time = OffsetDateTime::from_unix_timestamp_nanos(i128::from(first.timestamp))? .to_offset(offset); println!("\t\t\tcreated: {time}"); } if let Some(last) = last { println!("\t\tlast: {}", last.id.0.as_hyphenated()); let time = OffsetDateTime::from_unix_timestamp_nanos(i128::from(last.timestamp))? .to_offset(offset); println!("\t\t\tcreated: {time}"); } } println!(); } Ok(()) } } ================================================ FILE: crates/atuin/src/command/client/sync/status.rs ================================================ use crate::{SHA, VERSION}; use atuin_client::{api_client, database::Database, settings::Settings}; use colored::Colorize; use eyre::{Result, bail}; pub async fn run(settings: &Settings, db: &impl Database) -> Result<()> { if !settings.logged_in().await? { bail!("You are not logged in to a sync server - cannot show sync status"); } let client = api_client::Client::new( &settings.sync_address, settings.sync_auth_token().await?, settings.network_connect_timeout, settings.network_timeout, )?; let me = client.me().await?; let last_sync = Settings::last_sync().await?; println!("Atuin v{VERSION} - Build rev {SHA}\n"); println!("{}", "[Local]".green()); if settings.auto_sync { println!("Sync frequency: {}", settings.sync_frequency); println!("Last sync: {}", last_sync.to_offset(settings.timezone.0)); } if !settings.sync.records { let local_count = db.history_count(false).await?; let deleted_count = db.history_count(true).await? - local_count; println!("History count: {local_count}"); println!("Deleted history count: {deleted_count}\n"); } if settings.auto_sync { println!("{}", "[Remote]".green()); println!("Address: {}", settings.sync_address); println!("Username: {}", me.username); } Ok(()) } ================================================ FILE: crates/atuin/src/command/client/sync.rs ================================================ use clap::Subcommand; use eyre::{Result, WrapErr}; use atuin_client::{ database::Database, encryption, history::store::HistoryStore, record::{sqlite_store::SqliteStore, store::Store, sync}, settings::Settings, }; mod status; use crate::command::client::account; #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub enum Cmd { /// Sync with the configured server Sync { /// Force re-download everything #[arg(long, short)] force: bool, }, /// Login to the configured server Login(account::login::Cmd), /// Log out Logout, /// Register with the configured server Register(account::register::Cmd), /// Print the encryption key for transfer to another machine Key { /// Switch to base64 output of the key #[arg(long)] base64: bool, }, /// Display the sync status Status, } impl Cmd { pub async fn run( self, settings: Settings, db: &impl Database, store: SqliteStore, ) -> Result<()> { match self { Self::Sync { force } => run(&settings, force, db, store).await, Self::Login(l) => l.run(&settings, &store).await, Self::Logout => account::logout::run().await, Self::Register(r) => r.run(&settings, &store).await, Self::Status => status::run(&settings, db).await, Self::Key { base64 } => { use atuin_client::encryption::{encode_key, load_key}; let key = load_key(&settings).wrap_err("could not load encryption key")?; if base64 { let encode = encode_key(&key).wrap_err("could not encode encryption key")?; println!("{encode}"); } else { let mnemonic = bip39::Mnemonic::from_entropy(&key, bip39::Language::English) .map_err(|_| eyre::eyre!("invalid key"))?; println!("{mnemonic}"); } Ok(()) } } } } async fn run( settings: &Settings, force: bool, db: &impl Database, store: SqliteStore, ) -> Result<()> { if settings.sync.records { let encryption_key: [u8; 32] = encryption::load_key(settings) .context("could not load encryption key")? .into(); let host_id = Settings::host_id().await?; let history_store = HistoryStore::new(store.clone(), host_id, encryption_key); let (uploaded, downloaded) = sync::sync(settings, &store).await?; crate::sync::build(settings, &store, db, Some(&downloaded)).await?; println!("{uploaded}/{} up/down to record store", downloaded.len()); let history_length = db.history_count(true).await?; let store_history_length = store.len_tag("history").await?; #[allow(clippy::cast_sign_loss)] if history_length as u64 > store_history_length { println!( "{history_length} in history index, but {store_history_length} in history store" ); println!("Running automatic history store init..."); // Internally we use the global filter mode, so this context is ignored. // don't recurse or loop here. history_store.init_store(db).await?; println!("Re-running sync due to new records locally"); // we'll want to run sync once more, as there will now be stuff to upload let (uploaded, downloaded) = sync::sync(settings, &store).await?; crate::sync::build(settings, &store, db, Some(&downloaded)).await?; println!("{uploaded}/{} up/down to record store", downloaded.len()); } } else { atuin_client::sync::sync(settings, force, db).await?; } println!( "Sync complete! {} items in history database, force: {}", db.history_count(true).await?, force ); Ok(()) } ================================================ FILE: crates/atuin/src/command/client/wrapped.rs ================================================ use crossterm::style::{ResetColor, SetAttribute}; use eyre::Result; use std::collections::{HashMap, HashSet}; use time::{Date, Duration, Month, OffsetDateTime, Time}; use atuin_client::{ database::Database, encryption, record::sqlite_store::SqliteStore, settings::Settings, theme::Theme, }; use atuin_dotfiles::store::AliasStore; use atuin_history::stats::{Stats, compute}; #[derive(Debug)] struct WrappedStats { nav_commands: usize, pkg_commands: usize, error_rate: f64, first_half_commands: Vec<(String, usize)>, second_half_commands: Vec<(String, usize)>, git_percentage: f64, busiest_hour: Option<(String, usize)>, } impl WrappedStats { #[allow(clippy::too_many_lines, clippy::cast_precision_loss)] fn new( settings: &Settings, stats: &Stats, history: &[atuin_client::history::History], alias_map: &HashMap, ) -> Self { // Helper to expand alias to its first command word let expand_alias = |cmd: &str| -> String { alias_map.get(cmd).map_or_else( || cmd.to_string(), |expanded| { expanded .split_whitespace() .next() .unwrap_or(cmd) .to_string() }, ) }; let nav_commands = stats .top .iter() .filter(|(cmd, _)| { let cmd = &cmd[0]; cmd == "cd" || cmd == "ls" || cmd == "pwd" || cmd == "pushd" || cmd == "popd" }) .map(|(_, count)| count) .sum(); let pkg_managers = [ "cargo", "npm", "pnpm", "yarn", "pip", "pip3", "pipenv", "poetry", "pipx", "uv", "brew", "apt", "apt-get", "apk", "pacman", "yay", "paru", "yum", "dnf", "dnf5", "rpm", "rpm-ostree", "zypper", "pkg", "chocolatey", "choco", "scoop", "winget", "gem", "bundle", "shards", "composer", "gradle", "maven", "mvn", "go get", "nuget", "dotnet", "mix", "hex", "rebar3", "nix", "nix-env", "cabal", "opam", ]; let pkg_commands = history .iter() .filter(|h| { let cmd = h.command.clone(); pkg_managers.iter().any(|pm| cmd.starts_with(pm)) }) .count(); // Error analysis let mut command_errors: HashMap = HashMap::new(); // (total_uses, errors) let midyear = history[0].timestamp + Duration::days(182); // Split year in half let mut first_half_commands: HashMap = HashMap::new(); let mut second_half_commands: HashMap = HashMap::new(); let mut hours: HashMap = HashMap::new(); for entry in history { let raw_cmd = entry .command .split_whitespace() .next() .unwrap_or("") .to_string(); let cmd = expand_alias(&raw_cmd); let (total, errors) = command_errors.entry(cmd.clone()).or_insert((0, 0)); *total += 1; if entry.exit != 0 { *errors += 1; } // Track command evolution if entry.timestamp < midyear { *first_half_commands.entry(cmd.clone()).or_default() += 1; } else { *second_half_commands.entry(cmd).or_default() += 1; } // Track hourly distribution let local_time = entry .timestamp .to_offset(time::UtcOffset::current_local_offset().unwrap_or(settings.timezone.0)); let hour = format!("{:02}:00", local_time.time().hour()); *hours.entry(hour).or_default() += 1; } let total_errors: usize = command_errors.values().map(|(_, errors)| errors).sum(); let total_commands: usize = command_errors.values().map(|(total, _)| total).sum(); let error_rate = total_errors as f64 / total_commands as f64; // Process command evolution data let mut first_half: Vec<_> = first_half_commands.into_iter().collect(); let mut second_half: Vec<_> = second_half_commands.into_iter().collect(); first_half.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); second_half.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); first_half.truncate(5); second_half.truncate(5); // Calculate git percentage let git_commands: usize = stats .top .iter() .filter(|(cmd, _)| cmd[0].starts_with("git")) .map(|(_, count)| count) .sum(); let git_percentage = git_commands as f64 / stats.total_commands as f64; // Find busiest hour let busiest_hour = hours.into_iter().max_by_key(|(_, count)| *count); Self { nav_commands, pkg_commands, error_rate, first_half_commands: first_half, second_half_commands: second_half, git_percentage, busiest_hour, } } } pub fn print_wrapped_header(year: i32) { let reset = ResetColor; let bold = SetAttribute(crossterm::style::Attribute::Bold); println!("{bold}╭────────────────────────────────────╮{reset}"); println!("{bold}│ ATUIN WRAPPED {year} │{reset}"); println!("{bold}│ Your Year in Shell History │{reset}"); println!("{bold}╰────────────────────────────────────╯{reset}"); println!(); } #[allow(clippy::cast_precision_loss)] fn print_fun_facts(wrapped_stats: &WrappedStats, stats: &Stats, year: i32) { let reset = ResetColor; let bold = SetAttribute(crossterm::style::Attribute::Bold); if wrapped_stats.git_percentage > 0.05 { println!( "{bold}🌟 You're a Git Power User!{reset} {bold}{:.1}%{reset} of your commands were Git operations\n", wrapped_stats.git_percentage * 100.0 ); } // Navigation patterns let nav_percentage = wrapped_stats.nav_commands as f64 / stats.total_commands as f64 * 100.0; if nav_percentage > 0.05 { println!( "{bold}🚀 You're a Navigator!{reset} {bold}{nav_percentage:.1}%{reset} of your time was spent navigating directories\n", ); } // Command vocabulary println!( "{bold}📚 Command Vocabulary{reset}: You know {bold}{}{reset} unique commands\n", stats.unique_commands ); // Package management println!( "{bold}📦 Package Management{reset}: You ran {bold}{}{reset} package-related commands\n", wrapped_stats.pkg_commands ); // Error patterns let error_percentage = wrapped_stats.error_rate * 100.0; println!( "{bold}🚨 Error Analysis{reset}: Your commands failed {bold}{error_percentage:.1}%{reset} of the time\n", ); // Command evolution println!("🔍 Command Evolution:"); // print stats for each half and compare println!(" {bold}Top Commands{reset} in the first half of {year}:"); for (cmd, count) in wrapped_stats.first_half_commands.iter().take(3) { println!(" {bold}{cmd}{reset} ({count} times)"); } println!(" {bold}Top Commands{reset} in the second half of {year}:"); for (cmd, count) in wrapped_stats.second_half_commands.iter().take(3) { println!(" {bold}{cmd}{reset} ({count} times)"); } // Find new favorite commands (in top 5 of second half but not in first half) let first_half_set: HashSet<_> = wrapped_stats .first_half_commands .iter() .map(|(cmd, _)| cmd) .collect(); let new_favorites: Vec<_> = wrapped_stats .second_half_commands .iter() .filter(|(cmd, _)| !first_half_set.contains(cmd)) .take(2) .collect(); if !new_favorites.is_empty() { println!(" {bold}New favorites{reset} in the second half:"); for (cmd, count) in new_favorites { println!(" {bold}{cmd}{reset} ({count} times)"); } } // Time patterns if let Some((hour, count)) = &wrapped_stats.busiest_hour { println!("\n🕘 Most Productive Hour: {bold}{hour}{reset} ({count} commands)",); // Night owl or early bird let hour_num = hour .split(':') .next() .unwrap_or("0") .parse::() .unwrap_or(0); if hour_num >= 22 || hour_num <= 4 { println!(" You're quite the night owl! 🦉"); } else if (5..=7).contains(&hour_num) { println!(" Early bird gets the worm! 🐦"); } } println!(); } pub async fn run( year: Option, db: &impl Database, settings: &Settings, store: SqliteStore, theme: &Theme, ) -> Result<()> { let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0); let month = now.month(); // If we're in December, then wrapped is for the current year. If not, it's for the previous year let year = year.unwrap_or_else(|| { if month == Month::December { now.year() } else { now.year() - 1 } }); let start = OffsetDateTime::new_in_offset( Date::from_calendar_date(year, Month::January, 1).unwrap(), Time::MIDNIGHT, now.offset(), ); let end = OffsetDateTime::new_in_offset( Date::from_calendar_date(year, Month::December, 31).unwrap(), Time::MIDNIGHT + Duration::days(1) - Duration::nanoseconds(1), now.offset(), ); let history = db.range(start, end).await?; if history.is_empty() { println!( "Your history for {year} is empty!\nMaybe 'atuin import' could help you import your previous history 🪄" ); return Ok(()); } // Load aliases for expansion let alias_map: HashMap = if settings.dotfiles.enabled { if let Ok(encryption_key) = encryption::load_key(settings) { let encryption_key: [u8; 32] = encryption_key.into(); let host_id = Settings::host_id().await?; let alias_store = AliasStore::new(store, host_id, encryption_key); alias_store .aliases() .await .unwrap_or_default() .into_iter() .map(|a| (a.name, a.value)) .collect() } else { HashMap::new() } } else { HashMap::new() }; // Compute overall stats using existing functionality let stats = compute(settings, &history, 10, 1).expect("Failed to compute stats"); let wrapped_stats = WrappedStats::new(settings, &stats, &history, &alias_map); // Print wrapped format print_wrapped_header(year); println!("🎉 In {year}, you typed {} commands!", stats.total_commands); println!( " That's ~{} commands every day\n", stats.total_commands / 365 ); println!("Your Top Commands:"); atuin_history::stats::pretty_print(stats.clone(), 1, theme); println!(); print_fun_facts(&wrapped_stats, &stats, year); Ok(()) } ================================================ FILE: crates/atuin/src/command/client.rs ================================================ use std::fs::{self, OpenOptions}; use std::path::{Path, PathBuf}; use clap::Subcommand; use eyre::{Result, WrapErr}; use atuin_client::{ database::Sqlite, record::sqlite_store::SqliteStore, settings::Settings, theme, }; use tracing_appender::rolling::{RollingFileAppender, Rotation}; use tracing_subscriber::{ Layer, filter::EnvFilter, filter::LevelFilter, fmt, fmt::format::FmtSpan, prelude::*, }; fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) { let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(retention_days * 24 * 60 * 60); let Ok(entries) = fs::read_dir(log_dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; // Match files like "search.log.2024-02-23" or "daemon.log.2024-02-23" if !name.starts_with(prefix) || name == prefix { continue; } if let Ok(metadata) = entry.metadata() && let Ok(modified) = metadata.modified() && modified < cutoff { let _ = fs::remove_file(&path); } } } #[cfg(feature = "sync")] mod sync; #[cfg(feature = "sync")] mod account; #[cfg(feature = "daemon")] mod daemon; mod default_config; mod doctor; mod dotfiles; mod history; mod import; mod info; mod init; mod kv; mod scripts; mod search; mod setup; mod stats; mod store; mod wrapped; #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub enum Cmd { /// Setup Atuin features #[command()] Setup, /// Manipulate shell history #[command(subcommand)] History(history::Cmd), /// Import shell history from file #[command(subcommand)] Import(import::Cmd), /// Calculate statistics for your history Stats(stats::Cmd), /// Interactive history search Search(search::Cmd), #[cfg(feature = "sync")] #[command(flatten)] Sync(sync::Cmd), /// Manage your sync account #[cfg(feature = "sync")] Account(account::Cmd), /// Get or set small key-value pairs #[command(subcommand)] Kv(kv::Cmd), /// Manage the atuin data store #[command(subcommand)] Store(store::Cmd), /// Manage your dotfiles with Atuin #[command(subcommand)] Dotfiles(dotfiles::Cmd), /// Manage your scripts with Atuin #[command(subcommand)] Scripts(scripts::Cmd), /// Print Atuin's shell init script #[command()] Init(init::Cmd), /// Information about dotfiles locations and ENV vars #[command()] Info, /// Run the doctor to check for common issues #[command()] Doctor, #[command()] Wrapped { year: Option }, /// *Experimental* Manage the background daemon #[cfg(feature = "daemon")] #[command()] Daemon(daemon::Cmd), /// Print the default atuin configuration (config.toml) #[command()] DefaultConfig, /// Run the AI assistant #[cfg(feature = "ai")] #[command(subcommand)] Ai(atuin_ai::commands::Commands), } impl Cmd { pub fn run(self) -> Result<()> { // Daemonize before creating the async runtime – fork() inside a live // tokio runtime corrupts its internal state. #[cfg(all(unix, feature = "daemon"))] if let Self::Daemon(ref cmd) = self && cmd.should_daemonize() { daemon::daemonize_current_process()?; } let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let settings = Settings::new().wrap_err("could not load client settings")?; let theme_manager = theme::ThemeManager::new(settings.theme.debug, None); let res = runtime.block_on(self.run_inner(settings, theme_manager)); runtime.shutdown_timeout(std::time::Duration::from_millis(50)); res } #[allow(clippy::too_many_lines)] async fn run_inner( self, mut settings: Settings, mut theme_manager: theme::ThemeManager, ) -> Result<()> { // ATUIN_LOG env var overrides config file level settings let env_log_set = std::env::var("ATUIN_LOG").is_ok(); // Base filter from env var (or empty if not set) let base_filter = EnvFilter::from_env("ATUIN_LOG").add_directive("sqlx_sqlite::regexp=off".parse()?); let is_interactive_search = matches!(&self, Self::Search(cmd) if cmd.is_interactive()); // Use file-based logging for interactive search (TUI mode) let use_search_logging = is_interactive_search && settings.logs.search_enabled(); // Use file-based logging for daemon #[cfg(feature = "daemon")] let use_daemon_logging = matches!(&self, Self::Daemon(_)) && settings.logs.daemon_enabled(); #[cfg(not(feature = "daemon"))] let use_daemon_logging = false; // Check if daemon should also log to console #[cfg(feature = "daemon")] let daemon_show_logs = matches!(&self, Self::Daemon(cmd) if cmd.show_logs()); #[cfg(not(feature = "daemon"))] let daemon_show_logs = false; // Set up span timing JSON logs if ATUIN_SPAN is set let span_path = std::env::var("ATUIN_SPAN").ok().map(|p| { if p.is_empty() { "atuin-spans.json".to_string() } else { p } }); // Helper to create span timing layer macro_rules! make_span_layer { ($path:expr) => {{ let span_file = OpenOptions::new() .create(true) .truncate(true) .write(true) .open($path)?; Some( fmt::layer() .json() .with_writer(span_file) .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE) .with_filter(LevelFilter::TRACE), ) }}; } // Build the subscriber with all configured layers if use_search_logging { let search_filename = settings.logs.search.file.clone(); let log_dir = PathBuf::from(&settings.logs.dir); fs::create_dir_all(&log_dir)?; // Clean up old log files cleanup_old_logs(&log_dir, &search_filename, settings.logs.search_retention()); let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, &search_filename); // Use config level unless ATUIN_LOG is set let filter = if env_log_set { base_filter } else { EnvFilter::default() .add_directive(settings.logs.search_level().as_directive().parse()?) .add_directive("sqlx_sqlite::regexp=off".parse()?) }; let base = tracing_subscriber::registry().with( fmt::layer() .with_writer(file_appender) .with_ansi(false) .with_filter(filter), ); match &span_path { Some(sp) => { base.with(make_span_layer!(sp)).init(); } None => { base.init(); } } } else if use_daemon_logging { let daemon_filename = settings.logs.daemon.file.clone(); let log_dir = PathBuf::from(&settings.logs.dir); fs::create_dir_all(&log_dir)?; // Clean up old log files cleanup_old_logs(&log_dir, &daemon_filename, settings.logs.daemon_retention()); let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, &daemon_filename); // Use config level unless ATUIN_LOG is set let file_filter = if env_log_set { base_filter } else { EnvFilter::default() .add_directive(settings.logs.daemon_level().as_directive().parse()?) .add_directive("sqlx_sqlite::regexp=off".parse()?) }; let file_layer = fmt::layer() .with_writer(file_appender) .with_ansi(false) .with_filter(file_filter); // Optionally add console layer for --show-logs if daemon_show_logs { let console_filter = EnvFilter::from_env("ATUIN_LOG") .add_directive("sqlx_sqlite::regexp=off".parse()?); let console_layer = fmt::layer().with_filter(console_filter); let base = tracing_subscriber::registry() .with(file_layer) .with(console_layer); match &span_path { Some(sp) => { base.with(make_span_layer!(sp)).init(); } None => { base.init(); } } } else { let base = tracing_subscriber::registry().with(file_layer); match &span_path { Some(sp) => { base.with(make_span_layer!(sp)).init(); } None => { base.init(); } } } } tracing::trace!(command = ?self, "client command"); // Skip initializing any databases for history // This is a pretty hot path, as it runs before and after every single command the user // runs match self { Self::History(history) => return history.run(&settings).await, Self::Init(init) => return init.run(&settings).await, Self::Doctor => return doctor::run(&settings).await, _ => {} } let db_path = PathBuf::from(settings.db_path.as_str()); let record_store_path = PathBuf::from(settings.record_store_path.as_str()); let db = Sqlite::new(db_path, settings.local_timeout).await?; let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?; let theme_name = settings.theme.name.clone(); let theme = theme_manager.load_theme(theme_name.as_str(), settings.theme.max_depth); match self { Self::Setup => setup::run(&settings).await, Self::Import(import) => import.run(&db).await, Self::Stats(stats) => stats.run(&db, &settings, theme).await, Self::Search(search) => search.run(db, &mut settings, sqlite_store, theme).await, #[cfg(feature = "sync")] Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await, #[cfg(feature = "sync")] Self::Account(account) => account.run(settings, sqlite_store).await, Self::Kv(kv) => kv.run(&settings, &sqlite_store).await, Self::Store(store) => store.run(&settings, &db, sqlite_store).await, Self::Dotfiles(dotfiles) => dotfiles.run(&settings, sqlite_store).await, Self::Scripts(scripts) => scripts.run(&settings, sqlite_store, &db).await, Self::Info => { info::run(&settings); Ok(()) } Self::DefaultConfig => { default_config::run(); Ok(()) } Self::Wrapped { year } => wrapped::run(year, &db, &settings, sqlite_store, theme).await, #[cfg(feature = "daemon")] Self::Daemon(cmd) => cmd.run(settings, sqlite_store, db).await, Self::History(_) | Self::Init(_) | Self::Doctor => unreachable!(), #[cfg(feature = "ai")] Self::Ai(cli) => atuin_ai::commands::run(cli, &settings).await, } } } ================================================ FILE: crates/atuin/src/command/contributors.rs ================================================ static CONTRIBUTORS: &str = include_str!("CONTRIBUTORS"); pub fn run() { println!("\n{CONTRIBUTORS}"); } ================================================ FILE: crates/atuin/src/command/external.rs ================================================ use std::fmt::Write as _; use std::process::Command; use std::{io, process}; #[cfg(feature = "client")] use atuin_client::plugin::OfficialPluginRegistry; use clap::CommandFactory; use clap::builder::{StyledStr, Styles}; use eyre::Result; use crate::Atuin; pub fn run(args: &[String]) -> Result<()> { let subcommand = &args[0]; let bin = format!("atuin-{subcommand}"); let mut cmd = Command::new(&bin); cmd.args(&args[1..]); let spawn_result = match cmd.spawn() { Ok(child) => Ok(child), Err(e) => match e.kind() { io::ErrorKind::NotFound => { let output = render_not_found(subcommand, &bin); Err(output) } _ => Err(e.to_string().into()), }, }; match spawn_result { Ok(mut child) => { let status = child.wait()?; if status.success() { Ok(()) } else { process::exit(status.code().unwrap_or(1)); } } Err(e) => { eprintln!("{}", e.ansi()); process::exit(1); } } } fn render_not_found(subcommand: &str, bin: &str) -> StyledStr { let mut output = StyledStr::new(); let styles = Styles::styled(); let error = styles.get_error(); let invalid = styles.get_invalid(); let literal = styles.get_literal(); #[cfg(feature = "client")] { let registry = OfficialPluginRegistry::new(); // Check if this is an official plugin if let Some(install_message) = registry.get_install_message(subcommand) { let _ = write!(output, "{error}error:{error:#} "); let _ = write!( output, "'{invalid}{subcommand}{invalid:#}' is an official atuin plugin, but it's not installed" ); let _ = write!(output, "\n\n"); let _ = write!(output, "{install_message}"); return output; } } let mut atuin_cmd = Atuin::command(); let usage = atuin_cmd.render_usage(); let _ = write!(output, "{error}error:{error:#} "); let _ = write!( output, "unrecognized subcommand '{invalid}{subcommand}{invalid:#}' " ); let _ = write!( output, "and no executable named '{invalid}{bin}{invalid:#}' found in your PATH" ); let _ = write!(output, "\n\n"); let _ = write!(output, "{usage}"); let _ = write!(output, "\n\n"); let _ = write!( output, "For more information, try '{literal}--help{literal:#}'." ); output } ================================================ FILE: crates/atuin/src/command/gen_completions.rs ================================================ use clap::{CommandFactory, Parser, ValueEnum}; use clap_complete::{Generator, Shell, generate, generate_to}; use clap_complete_nushell::Nushell; use eyre::Result; // clap put nushell completions into a separate package due to the maintainers // being a little less committed to support them. // This means we have to do a tiny bit of legwork to combine these completions // into one command. #[derive(Debug, Clone, ValueEnum)] #[value(rename_all = "lower")] pub enum GenShell { Bash, Elvish, Fish, Nushell, PowerShell, Zsh, } impl Generator for GenShell { fn file_name(&self, name: &str) -> String { match self { // clap_complete Self::Bash => Shell::Bash.file_name(name), Self::Elvish => Shell::Elvish.file_name(name), Self::Fish => Shell::Fish.file_name(name), Self::PowerShell => Shell::PowerShell.file_name(name), Self::Zsh => Shell::Zsh.file_name(name), // clap_complete_nushell Self::Nushell => Nushell.file_name(name), } } fn generate(&self, cmd: &clap::Command, buf: &mut dyn std::io::prelude::Write) { match self { // clap_complete Self::Bash => Shell::Bash.generate(cmd, buf), Self::Elvish => Shell::Elvish.generate(cmd, buf), Self::Fish => Shell::Fish.generate(cmd, buf), Self::PowerShell => Shell::PowerShell.generate(cmd, buf), Self::Zsh => Shell::Zsh.generate(cmd, buf), // clap_complete_nushell Self::Nushell => Nushell.generate(cmd, buf), } } } #[derive(Debug, Parser)] pub struct Cmd { /// Set the shell for generating completions #[arg(long, short)] shell: GenShell, /// Set the output directory #[arg(long, short)] out_dir: Option, } impl Cmd { pub fn run(self) -> Result<()> { let Cmd { shell, out_dir } = self; let mut cli = crate::Atuin::command(); match out_dir { Some(out_dir) => { generate_to(shell, &mut cli, env!("CARGO_PKG_NAME"), &out_dir)?; } None => { generate( shell, &mut cli, env!("CARGO_PKG_NAME"), &mut std::io::stdout(), ); } } Ok(()) } } ================================================ FILE: crates/atuin/src/command/mod.rs ================================================ use clap::Subcommand; use eyre::Result; #[cfg(not(windows))] use rustix::{fs::Mode, process::umask}; #[cfg(feature = "client")] mod client; mod contributors; mod gen_completions; mod external; #[derive(Subcommand)] #[command(infer_subcommands = true)] #[allow(clippy::large_enum_variant)] pub enum AtuinCmd { #[cfg(feature = "client")] #[command(flatten)] Client(client::Cmd), /// Terminal emulator for atuin #[cfg(feature = "hex")] Hex { #[command(subcommand)] cmd: Option, }, /// Generate a UUID Uuid, Contributors, /// Generate shell completions GenCompletions(gen_completions::Cmd), #[command(external_subcommand)] External(Vec), } impl AtuinCmd { pub fn run(self) -> Result<()> { #[cfg(not(windows))] { // set umask before we potentially open/create files // or in other words, 077. Do not allow any access to any other user let mode = Mode::RWXG | Mode::RWXO; umask(mode); } match self { #[cfg(feature = "client")] Self::Client(client) => client.run(), #[cfg(feature = "hex")] Self::Hex { cmd } => { atuin_hex::run(cmd); Ok(()) } Self::Contributors => { contributors::run(); Ok(()) } Self::Uuid => { println!("{}", atuin_common::utils::uuid_v7().as_simple()); Ok(()) } Self::GenCompletions(gen_completions) => gen_completions.run(), Self::External(args) => external::run(&args), } } } ================================================ FILE: crates/atuin/src/main.rs ================================================ #![warn(clippy::pedantic, clippy::nursery)] #![allow(clippy::use_self, clippy::missing_const_for_fn)] // not 100% reliable use clap::Parser; use clap::builder::Styles; use clap::builder::styling::{AnsiColor, Effects}; use eyre::Result; use command::AtuinCmd; mod command; #[cfg(feature = "sync")] mod sync; const VERSION: &str = env!("CARGO_PKG_VERSION"); const SHA: &str = env!("GIT_HASH"); const LONG_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")"); static HELP_TEMPLATE: &str = "\ {before-help}{name} {version} {author} {about} {usage-heading} {usage} {all-args}{after-help}"; const STYLES: Styles = Styles::styled() .header(AnsiColor::Yellow.on_default().effects(Effects::BOLD)) .usage(AnsiColor::Green.on_default().effects(Effects::BOLD)) .literal(AnsiColor::Green.on_default().effects(Effects::BOLD)) .placeholder(AnsiColor::Green.on_default()); /// Magical shell history #[derive(Parser)] #[command( author = "Ellie Huxtable ", version = VERSION, long_version = LONG_VERSION, help_template(HELP_TEMPLATE), styles = STYLES, )] struct Atuin { #[command(subcommand)] atuin: AtuinCmd, } impl Atuin { fn run(self) -> Result<()> { self.atuin.run() } } fn main() -> Result<()> { Atuin::parse().run() } ================================================ FILE: crates/atuin/src/shell/.gitattributes ================================================ * eol=lf ================================================ FILE: crates/atuin/src/shell/atuin.bash ================================================ # Include guard if [[ ${__atuin_initialized-} == true ]]; then false elif [[ $- != *i* ]]; then # Enable only in interactive shells false elif ((BASH_VERSINFO[0] < 3 || BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1)); then # Require bash >= 3.1 [[ -t 2 ]] && printf 'atuin: requires bash >= 3.1 for the integration.\n' >&2 false else # (include guard) beginning of main content #------------------------------------------------------------------------------ __atuin_initialized=true if [[ -z "${ATUIN_SESSION:-}" || "${ATUIN_SHLVL:-}" != "$SHLVL" ]]; then ATUIN_SESSION=$(atuin uuid) export ATUIN_SESSION export ATUIN_SHLVL=$SHLVL fi ATUIN_STTY=$(stty -g) ATUIN_HISTORY_ID="" export ATUIN_PREEXEC_BACKEND=$SHLVL:none __atuin_update_preexec_backend() { if [[ ${BLE_ATTACHED-} ]]; then ATUIN_PREEXEC_BACKEND=$SHLVL:blesh-${BLE_VERSION-} elif [[ ${bash_preexec_imported-} ]]; then ATUIN_PREEXEC_BACKEND=$SHLVL:bash-preexec elif [[ ${__bp_imported-} ]]; then ATUIN_PREEXEC_BACKEND="$SHLVL:bash-preexec (old)" else ATUIN_PREEXEC_BACKEND=$SHLVL:unknown fi } __atuin_preexec() { # Workaround for old versions of bash-preexec if [[ ! ${BLE_ATTACHED-} ]]; then # In older versions of bash-preexec, the preexec hook may be called # even for the commands run by keybindings. There is no general and # robust way to detect the command for keybindings, but at least we # want to exclude Atuin's keybindings. When the preexec hook is called # for a keybinding, the preexec hook for the user command will not # fire, so we instead set a fake ATUIN_HISTORY_ID here to notify # __atuin_precmd of this failure. if [[ $BASH_COMMAND != "$1" ]]; then case $BASH_COMMAND in '__atuin_history'* | '__atuin_widget_run'* | '__atuin_bash42_dispatch'*) ATUIN_HISTORY_ID=__bash_preexec_failure__ return 0 ;; esac fi fi # Note: We update ATUIN_PREEXEC_BACKEND on every preexec because blesh's # attaching state can dynamically change. __atuin_update_preexec_backend local id id=$(atuin history start -- "$1" 2>/dev/null) export ATUIN_HISTORY_ID=$id __atuin_preexec_time=${EPOCHREALTIME-} } __atuin_precmd() { local EXIT=$? __atuin_precmd_time=${EPOCHREALTIME-} [[ ! $ATUIN_HISTORY_ID ]] && return # If the previous preexec hook failed, we manually call __atuin_preexec if [[ $ATUIN_HISTORY_ID == __bash_preexec_failure__ ]]; then # This is the command extraction code taken from bash-preexec local previous_command previous_command=$( export LC_ALL=C HISTTIMEFORMAT='' builtin history 1 | sed '1 s/^ *[0-9][0-9]*[* ] //' ) __atuin_preexec "$previous_command" fi local duration="" # shellcheck disable=SC2154,SC2309 if [[ ${BLE_ATTACHED-} && ${_ble_exec_time_ata-} ]]; then # With ble.sh, we utilize the shell variable `_ble_exec_time_ata` # recorded by ble.sh. It is more accurate than the measurements by # Atuin, which includes the spawn cost of Atuin. ble.sh uses the # special shell variable `EPOCHREALTIME` in bash >= 5.0 with the # microsecond resolution, or the builtin `time` in bash < 5.0 with the # millisecond resolution. duration=${_ble_exec_time_ata}000 elif ((BASH_VERSINFO[0] >= 5)); then # We calculate the high-resolution duration based on EPOCHREALTIME # (bash >= 5.0) recorded by precmd/preexec, though it might not be as # accurate as `_ble_exec_time_ata` provided by ble.sh because it # includes the extra time of the precmd/preexec handling. Since Bash # does not offer floating-point arithmetic, we remove the non-digit # characters and perform the integral arithmetic. The fraction part of # EPOCHREALTIME is fixed to have 6 digits in Bash. We remove all the # non-digit characters because the decimal point is not necessarily a # period depending on the locale. duration=$((${__atuin_precmd_time//[!0-9]} - ${__atuin_preexec_time//[!0-9]})) if ((duration >= 0)); then duration=${duration}000 else duration="" # clear the result on overflow fi fi (ATUIN_LOG=error atuin history end --exit "$EXIT" ${duration:+"--duration=$duration"} -- "$ATUIN_HISTORY_ID" &) >/dev/null 2>&1 export ATUIN_HISTORY_ID="" } __atuin_set_ret_value() { return ${1:+"$1"} } #------------------------------------------------------------------------------ # section: __atuin_accept_line # # The function "__atuin_accept_line" is kept for backward compatibility of the # direct use of __atuin_history in keybindings by users. # The shell function `__atuin_evaluate_prompt` evaluates prompt sequences in # $PS1. We switch the implementation of the shell function # `__atuin_evaluate_prompt` based on the Bash version because the expansion # ${PS1@P} is only available in bash >= 4.4. if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4)); then __atuin_evaluate_prompt() { __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}" __atuin_prompt=${PS1@P} # Note: Strip the control characters ^A (\001) and ^B (\002), which # Bash internally uses to enclose the escape sequences. They are # produced by '\[' and '\]', respectively, in $PS1 and used to tell # Bash that the strings inbetween do not contribute to the prompt # width. After the prompt width calculation, Bash strips those control # characters before outputting it to the terminal. We here strip these # characters following Bash's behavior. __atuin_prompt=${__atuin_prompt//[$'\001\002']} # Count the number of newlines contained in $__atuin_prompt __atuin_prompt_offset=${__atuin_prompt//[!$'\n']} __atuin_prompt_offset=${#__atuin_prompt_offset} } else __atuin_evaluate_prompt() { __atuin_prompt='$ ' __atuin_prompt_offset=0 } fi # The shell function `__atuin_clear_prompt N` outputs terminal control # sequences to clear the contents of the current and N previous lines. After # clearing, the cursor is placed at the beginning of the N-th previous line. __atuin_clear_prompt_cache=() __atuin_clear_prompt() { local offset=$1 if [[ ! ${__atuin_clear_prompt_cache[offset]+set} ]]; then if [[ ! ${__atuin_clear_prompt_cache[0]+set} ]]; then __atuin_clear_prompt_cache[0]=$'\r'$(tput el 2>/dev/null || tput ce 2>/dev/null) fi if ((offset > 0)); then __atuin_clear_prompt_cache[offset]=${__atuin_clear_prompt_cache[0]}$( tput cuu "$offset" 2>/dev/null || tput UP "$offset" 2>/dev/null tput dl "$offset" 2>/dev/null || tput DL "$offset" 2>/dev/null tput il "$offset" 2>/dev/null || tput AL "$offset" 2>/dev/null ) fi fi printf '%s' "${__atuin_clear_prompt_cache[offset]}" } __atuin_accept_line() { local __atuin_command=$1 # Reprint the prompt, accounting for multiple lines local __atuin_prompt __atuin_prompt_offset __atuin_evaluate_prompt __atuin_clear_prompt "$__atuin_prompt_offset" printf '%s\n' "$__atuin_prompt$__atuin_command" # Add it to the bash history history -s "$__atuin_command" # Assuming bash-preexec # Invoke every function in the preexec array local __atuin_preexec_function local __atuin_preexec_function_ret_value local __atuin_preexec_ret_value=0 for __atuin_preexec_function in "${preexec_functions[@]:-}"; do if type -t "$__atuin_preexec_function" 1>/dev/null; then __atuin_set_ret_value "${__bp_last_ret_value:-}" "$__atuin_preexec_function" "$__atuin_command" __atuin_preexec_function_ret_value=$? if [[ $__atuin_preexec_function_ret_value != 0 ]]; then __atuin_preexec_ret_value=$__atuin_preexec_function_ret_value fi fi done # If extdebug is turned on and any preexec function returns non-zero # exit status, we do not run the user command. if ! { shopt -q extdebug && ((__atuin_preexec_ret_value)); }; then # Note: When a child Bash session is started by enter_accept, if the # environment variable READLINE_POINT is present, bash-preexec in the # child session does not fire preexec at all because it considers we # are inside Atuin's keybinding of the current session. To avoid # propagating the environment variable to the child session, we remove # the export attribute of READLINE_LINE and READLINE_POINT. export -n READLINE_LINE READLINE_POINT # Juggle the terminal settings so that the command can be interacted # with local __atuin_stty_backup __atuin_stty_backup=$(stty -g) stty "$ATUIN_STTY" # Execute the command. Note: We need to record $? and $_ after the # user command within the same call of "eval" because $_ is otherwise # overwritten by the last argument of "eval". __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}" eval -- "$__atuin_command"$'\n__bp_last_ret_value=$? __bp_last_argument_prev_command=$_' stty "$__atuin_stty_backup" fi # Execute preprompt commands local __atuin_prompt_command for __atuin_prompt_command in "${PROMPT_COMMAND[@]}"; do __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}" eval -- "$__atuin_prompt_command" done # Bash will redraw only the line with the prompt after we finish, # so to work for a multiline prompt we need to print it ourselves, # then go to the beginning of the last line. __atuin_evaluate_prompt printf '%s' "$__atuin_prompt" __atuin_clear_prompt 0 } #------------------------------------------------------------------------------ # Check if tmux popup is available (tmux >= 3.2) __atuin_tmux_popup_check() { [[ -n "${TMUX-}" ]] || return 1 [[ "${ATUIN_TMUX_POPUP:-true}" != "false" ]] || return 1 # https://github.com/tmux/tmux/wiki/FAQ#how-often-is-tmux-released-what-is-the-version-number-scheme local tmux_version tmux_version=$(tmux -V 2>/dev/null | sed -n 's/^[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p') # Could have used grep... [[ -z "$tmux_version" ]] && return 1 local m1 m2 m1=${tmux_version%%.*} m2=${tmux_version#*.} m2=${m2%%.*} [[ "$m1" =~ ^[0-9]+$ ]] || return 1 [[ "$m2" =~ ^[0-9]+$ ]] || m2=0 (( m1 > 3 || (m1 == 3 && m2 >= 2) )) } # Use global variable to fix scope issues with traps __atuin_popup_tmpdir="" __atuin_tmux_popup_cleanup() { [[ -n "$__atuin_popup_tmpdir" && -d "$__atuin_popup_tmpdir" ]] && command rm -rf "$__atuin_popup_tmpdir" __atuin_popup_tmpdir="" } __atuin_search_cmd() { local -a search_args=("$@") if __atuin_tmux_popup_check; then __atuin_popup_tmpdir=$(mktemp -d) || return 1 local result_file="$__atuin_popup_tmpdir/result" trap '__atuin_tmux_popup_cleanup' EXIT HUP INT TERM local escaped_query escaped_args escaped_query=$(printf '%s' "$READLINE_LINE" | sed "s/'/'\\\\''/g") escaped_args="" for arg in "${search_args[@]}"; do escaped_args+=" '$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")'" done # In the popup, atuin goes to terminal, stderr goes to file local cdir popup_width popup_height cdir=$(pwd) popup_width="${ATUIN_TMUX_POPUP_WIDTH:-80%}" # Keep default value anyways popup_height="${ATUIN_TMUX_POPUP_HEIGHT:-60%}" tmux display-popup -d "$cdir" -w "$popup_width" -h "$popup_height" -E -E -- \ sh -c "PATH='$PATH' ATUIN_SESSION='$ATUIN_SESSION' ATUIN_SHELL=bash ATUIN_LOG=error ATUIN_QUERY='$escaped_query' atuin search $escaped_args -i 2>'$result_file'" if [[ -f "$result_file" ]]; then cat "$result_file" fi __atuin_tmux_popup_cleanup trap - EXIT HUP INT TERM else ATUIN_SHELL=bash ATUIN_LOG=error ATUIN_QUERY=$READLINE_LINE atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 fi } __atuin_history() { # Default action of the up key: When this function is called with the first # argument `--shell-up-key-binding`, we perform Atuin's history search only # when the up key is supposed to cause the history movement in the original # binding. We do this only for ble.sh because the up key always invokes # the history movement in the plain Bash. if [[ ${BLE_ATTACHED-} && ${1-} == --shell-up-key-binding ]]; then # When the current cursor position is not in the first line, the up key # should move the cursor to the previous line. While the selection is # performed, the up key should not start the history search. # shellcheck disable=SC2154 # Note: these variables are set by ble.sh if [[ ${_ble_edit_str::_ble_edit_ind} == *$'\n'* || $_ble_edit_mark_active ]]; then ble/widget/@nomarked backward-line local status=$? READLINE_LINE=$_ble_edit_str READLINE_POINT=$_ble_edit_ind READLINE_MARK=$_ble_edit_mark return "$status" fi fi # READLINE_LINE and READLINE_POINT are only supported by bash >= 4.0 or # ble.sh. When it is not supported, we clear them to suppress strange # behaviors. [[ ${BLE_ATTACHED-} ]] || ((BASH_VERSINFO[0] >= 4)) || READLINE_LINE="" READLINE_POINT=0 local __atuin_output __atuin_output=$(__atuin_search_cmd "$@") # We do nothing when the search is canceled. [[ $__atuin_output ]] || return 0 if [[ $__atuin_output == __atuin_accept__:* ]]; then __atuin_output=${__atuin_output#__atuin_accept__:} if [[ ${BLE_ATTACHED-} ]]; then ble-edit/content/reset-and-check-dirty "$__atuin_output" ble/widget/accept-line READLINE_LINE="" elif [[ ${__atuin_macro_chain_keymap-} ]]; then READLINE_LINE=$__atuin_output bind -m "$__atuin_macro_chain_keymap" '"'"$__atuin_macro_chain"'": '"$__atuin_macro_accept_line" else __atuin_accept_line "$__atuin_output" READLINE_LINE="" fi READLINE_POINT=${#READLINE_LINE} else READLINE_LINE=$__atuin_output READLINE_POINT=${#READLINE_LINE} if [[ ! ${BLE_ATTACHED-} ]] && ((BASH_VERSINFO[0] < 4)) && [[ ${__atuin_macro_chain_keymap-} ]]; then bind -m "$__atuin_macro_chain_keymap" '"'"$__atuin_macro_chain"'": '"$__atuin_macro_insert_line" fi fi } __atuin_initialize_blesh() { # shellcheck disable=SC2154 [[ ${BLE_VERSION-} ]] && ((_ble_version >= 400)) || return 0 ble-import contrib/integration/bash-preexec # Define and register an autosuggestion source for ble.sh's auto-complete. # If you'd like to overwrite this, define the same name of shell function # after the $(atuin init bash) line in your .bashrc. If you do not need # the auto-complete source by Atuin, please add the following code to # remove the entry after the $(atuin init bash) line in your .bashrc: # # ble/util/import/eval-after-load core-complete ' # ble/array#remove _ble_complete_auto_source atuin-history' # function ble/complete/auto-complete/source:atuin-history { local suggestion suggestion=$(ATUIN_QUERY="$_ble_edit_str" atuin search --cmd-only --limit 1 --search-mode prefix 2>/dev/null) [[ $suggestion == "$_ble_edit_str"?* ]] || return 1 ble/complete/auto-complete/enter h 0 "${suggestion:${#_ble_edit_str}}" '' "$suggestion" } ble/util/import/eval-after-load core-complete ' ble/array#unshift _ble_complete_auto_source atuin-history' # @env BLE_SESSION_ID: `atuin doctor` references the environment variable # BLE_SESSION_ID. We explicitly export the variable because it was not # exported in older versions of ble.sh. [[ ${BLE_SESSION_ID-} ]] && export BLE_SESSION_ID } __atuin_initialize_blesh BLE_ONLOAD+=(__atuin_initialize_blesh) precmd_functions+=(__atuin_precmd) preexec_functions+=(__atuin_preexec) #------------------------------------------------------------------------------ # section: atuin-bind __atuin_widget=() __atuin_widget_save() { local data=$1 for REPLY in "${!__atuin_widget[@]}"; do if [[ ${__atuin_widget[REPLY]} == "$data" ]]; then return 0 fi done # shellcheck disable=SC2154 REPLY=${#__atuin_widget[*]} __atuin_widget[REPLY]=$data } __atuin_widget_run() { local data=${__atuin_widget[$1]} local keymap=${data%%:*} widget=${data#*:} local __atuin_macro_chain_keymap=$keymap bind -m "$keymap" '"'"$__atuin_macro_chain"'": ""' builtin eval -- "$widget" } # To realize the enter_accept feature in a robust way, we need to call the # readline bindable function `accept-line'. However, there is no way to call # `accept-line' from the shell script. To call the bindable function # `accept-line', we may utilize string macros of readline. When we bind KEYSEQ # to a WIDGET that wants to conditionally call `accept-line' at the end, we # perform two-step dispatching: # # 1. [KEYSEQ -> IKEYSEQ1 IKEYSEQ2]---We first translate KEYSEQ to two # intermediate key sequences IKEYSEQ1 and IKEYSEQ2 using string macros. For # example, when we bind `__atuin_history` to \C-r, this step can be set up by # `bind '"\C-r": "IKEYSEQ1IKEYSEQ2"'`. # # 2. [IKEYSEQ1 -> WIDGET]---Then, IKEYSEQ1 is bound to the WIDGET, and the # binding of IKEYSEQ2 is dynamically determined by WIDGET. For example, when # we bind `__atuin_history` to \C-r, this step can be set up by `bind -x # '"IKEYSEQ1": WIDGET'`. # # 3. [IKEYSEQ2 -> accept-line] or [IKEYSEQ2 -> ""]---To request the execution # of `accept-line', WIDGET can change the binding of IKEYSEQ2 by running # `bind '"IKEYSEQ2": accept-line''. Otherwise, WIDGET can change the binding # of IKEYSEQ2 to no-op by running `bind '"IKEYSEQ2": ""'`. # # For the choice of the intermediate key sequences, we want to choose key # sequences that are unlikely to conflict with others. In addition, we want to # avoid a key sequence containing \e because keymap "vi-insert" stops # processing key sequences containing \e in older versions of Bash. We have # used \e[0;A (a variant of the [up] key with modifier ) in Atuin 3.10.0 # for intermediate key sequences, but this contains \e and caused a problem. # Instead, we use \C-x\C-_A\a, which starts with \C-x\C-_ (an unlikely # two-byte combination) and A (represents the initial letter of Atuin), # followed by the payload and the terminator \a (BEL, \C-g). __atuin_macro_chain='\C-x\C-_A0\a' for __atuin_keymap in emacs vi-insert vi-command; do bind -m "$__atuin_keymap" "\"$__atuin_macro_chain\": \"\"" done unset -v __atuin_keymap if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3)); then # In Bash >= 4.3 __atuin_macro_accept_line=accept-line __atuin_bind_impl() { local keymap=$1 keyseq=$2 command=$3 # Note: In Bash <= 5.0, the table for `bind -x` from the keyseq to the # command is shared by all the keymaps (emacs, vi-insert, and # vi-command), so one cannot safely bind different command strings to # the same keyseq in different keymaps. Therefore, the command string # and the keyseq need to be globally in one-to-one correspondence in # all the keymaps. local REPLY __atuin_widget_save "$keymap:$command" local widget=$REPLY local ikeyseq1='\C-x\C-_A'$((1 + widget))'\a' local ikeyseq2=$__atuin_macro_chain if ((BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] == 1)); then # Workaround for Bash 5.1: Bash 5.1 has a bug that overwriting an # existing "bind -x" keybinding breaks other existing "bind -x" # keybindings [1,2]. To work around the problem, we explicitly # unbind an existing keybinding before overwriting it. # # [1] https://lists.gnu.org/archive/html/bug-bash/2021-04/msg00135.html # [2] https://github.com/atuinsh/atuin/issues/962#issuecomment-3451132291 bind -m "$keymap" -r "$keyseq" fi bind -m "$keymap" "\"$keyseq\": \"$ikeyseq1$ikeyseq2\"" bind -m "$keymap" -x "\"$ikeyseq1\": __atuin_widget_run $widget" } __atuin_bind_blesh_onload() { # In ble.sh, we need to enable unrecognized CSI sequences like \e[0;0A, # which are discarded by ble.sh by default. Note: In Bash <= 4.2, we # do not need to unset "decode_error_cseq_discard" because \e[0;A is # used only for the macro chaining (which is unused by ble.sh) in Bash # <= 4.2. bleopt decode_error_cseq_discard= } if [[ ${BLE_VERSION-} ]]; then __atuin_bind_blesh_onload fi BLE_ONLOAD+=(__atuin_bind_blesh_onload) else # In Bash <= 4.2, "bind -x" cannot bind a shell command to a keyseq having # more than two bytes, so we need to work with only two-byte sequences. # # However, the number of available combinations of two-byte sequences is # limited. To minimize the number of key sequences used by Atuin, instead # of specifying a widget by its own intermediate sequence, we specify a # widget by a fixed-length sequence of multiple two-byte sequences. More # specifically, instead of IKEYSEQ1, we use IKS1 IKS2 IKS3 [IKS4 IKS5] # IKSX, where IKS1..IKS5 just stores its information to a global variable, # and IKSX collects all the information and determine and call the actual # widget based on the stored information. Each of IKn (n=1..5) is one of # the two reserved sequences, $__atuin_bash42_code0 and # $__atuin_bash42_code1. IKSX is fixed to be $__atuin_bash42_code2. # # For the choices of the special key sequences, we consider \C-xQ, \C-xR, # and \C-xS. In the emacs editing mode of Bash, \C-x is used as a prefix # key, i.e., it is used for the beginning key of the keybindings with # multiple keys, so \C-x is unlikely to be used for a single-key binding by # the user. Also, \C-x is not used in the vi editing mode by default. The # combinations \C-xQ..\C-xS are also unlikely be used because we need to # switch the modifier keys from Control to Shift to input these sequences, # and these are not easy to input. __atuin_bash42_code0='\C-xQ' __atuin_bash42_code1='\C-xR' __atuin_bash42_code2='\C-xS' __atuin_bash42_encode() { REPLY= local n=$1 min_width=${2-} while if ((n % 2 == 0)); then REPLY=$__atuin_bash42_code0$REPLY else REPLY=$__atuin_bash42_code1$REPLY fi (((n /= 2) || ${#REPLY} / ${#__atuin_bash42_code0} < min_width)) do :; done } __atuin_bash42_bind() { local __atuin_keymap for __atuin_keymap in emacs vi-insert vi-command; do bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code0"'": __atuin_bash42_dispatch_selector+=0' bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code1"'": __atuin_bash42_dispatch_selector+=1' bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code2"'": __atuin_bash42_dispatch' done } __atuin_bash42_bind # In Bash <= 4.2, there is no way to read users' "bind -x" settings, so we # need to explicitly perform "bind -x" when ble.sh is loaded. BLE_ONLOAD+=(__atuin_bash42_bind) if ((BASH_VERSINFO[0] >= 4)); then __atuin_macro_accept_line=accept-line else # Note: We rewrite the command line and invoke `accept-line'. In # bash <= 3.2, there is no way to rewrite the command line from the # shell script, so we rewrite it using a macro and # `shell-expand-line'. # # Note: Concerning the key sequences to invoke bindable functions # such as "\C-x\C-_A1\a", another option is to use # "\exbegginning-of-line\r", etc. to make it consistent with bash # >= 5.3. However, an older Bash configuration can still conflict # on [M-x]. The conflict is more likely than \C-x\C-_A1\a. for __atuin_keymap in emacs vi-insert vi-command; do bind -m "$__atuin_keymap" '"\C-x\C-_A1\a": beginning-of-line' bind -m "$__atuin_keymap" '"\C-x\C-_A2\a": kill-line' # shellcheck disable=SC2016 bind -m "$__atuin_keymap" '"\C-x\C-_A3\a": "$READLINE_LINE"' bind -m "$__atuin_keymap" '"\C-x\C-_A4\a": shell-expand-line' bind -m "$__atuin_keymap" '"\C-x\C-_A5\a": accept-line' bind -m "$__atuin_keymap" '"\C-x\C-_A6\a": end-of-line' done unset -v __atuin_keymap bind -m vi-command '"\C-x\C-_A7\a": vi-insertion-mode' bind -m vi-insert '"\C-x\C-_A7\a": vi-movement-mode' # "\C-x\C-_A10\a": Replace the command line with READLINE_LINE. When we are # in the vi-command keymap, we go to vi-insert, input # "$READLINE_LINE", and come back to vi-command. bind -m emacs '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A3\a\C-x\C-_A4\a"' bind -m vi-insert '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A3\a\C-x\C-_A4\a"' bind -m vi-command '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A7\a\C-x\C-_A3\a\C-x\C-_A7\a\C-x\C-_A4\a"' __atuin_macro_accept_line='"\C-x\C-_A10\a\C-x\C-_A5\a"' __atuin_macro_insert_line='"\C-x\C-_A10\a\C-x\C-_A6\a"' fi __atuin_bash42_dispatch_selector= __atuin_bash42_dispatch() { local s=$__atuin_bash42_dispatch_selector __atuin_bash42_dispatch_selector= __atuin_widget_run "$((2#0$s))" } __atuin_bind_impl() { local keymap=$1 keyseq=$2 command=$3 __atuin_widget_save "$keymap:$command" __atuin_bash42_encode "$REPLY" local macro=$REPLY$__atuin_bash42_code2$__atuin_macro_chain bind -m "$keymap" "\"$keyseq\": \"$macro\"" } fi atuin-bind() { local keymap= local OPTIND=1 OPTARG="" OPTERR=0 flag while getopts ':m:' flag "$@"; do case $flag in m) keymap=$OPTARG ;; *) printf '%s\n' "atuin-bind: unrecognized option '-$flag'" >&2 return 2 ;; esac done shift "$((OPTIND - 1))" if (($# != 2)); then printf '%s\n' 'usage: atuin-bind [-m keymap] keyseq widget' >&2 return 2 fi local keyseq=$1 [[ $keymap ]] || keymap=$(bind -v | awk '$2 == "keymap" { print $3 }') case $keymap in emacs-meta) keymap=emacs keyseq='\e'$keyseq ;; emacs-ctlx) keymap=emacs keyseq='\C-x'$keyseq ;; emacs*) keymap=emacs ;; vi-insert) ;; vi*) keymap=vi-command ;; *) printf '%s\n' "atuin-bind: unknown keymap '$keymap'" >&2 return 2 ;; esac local command=$2 widget=${2%%[[:blank:]]*} case $widget in atuin-search) command=${2/#"$widget"/__atuin_history} ;; atuin-search-emacs) command=${2/#"$widget"/__atuin_history --keymap-mode=emacs} ;; atuin-search-viins) command=${2/#"$widget"/__atuin_history --keymap-mode=vim-insert} ;; atuin-search-vicmd) command=${2/#"$widget"/__atuin_history --keymap-mode=vim-normal} ;; atuin-up-search) command=${2/#"$widget"/__atuin_history --shell-up-key-binding} ;; atuin-up-search-emacs) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=emacs} ;; atuin-up-search-viins) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=vim-insert} ;; atuin-up-search-vicmd) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=vim-normal} ;; esac __atuin_bind_impl "$keymap" "$keyseq" "$command" } #------------------------------------------------------------------------------ # shellcheck disable=SC2154 if [[ $__atuin_bind_ctrl_r == true ]]; then # Note: We do not overwrite [C-r] in the vi-command keymap because we do # not want to overwrite "redo", which is already bound to [C-r] in the # vi_nmap keymap in ble.sh. atuin-bind -m emacs '\C-r' atuin-search-emacs atuin-bind -m vi-insert '\C-r' atuin-search-viins atuin-bind -m vi-command '/' atuin-search-emacs fi # shellcheck disable=SC2154 if [[ $__atuin_bind_up_arrow == true ]]; then atuin-bind -m emacs '\e[A' atuin-up-search-emacs atuin-bind -m emacs '\eOA' atuin-up-search-emacs atuin-bind -m vi-insert '\e[A' atuin-up-search-viins atuin-bind -m vi-insert '\eOA' atuin-up-search-viins atuin-bind -m vi-command '\e[A' atuin-up-search-vicmd atuin-bind -m vi-command '\eOA' atuin-up-search-vicmd atuin-bind -m vi-command 'k' atuin-up-search-vicmd fi #------------------------------------------------------------------------------ fi # (include guard) end of main content ================================================ FILE: crates/atuin/src/shell/atuin.fish ================================================ if not set -q ATUIN_SESSION; or test "$ATUIN_SHLVL" != "$SHLVL" set -gx ATUIN_SESSION (atuin uuid) set -gx ATUIN_SHLVL $SHLVL end set --erase ATUIN_HISTORY_ID function _atuin_preexec --on-event fish_preexec if not test -n "$fish_private_mode" set -g ATUIN_HISTORY_ID (atuin history start -- "$argv[1]" 2>/dev/null) end end function _atuin_postexec --on-event fish_postexec set -l s $status if test -n "$ATUIN_HISTORY_ID" ATUIN_LOG=error atuin history end --exit $s -- $ATUIN_HISTORY_ID &>/dev/null & disown end set --erase ATUIN_HISTORY_ID end # Check if tmux popup is available (tmux >= 3.2) function _atuin_tmux_popup_check if not test -n "$TMUX" echo 0 return end if test "$ATUIN_TMUX_POPUP" = false echo 0 return end set -l tmux_version (tmux -V 2>/dev/null | string match -r '\d+\.\d+') if not test -n "$tmux_version" echo 0 return end set -l parts (string split '.' $tmux_version) set -l m1 $parts[1] set -l m2 0 if test (count $parts) -ge 2 set m2 $parts[2] end if not string match -rq '^[0-9]+$' -- "$m1" echo 0 return end if not string match -rq '^[0-9]+$' -- "$m2" set m2 0 end if test "$m1" -gt 3 2>/dev/null; or begin test "$m1" -eq 3 2>/dev/null; and test "$m2" -ge 2 2>/dev/null end echo 1 else echo 0 end end function _atuin_search set -l keymap_mode switch $fish_key_bindings case fish_vi_key_bindings switch $fish_bind_mode case default set keymap_mode vim-normal case insert set keymap_mode vim-insert end case '*' set keymap_mode emacs end set -l use_tmux_popup (_atuin_tmux_popup_check) set -l ATUIN_H if test "$use_tmux_popup" -eq 1 set -l tmpdir (mktemp -d) if not test -d "$tmpdir" # if mktemp got errors set ATUIN_H (ATUIN_SHELL=fish ATUIN_LOG=error ATUIN_QUERY=(commandline -b) atuin search --keymap-mode=$keymap_mode $argv -i 3>&1 1>&2 2>&3 | string collect) else set -l result_file "$tmpdir/result" set -l query (commandline -b | string replace -a "'" "'\\''") set -l escaped_args "" for arg in $argv set escaped_args "$escaped_args '"(string replace -a "'" "'\\''" -- $arg)"'" end # In the popup, atuin goes to terminal, stderr goes to file set -l cdir (pwd) # Keep default value anyways set -l popup_width (test -n "$ATUIN_TMUX_POPUP_WIDTH" && echo "$ATUIN_TMUX_POPUP_WIDTH" || echo "80%") set -l popup_height (test -n "$ATUIN_TMUX_POPUP_HEIGHT" && echo "$ATUIN_TMUX_POPUP_HEIGHT" || echo "60%") tmux display-popup -d "$cdir" -w "$popup_width" -h "$popup_height" -E -E -- \ sh -c "PATH='$PATH' ATUIN_SESSION='$ATUIN_SESSION' ATUIN_SHELL=fish ATUIN_LOG=error ATUIN_QUERY='$query' atuin search --keymap-mode=$keymap_mode$escaped_args -i 2>'$result_file'" if test -f "$result_file" set ATUIN_H (cat "$result_file" | string collect) end command rm -rf "$tmpdir" end else # In fish 3.4 and above we can use `"$(some command)"` to keep multiple lines separate; # but to support fish 3.3 we need to use `(some command | string collect)`. # https://fishshell.com/docs/current/relnotes.html#id24 (fish 3.4 "Notable improvements and fixes") set ATUIN_H (ATUIN_SHELL=fish ATUIN_LOG=error ATUIN_QUERY=(commandline -b) atuin search --keymap-mode=$keymap_mode $argv -i 3>&1 1>&2 2>&3 | string collect) end set ATUIN_H (string trim -- $ATUIN_H | string collect) # trim whitespace if test -n "$ATUIN_H" if string match --quiet '__atuin_accept__:*' "$ATUIN_H" set -l ATUIN_HIST (string replace "__atuin_accept__:" "" -- "$ATUIN_H" | string collect) commandline -r "$ATUIN_HIST" commandline -f repaint commandline -f execute return else commandline -r "$ATUIN_H" end end commandline -f repaint end function _atuin_bind_up # Fallback to fish's builtin up-or-search if we're in search or paging mode if commandline --search-mode; or commandline --paging-mode up-or-search return end # Only invoke atuin if we're on the top line of the command set -l lineno (commandline --line) switch $lineno case 1 _atuin_search --shell-up-key-binding case '*' up-or-search end end ================================================ FILE: crates/atuin/src/shell/atuin.nu ================================================ # Source this in your ~/.config/nushell/config.nu # minimum supported version = 0.93.0 module compat { export def --wrapped "random uuid -v 7" [...rest] { atuin uuid } } use (if not ( (version).major > 0 or (version).minor >= 103 ) { "compat" }) * if 'ATUIN_SESSION' not-in $env or ('ATUIN_SHLVL' not-in $env) or ($env.ATUIN_SHLVL != ($env.SHLVL? | default "")) { $env.ATUIN_SESSION = (random uuid -v 7 | str replace -a "-" "") $env.ATUIN_SHLVL = ($env.SHLVL? | default "") } hide-env -i ATUIN_HISTORY_ID # Magic token to make sure we don't record commands run by keybindings let ATUIN_KEYBINDING_TOKEN = $"# (random uuid)" let _atuin_pre_execution = {|| if ($nu | get history-enabled?) == false { return } let cmd = (commandline) if ($cmd | is-empty) { return } if not ($cmd | str starts-with $ATUIN_KEYBINDING_TOKEN) { $env.ATUIN_HISTORY_ID = (atuin history start -- $cmd e>| complete | get stdout | str trim) } } let _atuin_pre_prompt = {|| let last_exit = $env.LAST_EXIT_CODE if 'ATUIN_HISTORY_ID' not-in $env { return } with-env { ATUIN_LOG: error } { if (version).minor >= 104 or (version).major > 0 { job spawn { ^atuin history end $'--exit=($env.LAST_EXIT_CODE)' -- $env.ATUIN_HISTORY_ID | complete } | ignore } else { do { atuin history end $'--exit=($last_exit)' -- $env.ATUIN_HISTORY_ID } | complete } } hide-env ATUIN_HISTORY_ID } def _atuin_search_cmd [...flags: string] { if (version).minor >= 106 or (version).major > 0 { [ $ATUIN_KEYBINDING_TOKEN, ([ `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline), ATUIN_SHELL: nu } {`, ([ 'let output = (run-external atuin search', ($flags | append [--interactive] | each {|e| $'"($e)"'}), 'e>| str trim)', ] | flatten | str join ' '), 'if ($output | str starts-with "__atuin_accept__:") {', 'commandline edit --accept ($output | str replace "__atuin_accept__:" "")', '} else {', 'commandline edit $output', '}', `}`, ] | flatten | str join "\n"), ] } else { [ $ATUIN_KEYBINDING_TOKEN, ([ `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline) } {`, 'commandline edit', '(run-external atuin search', ($flags | append [--interactive] | each {|e| $'"($e)"'}), ' e>| str trim)', `}`, ] | flatten | str join ' '), ] } | str join "\n" } $env.config = ($env | default {} config).config $env.config = ($env.config | default {} hooks) $env.config = ( $env.config | upsert hooks ( $env.config.hooks | upsert pre_execution ( $env.config.hooks | get pre_execution? | default [] | append $_atuin_pre_execution) | upsert pre_prompt ( $env.config.hooks | get pre_prompt? | default [] | append $_atuin_pre_prompt) ) ) $env.config = ($env.config | default [] keybindings) ================================================ FILE: crates/atuin/src/shell/atuin.ps1 ================================================ # Atuin PowerShell module # # This should support PowerShell 5.1 (which is shipped with Windows) and later versions, on Windows and Linux. # # Usage: atuin init powershell | Out-String | Invoke-Expression # # Settings: # - $env:ATUIN_POWERSHELL_PROMPT_OFFSET - Number of lines to offset the prompt position after exiting search. # This is useful when using a multi-line prompt: e.g. set this to -1 when using a 2-line prompt. # It is initialized from the current prompt line count if not set when the first Atuin search is performed. if (Get-Module Atuin -ErrorAction Ignore) { if ($PSVersionTable.PSVersion.Major -ge 7) { Write-Warning "The Atuin module is already loaded, replacing it." Remove-Module Atuin } else { Write-Warning "The Atuin module is already loaded, skipping." return } } if (!(Get-Command atuin -ErrorAction Ignore)) { Write-Error "The 'atuin' executable needs to be available in the PATH." return } if (!(Get-Module PSReadLine -ErrorAction Ignore)) { Write-Error "Atuin requires the PSReadLine module to be installed." return } New-Module -Name Atuin -ScriptBlock { if (-not $env:ATUIN_SESSION -or $env:ATUIN_PID -ne $PID) { $env:ATUIN_SESSION = atuin uuid $env:ATUIN_PID = $PID } $script:atuinHistoryId = $null $script:previousPSConsoleHostReadLine = $Function:PSConsoleHostReadLine # The ReadLine overloads changed with breaking changes over time, make sure the one we expect is available. $script:hasExpectedReadLineOverload = ([Microsoft.PowerShell.PSConsoleReadLine]::ReadLine).OverloadDefinitions.Contains("static string ReadLine(runspace runspace, System.Management.Automation.EngineIntrinsics engineIntrinsics, System.Threading.CancellationToken cancellationToken, System.Nullable[bool] lastRunStatus)") function Get-CommandLine { $commandLine = "" [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$commandLine, [ref]$null) return $commandLine } function Set-CommandLine { param([string]$Text) $commandLine = Get-CommandLine [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $commandLine.Length, $Text) } # This function name is called by PSReadLine to read the next command line to execute. # We replace it with a custom implementation which adds Atuin support. function PSConsoleHostReadLine { ## 1. Collect the exit code of the previous command. # This needs to be done as the first thing because any script run will flush $?. $lastRunStatus = $? # Exit statuses are maintained separately for native and PowerShell commands, this needs to be taken into account. $lastNativeExitCode = $global:LASTEXITCODE $exitCode = if ($lastRunStatus) { 0 } elseif ($lastNativeExitCode) { $lastNativeExitCode } else { 1 } ## 2. Report the status of the previous command to Atuin (atuin history end). if ($script:atuinHistoryId) { try { # The duration is not recorded in old PowerShell versions, let Atuin handle it. $null arguments are ignored. $duration = (Get-History -Count 1).Duration.Ticks * 100 $durationArg = if ($duration) { "--duration=$duration" } else { $null } # Fire and forget the atuin history end command to avoid blocking the shell during a potential sync. $process = New-Object System.Diagnostics.Process $process.StartInfo.FileName = "atuin" $process.StartInfo.Arguments = "history end --exit=$exitCode $durationArg -- $script:atuinHistoryId" $process.StartInfo.UseShellExecute = $false $process.StartInfo.CreateNoWindow = $true $process.StartInfo.RedirectStandardInput = $true $process.StartInfo.RedirectStandardOutput = $true $process.StartInfo.RedirectStandardError = $true $process.Start() | Out-Null $process.StandardInput.Close() $process.BeginOutputReadLine() $process.BeginErrorReadLine() } catch { # Ignore errors to avoid breaking the shell. # An error would occur if the user removes atuin from the PATH, for instance. } finally { $script:atuinHistoryId = $null } } ## 3. Read the next command line to execute. # PSConsoleHostReadLine implementation from PSReadLine, adjusted to support old versions. Microsoft.PowerShell.Core\Set-StrictMode -Off $line = if ($script:hasExpectedReadLineOverload) { # When the overload we expect is available, we can pass $lastRunStatus to it. [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($Host.Runspace, $ExecutionContext, [System.Threading.CancellationToken]::None, $lastRunStatus) } else { # Either PSReadLine is older than v2.2.0-beta3, or maybe newer than we expect, so use the function from PSReadLine as-is. & $script:previousPSConsoleHostReadLine } ## 4. Report the next command line to Atuin (atuin history start). # PowerShell doesn't handle double quotes in native command line arguments the same way depending on its version, # and the value of $PSNativeCommandArgumentPassing - see the about_Parsing help page which explains the breaking changes. # This makes it unreliable, so we go through an environment variable, which should always be consistent across versions. try { $env:ATUIN_COMMAND_LINE = $line $script:atuinHistoryId = atuin history start --command-from-env } catch { # Ignore errors to avoid breaking the shell, see above. } finally { $env:ATUIN_COMMAND_LINE = $null } $global:LASTEXITCODE = $lastNativeExitCode return $line } function Invoke-AtuinSearch { param([string]$ExtraArgs = "") $previousOutputEncoding = [System.Console]::OutputEncoding $resultFile = New-TemporaryFile $suggestion = "" $errorOutput = "" try { [System.Console]::OutputEncoding = [System.Text.Encoding]::UTF8 # Start-Process does some crazy stuff, just use the Process class directly to have more control. $process = New-Object System.Diagnostics.Process $process.StartInfo.FileName = "atuin" $process.StartInfo.Arguments = "search -i --result-file ""$resultFile"" $ExtraArgs" $process.StartInfo.UseShellExecute = $false $process.StartInfo.RedirectStandardError = $true $process.StartInfo.StandardErrorEncoding = [System.Text.Encoding]::UTF8 $process.StartInfo.EnvironmentVariables["ATUIN_SHELL"] = "powershell" $process.StartInfo.EnvironmentVariables["ATUIN_QUERY"] = Get-CommandLine try { $process.Start() | Out-Null # A single stream is redirected, so we can read it synchronously, but we have to start reading it # before waiting for the process to exit, otherwise the buffer could fill up and cause a deadlock. $errorOutput = $process.StandardError.ReadToEnd().Trim() $process.WaitForExit() $suggestion = (Get-Content -Raw $resultFile -Encoding UTF8 | Out-String).Trim() } catch { $errorOutput = $_ } if ($errorOutput) { Write-Host -ForegroundColor Red "Atuin error:" Write-Host -ForegroundColor DarkRed $errorOutput } # If no shell prompt offset is set, initialize it from the current prompt line count. if ($null -eq $env:ATUIN_POWERSHELL_PROMPT_OFFSET) { try { $promptLines = (& $Function:prompt | Out-String | Measure-Object -Line).Lines $env:ATUIN_POWERSHELL_PROMPT_OFFSET = -1 * ($promptLines - 1) } catch { $env:ATUIN_POWERSHELL_PROMPT_OFFSET = 0 } } # PSReadLine maintains its own cursor position, which will no longer be valid if Atuin scrolls the display in inline mode. # Fortunately, InvokePrompt can receive a new Y position and reset the internal state. $y = $Host.UI.RawUI.CursorPosition.Y + [int]$env:ATUIN_POWERSHELL_PROMPT_OFFSET $y = [System.Math]::Max([System.Math]::Min($y, [System.Console]::BufferHeight - 1), 0) [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt($null, $y) if ($suggestion -eq "") { # The previous input was already rendered by InvokePrompt return } $acceptPrefix = "__atuin_accept__:" if ( $suggestion.StartsWith($acceptPrefix)) { Set-CommandLine $suggestion.Substring($acceptPrefix.Length) [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() } else { Set-CommandLine $suggestion } } finally { [System.Console]::OutputEncoding = $previousOutputEncoding Remove-Item $resultFile } } function Enable-AtuinSearchKeys { param([bool]$CtrlR = $true, [bool]$UpArrow = $true) if ($CtrlR) { Set-PSReadLineKeyHandler -Chord "Ctrl+r" -BriefDescription "Runs Atuin search" -ScriptBlock { Invoke-AtuinSearch } } if ($UpArrow) { Set-PSReadLineKeyHandler -Chord "UpArrow" -BriefDescription "Runs Atuin search" -ScriptBlock { $line = Get-CommandLine if (!$line.Contains("`n")) { Invoke-AtuinSearch -ExtraArgs "--shell-up-key-binding" } else { [Microsoft.PowerShell.PSConsoleReadLine]::PreviousLine() } } } } $ExecutionContext.SessionState.Module.OnRemove += { $env:ATUIN_SESSION = $null $Function:PSConsoleHostReadLine = $script:previousPSConsoleHostReadLine } Export-ModuleMember -Function @("Enable-AtuinSearchKeys", "PSConsoleHostReadLine") } | Import-Module -Global ================================================ FILE: crates/atuin/src/shell/atuin.xsh ================================================ import subprocess from prompt_toolkit.application.current import get_app from prompt_toolkit.filters import Condition from prompt_toolkit.keys import Keys if "ATUIN_SESSION" not in ${...} or ${...}.get("ATUIN_SHLVL", "") != ${...}.get("SHLVL", ""): $ATUIN_SESSION=$(atuin uuid).rstrip('\n') $ATUIN_SHLVL = ${...}.get("SHLVL", "") @events.on_precommand def _atuin_precommand(cmd: str): cmd = cmd.rstrip("\n") try: $ATUIN_HISTORY_ID = $(atuin history start -- @(cmd) 2>/dev/null).rstrip("\n") except: $ATUIN_HISTORY_ID = "" @events.on_postcommand def _atuin_postcommand(cmd: str, rtn: int, out, ts): if "ATUIN_HISTORY_ID" not in ${...}: return duration = ts[1] - ts[0] # Duration is float representing seconds, but atuin expects integer of nanoseconds nanos = round(duration * 10 ** 9) with ${...}.swap(ATUIN_LOG="error"): # This causes the entire .xonshrc to be re-executed, which is incredibly slow # This happens when using a subshell and using output redirection at the same time # For more details, see https://github.com/xonsh/xonsh/issues/5224 # (atuin history end --exit @(rtn) -- $ATUIN_HISTORY_ID &) > /dev/null 2>&1 atuin history end --exit @(rtn) --duration @(nanos) -- $ATUIN_HISTORY_ID > /dev/null 2>&1 del $ATUIN_HISTORY_ID def _search(event, extra_args: list[str]): buffer = event.current_buffer cmd = ["atuin", "search", "--interactive", *extra_args] # We need to explicitly pass in xonsh env, in case user has set XDG_HOME or something else that matters env = ${...}.detype() env["ATUIN_SHELL"] = "xonsh" env["ATUIN_QUERY"] = buffer.text p = subprocess.run(cmd, stderr=subprocess.PIPE, encoding="utf-8", env=env) result = p.stderr.rstrip("\n") # redraw prompt - necessary if atuin is configured to run inline, rather than fullscreen event.cli.renderer.erase() if not result: return buffer.reset() if result.startswith("__atuin_accept__:"): buffer.insert_text(result[17:]) buffer.validate_and_handle() else: buffer.insert_text(result) @events.on_ptk_create def _custom_keybindings(bindings, **kw): if _ATUIN_BIND_CTRL_R: @bindings.add(Keys.ControlR) def r_search(event): _search(event, extra_args=[]) if _ATUIN_BIND_UP_ARROW: @Condition def should_search(): buffer = get_app().current_buffer # disable keybind when there is an active completion, so # that up arrow can be used to navigate completion menu if buffer.complete_state is not None: return False # similarly, disable when buffer text contains multiple lines if '\n' in buffer.text: return False return True @bindings.add(Keys.Up, filter=should_search) def up_search(event): _search(event, extra_args=["--shell-up-key-binding"]) ================================================ FILE: crates/atuin/src/shell/atuin.zsh ================================================ # shellcheck disable=SC2034,SC2153,SC2086,SC2155 # Above line is because shellcheck doesn't support zsh, per # https://github.com/koalaman/shellcheck/wiki/SC1071, and the ignore: param in # ludeeus/action-shellcheck only supports _directories_, not _files_. So # instead, we manually add any error the shellcheck step finds in the file to # the above line ... # Source this in your ~/.zshrc autoload -U add-zsh-hook zmodload zsh/datetime 2>/dev/null # If zsh-autosuggestions is installed, configure it to use Atuin's search. If # you'd like to override this, then add your config after the $(atuin init zsh) # in your .zshrc _zsh_autosuggest_strategy_atuin() { # silence errors, since we don't want to spam the terminal prompt while typing. suggestion=$(ATUIN_QUERY="$1" atuin search --cmd-only --limit 1 --search-mode prefix 2>/dev/null) } if [ -n "${ZSH_AUTOSUGGEST_STRATEGY:-}" ]; then ZSH_AUTOSUGGEST_STRATEGY=("atuin" "${ZSH_AUTOSUGGEST_STRATEGY[@]}") else ZSH_AUTOSUGGEST_STRATEGY=("atuin") fi if [[ -z "${ATUIN_SESSION:-}" || "${ATUIN_SHLVL:-}" != "$SHLVL" ]]; then export ATUIN_SESSION=$(atuin uuid) export ATUIN_SHLVL=$SHLVL fi ATUIN_HISTORY_ID="" _atuin_preexec() { local id id=$(atuin history start -- "$1" 2>/dev/null) export ATUIN_HISTORY_ID="$id" __atuin_preexec_time=${EPOCHREALTIME-} } _atuin_precmd() { local EXIT="$?" __atuin_precmd_time=${EPOCHREALTIME-} [[ -z "${ATUIN_HISTORY_ID:-}" ]] && return local duration="" if [[ -n $__atuin_preexec_time && -n $__atuin_precmd_time ]]; then printf -v duration %.0f $(((__atuin_precmd_time - __atuin_preexec_time) * 1000000000)) fi (ATUIN_LOG=error atuin history end --exit $EXIT ${duration:+--duration=$duration} -- $ATUIN_HISTORY_ID &) >/dev/null 2>&1 export ATUIN_HISTORY_ID="" } # Check if tmux popup is available (tmux >= 3.2) __atuin_tmux_popup_check() { [[ -n "${TMUX-}" ]] || return 1 [[ "${ATUIN_TMUX_POPUP:-true}" != "false" ]] || return 1 # https://github.com/tmux/tmux/wiki/FAQ#how-often-is-tmux-released-what-is-the-version-number-scheme local tmux_version tmux_version=$(tmux -V 2>/dev/null | sed -n 's/^[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p') # Could have used grep... [[ -z "$tmux_version" ]] && return 1 local m1 m2 m1=${tmux_version%%.*} m2=${tmux_version#*.} m2=${m2%%.*} [[ "$m1" =~ ^[0-9]+$ ]] || return 1 [[ "$m2" =~ ^[0-9]+$ ]] || m2=0 (( m1 > 3 || (m1 == 3 && m2 >= 2) )) } # Use global variable to fix scope issues with traps __atuin_popup_tmpdir="" __atuin_tmux_popup_cleanup() { [[ -n "$__atuin_popup_tmpdir" && -d "$__atuin_popup_tmpdir" ]] && command rm -rf "$__atuin_popup_tmpdir" __atuin_popup_tmpdir="" } __atuin_search_cmd() { local -a search_args=("$@") if __atuin_tmux_popup_check; then __atuin_popup_tmpdir=$(mktemp -d) || return 1 local result_file="$__atuin_popup_tmpdir/result" trap '__atuin_tmux_popup_cleanup' EXIT HUP INT TERM local escaped_query escaped_args escaped_query=$(printf '%s' "$BUFFER" | sed "s/'/'\\\\''/g") escaped_args="" for arg in "${search_args[@]}"; do escaped_args+=" '$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")'" done # In the popup, atuin goes to terminal, stderr goes to file local cdir popup_width popup_height cdir=$(pwd) popup_width="${ATUIN_TMUX_POPUP_WIDTH:-80%}" # Keep default value anyways popup_height="${ATUIN_TMUX_POPUP_HEIGHT:-60%}" tmux display-popup -d "$cdir" -w "$popup_width" -h "$popup_height" -E -E -- \ sh -c "PATH='$PATH' ATUIN_SESSION='$ATUIN_SESSION' ATUIN_SHELL=zsh ATUIN_LOG=error ATUIN_QUERY='$escaped_query' atuin search $escaped_args -i 2>'$result_file'" if [[ -f "$result_file" ]]; then cat "$result_file" fi __atuin_tmux_popup_cleanup trap - EXIT HUP INT TERM else ATUIN_SHELL=zsh ATUIN_LOG=error ATUIN_QUERY=$BUFFER atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 fi } _atuin_search() { emulate -L zsh zle -I # swap stderr and stdout, so that the tui stuff works # TODO: not this local output # shellcheck disable=SC2048 output=$(__atuin_search_cmd $*) zle reset-prompt # re-enable bracketed paste # shellcheck disable=SC2154 echo -n ${zle_bracketed_paste[1]} >/dev/tty if [[ -n $output ]]; then RBUFFER="" LBUFFER=$output if [[ $LBUFFER == __atuin_accept__:* ]] then LBUFFER=${LBUFFER#__atuin_accept__:} zle accept-line fi fi } _atuin_search_vicmd() { _atuin_search --keymap-mode=vim-normal } _atuin_search_viins() { _atuin_search --keymap-mode=vim-insert } _atuin_up_search() { # Only trigger if the buffer is a single line if [[ ! $BUFFER == *$'\n'* ]]; then _atuin_search --shell-up-key-binding "$@" else zle up-line fi } _atuin_up_search_vicmd() { _atuin_up_search --keymap-mode=vim-normal } _atuin_up_search_viins() { _atuin_up_search --keymap-mode=vim-insert } add-zsh-hook preexec _atuin_preexec add-zsh-hook precmd _atuin_precmd zle -N atuin-search _atuin_search zle -N atuin-search-vicmd _atuin_search_vicmd zle -N atuin-search-viins _atuin_search_viins zle -N atuin-up-search _atuin_up_search zle -N atuin-up-search-vicmd _atuin_up_search_vicmd zle -N atuin-up-search-viins _atuin_up_search_viins # These are compatibility widget names for "atuin <= 17.2.1" users. zle -N _atuin_search_widget _atuin_search zle -N _atuin_up_search_widget _atuin_up_search ================================================ FILE: crates/atuin/src/sync.rs ================================================ use atuin_dotfiles::store::{AliasStore, var::VarStore}; use atuin_scripts::store::ScriptStore; use eyre::{Context, Result}; use atuin_client::{ database::Database, history::store::HistoryStore, record::sqlite_store::SqliteStore, settings::Settings, }; use atuin_common::record::RecordId; use atuin_kv::store::KvStore; // This is the only crate that ties together all other crates. // Therefore, it's the only crate where functions tying together all stores can live /// Rebuild all stores after a sync /// Note: for history, this only does an _incremental_ sync. Hence the need to specify downloaded /// records. pub async fn build( settings: &Settings, store: &SqliteStore, db: &dyn Database, downloaded: Option<&[RecordId]>, ) -> Result<()> { let encryption_key: [u8; 32] = atuin_client::encryption::load_key(settings) .context("could not load encryption key")? .into(); let host_id = Settings::host_id().await?; let downloaded = downloaded.unwrap_or(&[]); let kv_db = atuin_kv::database::Database::new(settings.kv.db_path.clone(), 1.0).await?; let history_store = HistoryStore::new(store.clone(), host_id, encryption_key); let alias_store = AliasStore::new(store.clone(), host_id, encryption_key); let var_store = VarStore::new(store.clone(), host_id, encryption_key); let kv_store = KvStore::new(store.clone(), kv_db, host_id, encryption_key); let script_store = ScriptStore::new(store.clone(), host_id, encryption_key); history_store.incremental_build(db, downloaded).await?; alias_store.build().await?; var_store.build().await?; kv_store.build().await?; let script_db = atuin_scripts::database::Database::new(settings.scripts.db_path.clone(), 1.0).await?; script_store.build(script_db).await?; Ok(()) } ================================================ FILE: crates/atuin/tests/common/mod.rs ================================================ use std::{env, time::Duration}; use atuin_client::api_client; use atuin_common::utils::uuid_v7; use atuin_server::{Settings as ServerSettings, launch_with_tcp_listener}; use atuin_server_database::DbSettings; use atuin_server_postgres::Postgres; use futures_util::TryFutureExt; use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle}; use tracing::{Dispatch, dispatcher}; use tracing_subscriber::{EnvFilter, layer::SubscriberExt}; pub async fn start_server(path: &str) -> (String, oneshot::Sender<()>, JoinHandle<()>) { let formatting_layer = tracing_tree::HierarchicalLayer::default() .with_writer(tracing_subscriber::fmt::TestWriter::new()) .with_indent_lines(true) .with_ansi(true) .with_targets(true) .with_indent_amount(2); let dispatch: Dispatch = tracing_subscriber::registry() .with(formatting_layer) .with(EnvFilter::new("atuin_server=debug,atuin_client=debug,info")) .into(); let db_uri = env::var("ATUIN_DB_URI") .unwrap_or_else(|_| "postgres://atuin:pass@localhost:5432/atuin".to_owned()); let server_settings = ServerSettings { host: "127.0.0.1".to_owned(), port: 0, path: path.to_owned(), sync_v1_enabled: true, open_registration: true, max_history_length: 8192, max_record_size: 1024 * 1024 * 1024, page_size: 1100, register_webhook_url: None, register_webhook_username: String::new(), db_settings: DbSettings { db_uri: db_uri, read_db_uri: None, }, metrics: atuin_server::settings::Metrics::default(), fake_version: None, }; let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { let _tracing_guard = dispatcher::set_default(&dispatch); if let Err(e) = launch_with_tcp_listener::( server_settings, listener, shutdown_rx.unwrap_or_else(|_| ()), ) .await { tracing::error!(error=?e, "server error"); panic!("error running server: {e:?}"); } }); // let the server come online tokio::time::sleep(Duration::from_millis(200)).await; (format!("http://{addr}{path}"), shutdown_tx, server) } pub async fn register_inner<'a>( address: &'a str, username: &str, password: &str, ) -> api_client::Client<'a> { let email = format!("{}@example.com", uuid_v7().as_simple()); // registration works let registration_response = api_client::register(address, username, &email, password) .await .unwrap(); api_client::Client::new( address, api_client::AuthToken::Token(registration_response.session), 5, 30, ) .unwrap() } #[allow(dead_code)] pub async fn login(address: &str, username: String, password: String) -> api_client::Client<'_> { // registration works let login_response = api_client::login( address, atuin_common::api::LoginRequest { username, password }, ) .await .unwrap(); api_client::Client::new( address, api_client::AuthToken::Token(login_response.session), 5, 30, ) .unwrap() } #[allow(dead_code)] pub async fn register(address: &str) -> api_client::Client<'_> { let username = uuid_v7().as_simple().to_string(); let password = uuid_v7().as_simple().to_string(); register_inner(address, &username, &password).await } ================================================ FILE: crates/atuin/tests/sync.rs ================================================ use atuin_common::{api::AddHistoryRequest, utils::uuid_v7}; use time::OffsetDateTime; mod common; #[tokio::test] async fn sync() { let path = format!("/{}", uuid_v7().as_simple()); let (address, shutdown, server) = common::start_server(&path).await; let client = common::register(&address).await; let hostname = uuid_v7().as_simple().to_string(); let now = OffsetDateTime::now_utc(); let data1 = uuid_v7().as_simple().to_string(); let data2 = uuid_v7().as_simple().to_string(); client .post_history(&[ AddHistoryRequest { id: uuid_v7().as_simple().to_string(), timestamp: now, data: data1.clone(), hostname: hostname.clone(), }, AddHistoryRequest { id: uuid_v7().as_simple().to_string(), timestamp: now, data: data2.clone(), hostname: hostname.clone(), }, ]) .await .unwrap(); let history = client .get_history(OffsetDateTime::UNIX_EPOCH, OffsetDateTime::UNIX_EPOCH, None) .await .unwrap(); assert_eq!(history.history, vec![data1, data2]); shutdown.send(()).unwrap(); server.await.unwrap(); } ================================================ FILE: crates/atuin/tests/users.rs ================================================ use atuin_common::utils::uuid_v7; mod common; #[tokio::test] async fn registration() { let path = format!("/{}", uuid_v7().as_simple()); let (address, shutdown, server) = common::start_server(&path).await; dbg!(&address); // -- REGISTRATION -- let username = uuid_v7().as_simple().to_string(); let password = uuid_v7().as_simple().to_string(); let client = common::register_inner(&address, &username, &password).await; // the session token works let status = client.status().await.unwrap(); assert_eq!(status.username, username); // -- LOGIN -- let client = common::login(&address, username.clone(), password).await; // the session token works let status = client.status().await.unwrap(); assert_eq!(status.username, username); shutdown.send(()).unwrap(); server.await.unwrap(); } #[tokio::test] async fn change_password() { let path = format!("/{}", uuid_v7().as_simple()); let (address, shutdown, server) = common::start_server(&path).await; // -- REGISTRATION -- let username = uuid_v7().as_simple().to_string(); let password = uuid_v7().as_simple().to_string(); let client = common::register_inner(&address, &username, &password).await; // the session token works let status = client.status().await.unwrap(); assert_eq!(status.username, username); // -- PASSWORD CHANGE -- let current_password = password; let new_password = uuid_v7().as_simple().to_string(); let result = client .change_password(current_password, new_password.clone()) .await; // the password change request succeeded assert!(result.is_ok()); // -- LOGIN -- let client = common::login(&address, username.clone(), new_password).await; // login with new password yields a working token let status = client.status().await.unwrap(); assert_eq!(status.username, username); shutdown.send(()).unwrap(); server.await.unwrap(); } #[tokio::test] async fn multi_user_test() { let path = format!("/{}", uuid_v7().as_simple()); let (address, shutdown, server) = common::start_server(&path).await; dbg!(&address); // -- REGISTRATION -- let user_one = uuid_v7().as_simple().to_string(); let password_one = uuid_v7().as_simple().to_string(); let client_one = common::register_inner(&address, &user_one, &password_one).await; // the session token works let status = client_one.status().await.unwrap(); assert_eq!(status.username, user_one); let user_two = uuid_v7().as_simple().to_string(); let password_two = uuid_v7().as_simple().to_string(); let client_two = common::register_inner(&address, &user_two, &password_two).await; // the session token works let status = client_two.status().await.unwrap(); assert_eq!(status.username, user_two); // check that we can change user one's password, and _this does not affect user two_ let current_password = password_one; let new_password = uuid_v7().as_simple().to_string(); let result = client_one .change_password(current_password, new_password.clone()) .await; // the password change request succeeded assert!(result.is_ok()); // -- LOGIN -- let client_one = common::login(&address, user_one.clone(), new_password).await; let client_two = common::login(&address, user_two.clone(), password_two).await; // login with new password yields a working token let status = client_one.status().await.unwrap(); assert_eq!(status.username, user_one); assert_ne!(status.username, user_two); let status = client_two.status().await.unwrap(); assert_eq!(status.username, user_two); shutdown.send(()).unwrap(); server.await.unwrap(); } ================================================ FILE: crates/atuin-ai/Cargo.toml ================================================ [package] name = "atuin-ai" edition = "2024" description = "AI integration for Atuin CLI" rust-version = { workspace = true } version = { workspace = true } authors = { workspace = true } license = { workspace = true } homepage = { workspace = true } repository = { workspace = true } # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] atuin-client = { workspace = true } atuin-common = { workspace = true } tokio = { workspace = true } eyre = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = [ "ansi", "fmt", "registry", "env-filter", ] } directories = { workspace = true } tracing-appender = "0.2.4" reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } crossterm = { workspace = true, features = ["use-dev-tty", "event-stream"] } ratatui = { workspace = true, features = ["unstable-rendered-line-info"] } futures = "0.3" eventsource-stream = "0.2" pulldown-cmark = "0.13.0" async-stream = "0.3" uuid = { workspace = true } tui-textarea-2 = "0.9.1" unicode-width = "0.2" [dev-dependencies] pretty_assertions = { workspace = true } ================================================ FILE: crates/atuin-ai/render-tests.sh ================================================ #!/bin/bash # Render all test cases from test-renders.json # Usage: ./render-tests.sh [test_name] # With no args: renders all tests # With arg: renders only matching test (e.g., ./render-tests.sh 05) set -e cd "$(dirname "$0")" JSON_FILE="test-renders.json" FILTER="${1:-}" # Build once cargo build -p atuin-ai --quiet # Count tests TOTAL=$(jq length "$JSON_FILE") for i in $(seq 0 $((TOTAL - 1))); do NAME=$(jq -r ".[$i].name" "$JSON_FILE") DESC=$(jq -r ".[$i].description" "$JSON_FILE") STATE=$(jq -c ".[$i].state" "$JSON_FILE") # Skip if filter provided and doesn't match if [[ -n "$FILTER" && ! "$NAME" =~ $FILTER ]]; then continue fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "[$NAME] $DESC" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "$STATE" | cargo run -p atuin-ai --quiet -- debug-render -f plain echo "" done ================================================ FILE: crates/atuin-ai/replay-states.sh ================================================ #!/bin/bash # Replay state snapshots from a debug state JSONL file # Usage: ./replay-states.sh [entry-number] # With no entry: renders all frames in sequence (press Enter to advance) # With entry number: renders just that frame set -e # cd "$(dirname "$0")" STATE_FILE="${1:-}" ENTRY_FILTER="${2:-}" if [[ -z "$STATE_FILE" ]]; then echo "Usage: $0 [entry-number]" echo "" echo "Examples:" echo " $0 /tmp/state.jsonl # Interactive replay of all frames" echo " $0 /tmp/state.jsonl 15 # Show just entry 15" exit 1 fi if [[ ! -f "$STATE_FILE" ]]; then echo "Error: File not found: $STATE_FILE" exit 1 fi # Build once cargo build -p atuin --quiet # Count entries TOTAL=$(wc -l < "$STATE_FILE" | tr -d ' ') if [[ -n "$ENTRY_FILTER" ]]; then # Show single entry LINE=$(sed -n "${ENTRY_FILTER}p" "$STATE_FILE") if [[ -z "$LINE" ]]; then echo "Error: Entry $ENTRY_FILTER not found (file has $TOTAL entries)" exit 1 fi ENTRY=$(echo "$LINE" | jq -r '.entry') LABEL=$(echo "$LINE" | jq -r '.label') STATE=$(echo "$LINE" | jq -c '.state') echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "[$ENTRY/$TOTAL] $LABEL" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "$STATE" | cargo run -p atuin --quiet -- ai debug-render -f ansi else # Interactive replay echo "Replaying $TOTAL frames from $STATE_FILE" echo "Press Enter to advance, 'q' to quit, or number+Enter to jump" echo "" CURRENT=1 while [[ $CURRENT -le $TOTAL ]]; do LINE=$(sed -n "${CURRENT}p" "$STATE_FILE") ENTRY=$(echo "$LINE" | jq -r '.entry') LABEL=$(echo "$LINE" | jq -r '.label') STATE=$(echo "$LINE" | jq -c '.state') clear echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "[$CURRENT/$TOTAL] $LABEL" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "$STATE" | cargo run -p atuin --quiet -- ai debug-render -f ansi echo "" echo "[Enter: next] [p: prev] [number: jump] [s: show state JSON] [q: quit]" read -r INPUT case "$INPUT" in q|Q) break ;; p|P) if [[ $CURRENT -gt 1 ]]; then CURRENT=$((CURRENT - 1)) fi ;; s|S) echo "" echo "State JSON:" echo "$STATE" | jq . echo "" echo "Press Enter to continue..." read -r ;; ''|' ') CURRENT=$((CURRENT + 1)) ;; *[0-9]*) if [[ "$INPUT" =~ ^[0-9]+$ ]] && [[ "$INPUT" -ge 1 ]] && [[ "$INPUT" -le $TOTAL ]]; then CURRENT=$INPUT else echo "Invalid entry number (1-$TOTAL)" sleep 1 fi ;; esac done fi ================================================ FILE: crates/atuin-ai/src/commands/debug_render.rs ================================================ //! Debug render command for TUI development //! //! Takes JSON state as input and outputs a single rendered frame as text. //! Useful for debugging view model derivation and rendering without running the full TUI. use eyre::{Context, Result}; use ratatui::{Terminal, backend::TestBackend}; use serde::Deserialize; use std::io::{self, Read}; use std::time::Instant; use crate::tui::{ render::{RenderContext, render}, state::{AppMode, AppState, ConversationEvent, StreamingStatus}, view_model::Blocks, }; /// JSON input format for debug rendering #[derive(Debug, Deserialize)] pub struct DebugInput { /// Conversation events in API format pub events: Vec, /// Current mode: "Input", "Generating", "Streaming", "Review", "Error" #[serde(default = "default_mode")] pub mode: String, /// Text being streamed (for Streaming mode) #[serde(default)] pub streaming_text: String, /// Current input buffer #[serde(default)] pub input: String, /// Cursor position #[serde(default)] pub cursor_pos: usize, /// Spinner frame (0-3) #[serde(default)] pub spinner_frame: usize, /// Error message #[serde(default)] pub error: Option, /// Session ID from server #[serde(default)] pub session_id: Option, /// Streaming status #[serde(default)] pub streaming_status: Option, /// Whether current turn was interrupted #[serde(default)] pub was_interrupted: bool, /// Terminal width for rendering #[serde(default = "default_width")] pub width: u16, /// Terminal height for rendering #[serde(default = "default_height")] pub height: u16, } fn default_mode() -> String { "Review".to_string() } fn default_width() -> u16 { 80 } fn default_height() -> u16 { // Default to a reasonable height; state files include calculated height 50 } /// Event input matching the API protocol format #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum EventInput { UserMessage { content: String, }, Text { content: String, }, ToolCall { id: String, name: String, input: serde_json::Value, }, ToolResult { tool_use_id: String, content: String, #[serde(default)] is_error: bool, }, } impl From for ConversationEvent { fn from(input: EventInput) -> Self { match input { EventInput::UserMessage { content } => ConversationEvent::UserMessage { content }, EventInput::Text { content } => ConversationEvent::Text { content }, EventInput::ToolCall { id, name, input } => { ConversationEvent::ToolCall { id, name, input } } EventInput::ToolResult { tool_use_id, content, is_error, } => ConversationEvent::ToolResult { tool_use_id, content, is_error, }, } } } impl DebugInput { /// Parse JSON from string pub fn from_json(json: &str) -> Result { serde_json::from_str(json).context("Failed to parse debug input JSON") } /// Convert to AppState pub fn to_state(&self) -> AppState { let mode = match self.mode.as_str() { "Input" => AppMode::Input, "Generating" => AppMode::Generating, "Streaming" => AppMode::Streaming, "Review" => AppMode::Review, "Error" => AppMode::Error, _ => AppMode::Review, }; let events: Vec = self.events.iter().cloned().map(Into::into).collect(); let streaming_status = self .streaming_status .as_ref() .map(|s| StreamingStatus::from_status_str(s)); // Create textarea from input and set cursor position let mut textarea = tui_textarea::TextArea::from(self.input.lines()); // Disable underline on cursor line textarea.set_cursor_line_style(ratatui::style::Style::default()); // Enable word wrapping textarea.set_wrap_mode(tui_textarea::WrapMode::Word); // Note: cursor_pos from old format is character-based; new format has row/col // For compatibility, just move to end if we have text if !self.input.is_empty() { textarea.move_cursor(tui_textarea::CursorMove::End); } AppState { mode, events, streaming_text: self.streaming_text.clone(), textarea, error: self.error.clone(), should_exit: false, exit_action: None, session_id: self.session_id.clone(), streaming_status, was_interrupted: self.was_interrupted, spinner_frame: self.spinner_frame, last_spinner_tick: Instant::now(), streaming_started: None, confirmation_pending: false, } } } /// Output format options #[derive(Debug, Clone, Copy, Default)] pub enum OutputFormat { /// Raw terminal output (ANSI) #[default] Ansi, /// Plain text (strips ANSI codes) Plain, /// JSON with blocks structure Json, } /// Run the debug render command pub async fn run(input_file: Option, format: OutputFormat) -> Result<()> { // Read input JSON let json = if let Some(path) = input_file { std::fs::read_to_string(&path).context(format!("Failed to read input file: {}", path))? } else { let mut buffer = String::new(); io::stdin() .read_to_string(&mut buffer) .context("Failed to read from stdin")?; buffer }; let debug_input = DebugInput::from_json(&json)?; let state = debug_input.to_state(); match format { OutputFormat::Json => { // Output the derived blocks as JSON let blocks = Blocks::from_state(&state); println!( "{}", serde_json::to_string_pretty(&blocks_to_json(&blocks))? ); } OutputFormat::Plain | OutputFormat::Ansi => { // Render to a test backend let backend = TestBackend::new(debug_input.width, debug_input.height); let mut terminal = Terminal::new(backend)?; // Load default theme let settings = atuin_client::settings::Settings::new()?; let mut theme_manager = atuin_client::theme::ThemeManager::new(None, None); let theme = theme_manager.load_theme(&settings.theme.name, None); let ctx = RenderContext { theme, anchor_col: 0, textarea: Some(&state.textarea), max_height: debug_input.height, popup_mode: false, render_above: false, }; terminal.draw(|frame| { render(frame, &state, &ctx); })?; // Get buffer content let buffer = terminal.backend().buffer(); let output = buffer_to_string(buffer, matches!(format, OutputFormat::Plain)); print!("{}", output); } } Ok(()) } /// Convert blocks to JSON for debugging fn blocks_to_json(blocks: &Blocks) -> serde_json::Value { serde_json::json!({ "count": blocks.items.len(), "blocks": blocks.items.iter().map(|block| { serde_json::json!({ "separator_above": block.separator_above, "title": block.title, "content": block.content.iter().map(content_to_json).collect::>() }) }).collect::>(), "status_bar": blocks.status_bar.as_ref().map(|sb| serde_json::json!({ "frame": sb.frame, "text": sb.text })) }) } fn content_to_json(content: &crate::tui::view_model::Content) -> serde_json::Value { use crate::tui::view_model::Content; match content { Content::Input { text, active, cursor_pos, } => serde_json::json!({ "type": "Input", "text": text, "active": active, "cursor_pos": cursor_pos }), Content::Command { text, faded } => serde_json::json!({ "type": "Command", "text": text, "faded": faded }), Content::Text { markdown } => serde_json::json!({ "type": "Text", "markdown": markdown }), Content::Error { message } => serde_json::json!({ "type": "Error", "message": message }), Content::Warning { kind, text, pending_confirm, } => serde_json::json!({ "type": "Warning", "kind": format!("{:?}", kind), "text": text, "pending_confirm": pending_confirm }), Content::Spinner { frame, status_text } => serde_json::json!({ "type": "Spinner", "frame": frame, "status_text": status_text }), Content::ToolStatus { completed_count, current_label, frame, } => serde_json::json!({ "type": "ToolStatus", "completed_count": completed_count, "current_label": current_label, "frame": frame }), } } /// Convert ratatui buffer to string fn buffer_to_string(buffer: &ratatui::buffer::Buffer, strip_ansi: bool) -> String { let area = buffer.area; let mut output = String::new(); for y in 0..area.height { for x in 0..area.width { let cell = &buffer[(x, y)]; if strip_ansi { output.push_str(cell.symbol()); } else { // Include ANSI styling let fg = cell.fg; let bg = cell.bg; let mods = cell.modifier; // Simple ANSI encoding if fg != ratatui::style::Color::Reset || bg != ratatui::style::Color::Reset || !mods.is_empty() { output.push_str("\x1b["); let mut first = true; if mods.contains(ratatui::style::Modifier::BOLD) { output.push('1'); first = false; } if mods.contains(ratatui::style::Modifier::DIM) { if !first { output.push(';'); } output.push('2'); first = false; } if mods.contains(ratatui::style::Modifier::REVERSED) { if !first { output.push(';'); } output.push('7'); first = false; } if mods.contains(ratatui::style::Modifier::UNDERLINED) { if !first { output.push(';'); } output.push('4'); first = false; } if let Some(code) = color_to_ansi(fg, true) { if !first { output.push(';'); } output.push_str(&code); first = false; } if let Some(code) = color_to_ansi(bg, false) { if !first { output.push(';'); } output.push_str(&code); } output.push('m'); } output.push_str(cell.symbol()); if fg != ratatui::style::Color::Reset || bg != ratatui::style::Color::Reset || !mods.is_empty() { output.push_str("\x1b[0m"); } } } output.push('\n'); } output } fn color_to_ansi(color: ratatui::style::Color, foreground: bool) -> Option { use ratatui::style::Color; let base = if foreground { 30 } else { 40 }; match color { Color::Reset => None, Color::Black => Some((base).to_string()), Color::Red => Some((base + 1).to_string()), Color::Green => Some((base + 2).to_string()), Color::Yellow => Some((base + 3).to_string()), Color::Blue => Some((base + 4).to_string()), Color::Magenta => Some((base + 5).to_string()), Color::Cyan => Some((base + 6).to_string()), Color::Gray | Color::White => Some((base + 7).to_string()), Color::DarkGray => Some((base + 60).to_string()), Color::LightRed => Some((base + 61).to_string()), Color::LightGreen => Some((base + 62).to_string()), Color::LightYellow => Some((base + 63).to_string()), Color::LightBlue => Some((base + 64).to_string()), Color::LightMagenta => Some((base + 65).to_string()), Color::LightCyan => Some((base + 66).to_string()), Color::Indexed(i) => Some(format!("{}8;5;{}", if foreground { 3 } else { 4 }, i)), Color::Rgb(r, g, b) => Some(format!( "{}8;2;{};{};{}", if foreground { 3 } else { 4 }, r, g, b )), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_simple_input() { let json = r#"{ "events": [ {"type": "user_message", "content": "list files"}, {"type": "tool_call", "id": "123", "name": "suggest_command", "input": {"command": "ls -la"}} ], "mode": "Review" }"#; let input = DebugInput::from_json(json).unwrap(); assert_eq!(input.events.len(), 2); assert_eq!(input.mode, "Review"); let state = input.to_state(); assert_eq!(state.events.len(), 2); assert_eq!(state.mode, AppMode::Review); } #[test] fn test_parse_streaming_state() { let json = r#"{ "events": [ {"type": "user_message", "content": "explain flags"} ], "mode": "Streaming", "streaming_text": "The -l flag means..." }"#; let input = DebugInput::from_json(json).unwrap(); let state = input.to_state(); assert_eq!(state.mode, AppMode::Streaming); assert_eq!(state.streaming_text, "The -l flag means..."); } } ================================================ FILE: crates/atuin-ai/src/commands/init.rs ================================================ use crate::commands::detect_shell; pub async fn run(shell: String) -> eyre::Result<()> { let integration = match shell.as_str() { "zsh" => generate_zsh_integration(), "bash" => generate_bash_integration(), "fish" => generate_fish_integration(), "auto" => generate_auto_integration()?, _ => eyre::bail!("Unsupported shell: {}", shell), }; println!("{}", integration); Ok(()) } fn generate_auto_integration() -> eyre::Result<&'static str> { let shell = detect_shell(); match shell.as_deref() { Some("zsh") => Ok(generate_zsh_integration()), Some("bash") => Ok(generate_bash_integration()), Some("fish") => Ok(generate_fish_integration()), Some(s) => eyre::bail!("Unsupported shell: {}", s), None => eyre::bail!("Could not detect shell"), } } /// Generate the zsh integration function - pure function for easy testing pub fn generate_zsh_integration() -> &'static str { r#" # TUI uses an alternate screen, so no explicit cleanup is needed. _atuin_ai_cleanup() { true } # Question mark at start of line - natural language mode. # Named with 'self-' prefix so bracketed-paste-magic activates it during # paste, allowing url-quote-magic to escape ? in pasted URLs via self-insert. self-atuin-ai-question-mark() { # If buffer is empty or just contains '?', trigger natural language mode if [[ -z "$BUFFER" || "$BUFFER" == "?" ]]; then BUFFER="" local output output=$(atuin ai inline --hook 3>&1 1>&2 2>&3) # Clean up the inline viewport _atuin_ai_cleanup if [[ $output == __atuin_ai_print__:* ]]; then zle -I echo "${output#__atuin_ai_print__:}" elif [[ $output == __atuin_ai_cancel__ ]]; then zle reset-prompt elif [[ $output == __atuin_ai_execute__:* ]]; then RBUFFER="" LBUFFER=${output#__atuin_ai_execute__:} zle reset-prompt zle accept-line elif [[ $output == __atuin_ai_insert__:* ]]; then RBUFFER="" LBUFFER=${output#__atuin_ai_insert__:} zle reset-prompt elif [[ -n $output ]]; then RBUFFER="" LBUFFER=$output zle reset-prompt else zle reset-prompt fi else zle self-insert fi } # Set up keybindings zle -N self-atuin-ai-question-mark bindkey '?' self-atuin-ai-question-mark # Question mark "# .trim() } /// Generate the bash integration function - pure function for easy testing pub fn generate_bash_integration() -> &'static str { r#" # Question mark at start of line - natural language mode _atuin_ai_question_mark() { # If buffer is empty or just contains '?', trigger natural language mode if [[ -z "$READLINE_LINE" || "$READLINE_LINE" == "?" ]]; then READLINE_LINE="" READLINE_POINT=0 local output output=$(atuin ai inline --hook 3>&1 1>&2 2>&3) if [[ $output == __atuin_ai_print__:* ]]; then echo "${output#__atuin_ai_print__:}" READLINE_LINE="" READLINE_POINT=0 elif [[ $output == __atuin_ai_cancel__ ]]; then READLINE_LINE="" READLINE_POINT=0 elif [[ $output == __atuin_ai_execute__:* ]]; then # Execute the command immediately READLINE_LINE=${output#__atuin_ai_execute__:} READLINE_POINT=${#READLINE_LINE} # Note: We can't directly execute in bash bind -x, but we can # use a workaround by binding to a macro that accepts the line bind '"\C-x\C-a": accept-line' bind -x '"\C-x\C-e": _atuin_ai_question_mark' elif [[ $output == __atuin_ai_insert__:* ]]; then # Insert the command for editing READLINE_LINE=${output#__atuin_ai_insert__:} READLINE_POINT=${#READLINE_LINE} elif [[ -n $output ]]; then # Default: insert for editing READLINE_LINE=$output READLINE_POINT=${#READLINE_LINE} fi else # Not at empty prompt, just insert the question mark READLINE_LINE="${READLINE_LINE:0:READLINE_POINT}?${READLINE_LINE:READLINE_POINT}" ((READLINE_POINT++)) fi } # Set up keybindings # Bash requires special handling: we use bind -x for the function, # but need a two-step approach for execute mode __atuin_ai_accept_line="" _atuin_ai_question_mark_wrapper() { _atuin_ai_question_mark if [[ -n "$__atuin_ai_accept_line" ]]; then __atuin_ai_accept_line="" fi } bind -x '"?": _atuin_ai_question_mark' "# .trim() } /// Generate the fish integration function - pure function for easy testing pub fn generate_fish_integration() -> &'static str { r#" # Question mark at start of line - natural language mode function _atuin_ai_question_mark set -l buf (commandline -b) # If buffer is empty or just contains '?', trigger natural language mode if test -z "$buf" -o "$buf" = "?" commandline -r "" # Run atuin ai inline, swapping stdout and stderr set -l output (atuin ai inline --hook 3>&1 1>&2 2>&3 | string collect) if string match --quiet '__atuin_ai_print__:*' "$output" echo (string replace "__atuin_ai_print__:" "" -- "$output" | string collect) commandline -f repaint else if test "$output" = "__atuin_ai_cancel__" commandline -f repaint else if string match --quiet '__atuin_ai_execute__:*' "$output" # Execute the command immediately set -l cmd (string replace "__atuin_ai_execute__:" "" -- "$output" | string collect) commandline -r "$cmd" commandline -f repaint commandline -f execute else if string match --quiet '__atuin_ai_insert__:*' "$output" # Insert the command for editing set -l cmd (string replace "__atuin_ai_insert__:" "" -- "$output" | string collect) commandline -r "$cmd" commandline -f repaint else if test -n "$output" # Default: insert for editing commandline -r "$output" commandline -f repaint else commandline -f repaint end else # Not at empty prompt, just insert the question mark commandline -i "?" end end # Set up keybindings bind "?" _atuin_ai_question_mark "# .trim() } #[cfg(test)] mod tests { use super::*; #[test] fn test_generate_zsh_integration() { let result = generate_zsh_integration(); assert!(result.contains("self-atuin-ai-question-mark")); assert!(result.contains("bindkey")); assert!(result.contains("atuin ai inline --hook")); assert!(result.contains("__atuin_ai_print__")); assert!(result.contains("__atuin_ai_cancel__")); assert!(result.contains("__atuin_ai_execute__")); assert!(result.contains("__atuin_ai_insert__")); assert!(result.contains("zle self-insert")); } #[test] fn test_generate_bash_integration() { let result = generate_bash_integration(); assert!(result.contains("_atuin_ai_question_mark")); assert!(result.contains("bind")); assert!(result.contains("READLINE_LINE")); assert!(result.contains("atuin ai inline --hook")); assert!(result.contains("__atuin_ai_print__")); assert!(result.contains("__atuin_ai_cancel__")); assert!(result.contains("__atuin_ai_execute__")); assert!(result.contains("__atuin_ai_insert__")); } #[test] fn test_generate_fish_integration() { let result = generate_fish_integration(); assert!(result.contains("_atuin_ai_question_mark")); assert!(result.contains("bind")); assert!(result.contains("commandline")); assert!(result.contains("atuin ai inline --hook")); assert!(result.contains("__atuin_ai_print__")); assert!(result.contains("__atuin_ai_cancel__")); assert!(result.contains("__atuin_ai_execute__")); assert!(result.contains("__atuin_ai_insert__")); } } ================================================ FILE: crates/atuin-ai/src/commands/inline.rs ================================================ use crate::commands::detect_shell; use crate::tui::render::render; use crate::tui::{ App, AppEvent, AppMode, ConversationEvent, EventLoop, ExitAction, RenderContext, TerminalGuard, calculate_needed_height, install_panic_hook, }; use atuin_client::distro::detect_linux_distribution; use atuin_client::theme::ThemeManager; use atuin_common::tls::ensure_crypto_provider; use crossterm::{ event::{self, Event, KeyCode}, terminal::{disable_raw_mode, enable_raw_mode}, }; use eventsource_stream::Eventsource; use eyre::{Context as _, Result, bail}; use futures::StreamExt; use reqwest::Url; use std::io::Write; use tracing::{debug, error, info, trace}; pub async fn run( initial_command: Option, api_endpoint: Option, api_token: Option, keep_output: bool, debug_state_file: Option, settings: &atuin_client::settings::Settings, output_for_hook: bool, ) -> Result<()> { if !settings.ai.enabled { emit_shell_result( Action::Print( "Atuin AI is not enabled. Please enable it in your settings or run `atuin setup`." .to_string(), ), output_for_hook, ); return Ok(()); } // Install panic hook once at entry point to ensure terminal restoration install_panic_hook(); // Token and endpoint priority: // 1. Command line arguments/environment variables // 2. Settings file // 3. Default let endpoint = api_endpoint.as_deref().unwrap_or( settings .ai .endpoint .as_deref() .unwrap_or("https://hub.atuin.sh"), ); let api_token = api_token.as_deref().or(settings.ai.api_token.as_deref()); let token = if let Some(token) = &api_token { token.to_string() } else { // ensure_hub_session will authenticate against settings.active_hub_endpoint().unwrap_or_default(), // which is the default Hub endpoint if no endpoint is provided // // TODO[mkt]: Atuin AI and the Hub sync endpoint are too tightly coupled; // current setup means that Hub endpoint controls auth while AI endpoint controls AI conversations ensure_hub_session(settings).await? }; let action = run_inline_tui( endpoint.to_string(), token, initial_command, keep_output, debug_state_file, settings, ) .await?; emit_shell_result(action, output_for_hook); Ok(()) } async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Result { if let Some(token) = atuin_client::hub::get_session_token().await? { debug!("Found Hub session, using existing token"); return Ok(token); } let hub_address = settings.active_hub_endpoint().unwrap_or_default(); let will_sync = settings.is_hub_sync(); info!("No Hub session found, prompting for authentication"); println!("Atuin AI requires authenticating with Atuin Hub."); if will_sync { println!( "Once logged in, your shell history will be synchronized via Atuin Hub if auto_sync is enabled or when manually syncing." ) } println!( "If you have an existing Atuin sync account, you can log in with your existing credentials." ); println!("Press enter to begin (or esc to cancel)."); if !wait_for_login_confirmation()? { bail!("authentication canceled"); } debug!("Starting Atuin Hub authentication..."); println!("Authenticating with Atuin Hub..."); let session = atuin_client::hub::HubAuthSession::start(hub_address.as_ref()).await?; println!("Open this URL to continue:"); println!("{}", session.auth_url); let token = session .wait_for_completion( atuin_client::hub::DEFAULT_AUTH_TIMEOUT, atuin_client::hub::DEFAULT_POLL_INTERVAL, ) .await?; info!("Authentication complete, saving session token"); atuin_client::hub::save_session(&token).await?; // Silently attempt to link CLI account to Hub if one exists // This enables unified auth - users can use their Hub token for sync if let Ok(meta) = atuin_client::settings::Settings::meta_store().await && let Ok(Some(cli_token)) = meta.session_token().await { debug!("CLI session found, attempting to link accounts"); if let Err(e) = atuin_client::hub::link_account(hub_address.as_ref(), &cli_token).await { // Don't fail AI flow if linking fails - it's not critical debug!("Could not link CLI account to Hub: {}", e); } else { info!("Successfully linked CLI account to Hub"); } } Ok(token) } /// SSE event received from chat endpoint #[derive(Debug, Clone)] enum ChatStreamEvent { /// Text chunk to display TextChunk(String), /// Tool call event (need to echo back, may contain suggest_command) ToolCall { id: String, name: String, input: serde_json::Value, }, /// Tool result from server-side execution ToolResult { tool_use_id: String, content: String, is_error: bool, }, /// Status update from server Status(String), /// Stream complete Done { session_id: String }, /// Error from server Error(String), } fn create_chat_stream( hub_address: String, token: String, session_id: Option, messages: Vec, settings: &atuin_client::settings::Settings, ) -> std::pin::Pin> + Send>> { let send_cwd = settings.ai.send_cwd; Box::pin(async_stream::stream! { ensure_crypto_provider(); let endpoint = match hub_url(&hub_address, "/api/cli/chat") { Ok(url) => url, Err(e) => { yield Err(e); return; } }; debug!("Sending SSE request to {endpoint}"); let os = detect_os(); let shell = detect_shell(); let mut context = serde_json::json!({ "os": os, "shell": shell, "pwd": if send_cwd { std::env::current_dir() .ok() .map(|path| path.to_string_lossy().into_owned()) } else { None }, }); if os == "linux" { context["distro"] = serde_json::json!(detect_linux_distribution()); } // Build request body let mut request_body = serde_json::json!({ "messages": messages, "context": context, }); // Include session_id only if present (not on first request) if let Some(ref sid) = session_id { trace!("Including session_id in request: {sid}"); request_body["session_id"] = serde_json::json!(sid); } let client = reqwest::Client::new(); let response = match client .post(endpoint.clone()) .header("Accept", "text/event-stream") .bearer_auth(&token) .json(&request_body) .send() .await { Ok(resp) => resp, Err(e) => { yield Err(eyre::eyre!("Failed to send SSE request: {}", e)); return; } }; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { // Clear saved session on auth error error!("SSE request failed with status: {status}, clearing session"); let _ = atuin_client::hub::delete_session().await; yield Err(eyre::eyre!("Hub session expired. Re-run to authenticate again.")); return; } if !status.is_success() { let body = response.text().await.unwrap_or_default(); error!("SSE request failed ({}): {}", status, body); yield Err(eyre::eyre!("SSE request failed ({}): {}", status, body)); return; } let byte_stream = response.bytes_stream(); let mut stream = byte_stream.eventsource(); while let Some(event) = stream.next().await { match event { Ok(sse_event) => { let event_type = sse_event.event.as_str(); let data = sse_event.data.clone(); debug!(event_type = %event_type, "SSE event received"); match event_type { "text" => { if let Ok(json) = serde_json::from_str::(&data) && let Some(content) = json.get("content").and_then(|v| v.as_str()) { yield Ok(ChatStreamEvent::TextChunk(content.to_string())); } } "tool_call" => { if let Ok(json) = serde_json::from_str::(&data) { let id = json.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); let input = json.get("input").cloned().unwrap_or(serde_json::json!({})); yield Ok(ChatStreamEvent::ToolCall { id, name, input }); } } "tool_result" => { if let Ok(json) = serde_json::from_str::(&data) { let tool_use_id = json.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); let content = json.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(); let is_error = json.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false); yield Ok(ChatStreamEvent::ToolResult { tool_use_id, content, is_error }); } } "status" => { if let Ok(json) = serde_json::from_str::(&data) && let Some(state) = json.get("state").and_then(|v| v.as_str()) { yield Ok(ChatStreamEvent::Status(state.to_string())); } } "done" => { if let Ok(json) = serde_json::from_str::(&data) { let session_id = json.get("session_id") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); yield Ok(ChatStreamEvent::Done { session_id }); } else { yield Ok(ChatStreamEvent::Done { session_id: String::new() }); } break; } "error" => { if let Ok(json) = serde_json::from_str::(&data) { let message = json.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error").to_string(); error!("SSE error: {}", message); yield Ok(ChatStreamEvent::Error(message)); } else { error!("SSE error: {}", data); yield Ok(ChatStreamEvent::Error(data)); } break; } _ => { // Unknown event type, ignore } } } Err(e) => { yield Err(eyre::eyre!("SSE error: {}", e)); break; } } } }) } fn hub_url(base: &str, path: &str) -> Result { let base_with_slash = if base.ends_with('/') { base.to_string() } else { format!("{base}/") }; let stripped = path.strip_prefix('/').unwrap_or(path); Url::parse(&base_with_slash)? .join(stripped) .context("failed to build hub URL") } fn detect_os() -> String { match std::env::consts::OS { "macos" => "macos".to_string(), "linux" => "linux".to_string(), "windows" => "windows".to_string(), other => format!("Other: {other}"), } } #[derive(Clone)] enum Action { Execute(String), Insert(String), Print(String), Cancel, } /// Serialize AppState to JSON for debug logging fn state_to_json(state: &crate::tui::AppState) -> serde_json::Value { let events: Vec = state.events.iter().map(|e| e.to_json()).collect(); let mode = match state.mode { AppMode::Input => "Input", AppMode::Generating => "Generating", AppMode::Streaming => "Streaming", AppMode::Review => "Review", AppMode::Error => "Error", }; // Get input and cursor from textarea let input = state.input(); let cursor = state.textarea.cursor(); let mut json = serde_json::json!({ "events": events, "mode": mode, "input": input, "cursor_row": cursor.0, "cursor_col": cursor.1, "spinner_frame": state.spinner_frame, "confirmation_pending": state.confirmation_pending, }); // Add streaming fields if in streaming mode if !state.streaming_text.is_empty() { json["streaming_text"] = serde_json::json!(state.streaming_text); } if let Some(ref status) = state.streaming_status { json["streaming_status"] = serde_json::json!(status.display_text()); } if let Some(ref err) = state.error { json["error"] = serde_json::json!(err); } json } /// Debug logger that writes state changes to a file struct DebugStateLogger { file: std::fs::File, entry_count: usize, width: u16, } impl DebugStateLogger { fn new(path: &str) -> Result { let file = std::fs::File::create(path) .with_context(|| format!("Failed to create debug state file: {}", path))?; // Get terminal width, default to 80 let (width, _) = crossterm::terminal::size().unwrap_or((80, 24)); Ok(Self { file, entry_count: 0, width, }) } fn log(&mut self, label: &str, state: &crate::tui::AppState) { use crate::tui::calculate_needed_height; self.entry_count += 1; let timestamp_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0); // Calculate the actual content height needed for this state let content_height = calculate_needed_height(state, 0); let mut state_json = state_to_json(state); // Add dimensions for accurate replay state_json["width"] = serde_json::json!(self.width); state_json["height"] = serde_json::json!(content_height); let entry = serde_json::json!({ "entry": self.entry_count, "label": label, "timestamp_ms": timestamp_ms, "state": state_json, }); // Write as JSONL (one JSON object per line) if let Err(e) = writeln!(self.file, "{}", entry) { tracing::warn!("Failed to write debug state: {}", e); } let _ = self.file.flush(); } } async fn run_inline_tui( endpoint: String, token: String, initial_prompt: Option, keep_output: bool, debug_state_file: Option, settings: &atuin_client::settings::Settings, ) -> Result { // Detect popup mode (only on Unix where atuin-hex socket is available) #[cfg(unix)] let mut popup_state = crate::tui::popup::try_setup_popup(); #[cfg(not(unix))] let mut popup_state: Option<()> = None; let popup_mode = popup_state.is_some(); // Initialize terminal guard: popup mode uses Fixed viewport, inline uses Inline #[cfg(unix)] let mut guard = if let Some(ref ps) = popup_state { TerminalGuard::new_popup(ps.current_rect, ps.saved_screen.cursor_col)? } else { TerminalGuard::new(keep_output)? }; #[cfg(not(unix))] let mut guard = TerminalGuard::new(keep_output)?; let mut app = App::new(); if let Some(prompt) = initial_prompt { // Set initial text in textarea let mut textarea = tui_textarea::TextArea::from(prompt.lines()); // Disable underline on cursor line textarea.set_cursor_line_style(ratatui::style::Style::default()); // Enable word wrapping textarea.set_wrap_mode(tui_textarea::WrapMode::Word); // Move cursor to end textarea.move_cursor(tui_textarea::CursorMove::End); app.state.textarea = textarea; } // Initialize debug state logger if requested let mut debug_logger = debug_state_file .map(|path| DebugStateLogger::new(&path)) .transpose()?; // Helper macro to log state changes macro_rules! log_state { ($label:expr) => { if let Some(ref mut logger) = debug_logger { logger.log($label, &app.state); } }; } // Log initial state log_state!("init"); // Load theme let mut theme_manager = ThemeManager::new(None, None); let theme = theme_manager.load_theme(&settings.theme.name, None); // Initialize event loop let mut event_loop = EventLoop::new(); // Track chat stream let mut chat_stream: Option< std::pin::Pin> + Send>>, > = None; loop { // Ensure viewport is large enough for current content (capped at terminal height) // In popup mode, use the actual popup width for accurate height calculation let card_width = if popup_mode { #[cfg(unix)] { popup_state .as_ref() .map(|ps| { ps.current_rect .width .saturating_sub(crate::tui::popup::POPUP_MARGIN * 2) }) .unwrap_or(0) } #[cfg(not(unix))] { 0 } } else { 0 }; let needed_height = calculate_needed_height(&app.state, card_width); // Grow popup dynamically as content arrives #[cfg(unix)] if let Some(ref mut ps) = popup_state { // Add vertical margin for visual separation from terminal content let popup_height = needed_height.saturating_add(crate::tui::popup::POPUP_MARGIN * 2); if let Some(new_rect) = ps.fit_to(popup_height) { guard.resize_popup(new_rect)?; } } let actual_height = guard.ensure_height(needed_height)?; // Render current state let anchor_col = guard.anchor_col(); #[cfg(unix)] let render_above = popup_state.as_ref().is_some_and(|ps| ps.render_above); #[cfg(not(unix))] let render_above = false; let ctx = RenderContext { theme, anchor_col, textarea: Some(&app.state.textarea), max_height: actual_height, popup_mode, render_above, }; // Handle draw errors gracefully - cursor position reads can fail during resize if let Err(e) = guard.terminal().draw(|frame| { render(frame, &app.state, &ctx); }) { let err_msg = e.to_string(); if err_msg.contains("cursor position") { // Cursor position read failed (common during terminal resize) // Skip this frame and continue - next frame will likely succeed tracing::debug!( "Skipping frame due to cursor position read error: {}", err_msg ); continue; } return Err(e.into()); } // Get next event let event = event_loop.run().await?; // Handle event based on app mode match event { AppEvent::Key(key) => { app.handle_key(key); log_state!("key"); } AppEvent::Tick => { app.state.tick(); // Poll chat stream if active - keep polling until done regardless of mode // (mode may change to Review before we receive the done event with session_id) if let Some(stream) = &mut chat_stream { let mut cx = std::task::Context::from_waker(futures::task::noop_waker_ref()); match stream.as_mut().poll_next(&mut cx) { std::task::Poll::Ready(Some(Ok(event))) => match event { ChatStreamEvent::TextChunk(text) => { trace!(text = %text, "Processing TextChunk"); app.state.append_streaming_text(&text); log_state!("text_chunk"); } ChatStreamEvent::ToolCall { id, name, input } => { trace!(id = %id, name = %name, "Processing ToolCall"); app.state.add_tool_call(id, name, input); log_state!("tool_call"); } ChatStreamEvent::ToolResult { tool_use_id, content, is_error, } => { trace!(tool_use_id = %tool_use_id, "Processing ToolResult"); app.state.add_tool_result(tool_use_id, content, is_error); log_state!("tool_result"); } ChatStreamEvent::Status(status) => { trace!(status = %status, "Processing Status"); app.state.update_streaming_status(&status); log_state!("status"); } ChatStreamEvent::Done { session_id } => { trace!(session_id = %session_id, "Processing Done"); chat_stream = None; if !session_id.is_empty() { app.state.store_session_id(session_id); } app.state.finalize_streaming(); log_state!("done"); } ChatStreamEvent::Error(msg) => { trace!(error = %msg, "Processing Error"); chat_stream = None; app.state.streaming_error(msg); log_state!("error"); } }, std::task::Poll::Ready(Some(Err(e))) => { chat_stream = None; app.state.streaming_error(e.to_string()); log_state!("stream_error"); } std::task::Poll::Ready(None) => { chat_stream = None; app.state.finalize_streaming(); log_state!("stream_end"); } std::task::Poll::Pending => {} } } } _ => {} } // Handle user cancellation (Esc during streaming) - drop the stream if app.state.was_interrupted && chat_stream.is_some() { debug!("User cancelled streaming, dropping chat stream"); chat_stream = None; app.state.was_interrupted = false; // Reset the flag } // Check exit condition (includes Ctrl+C / SIGINT from event loop) if app.state.should_exit || event_loop.is_shutdown() { break; } // Handle generation trigger - unified path for all turns if app.state.mode == AppMode::Generating && chat_stream.is_none() { // Get the last user message from events let last_user_content = app.state.events.iter().rev().find_map(|e| { if let ConversationEvent::UserMessage { content } = e { Some(content.clone()) } else { None } }); if last_user_content.is_some() { // Build messages in Claude API format let messages = app.state.events_to_messages(); // Transition to streaming mode app.state.start_streaming(); log_state!("start_streaming"); // Start the chat stream chat_stream = Some(create_chat_stream( endpoint.clone(), token.clone(), app.state.session_id.clone(), messages, settings, )); } } } // Restore popup area before guard drops (guard skips cleanup in popup mode) #[cfg(unix)] if let Some(ref ps) = popup_state { crate::tui::popup::restore(ps); } // Map exit action to return value let result = match app.state.exit_action { Some(ExitAction::Execute(cmd)) => Action::Execute(cmd), Some(ExitAction::Insert(cmd)) => Action::Insert(cmd), _ => Action::Cancel, }; Ok(result) } struct RawModeGuard; impl Drop for RawModeGuard { fn drop(&mut self) { let _ = disable_raw_mode(); } } fn emit_shell_result(action: Action, output_for_hook: bool) { if output_for_hook { match action { Action::Execute(output) => eprintln!("__atuin_ai_execute__:{output}"), Action::Insert(output) => eprintln!("__atuin_ai_insert__:{output}"), Action::Print(output) => eprintln!("__atuin_ai_print__:{output}"), Action::Cancel => eprintln!("__atuin_ai_cancel__"), } } else { match action { Action::Execute(output) => eprintln!("{output}"), Action::Insert(output) => eprintln!("{output}"), Action::Print(output) => eprintln!("{output}"), Action::Cancel => eprintln!(), } } } fn wait_for_login_confirmation() -> Result { enable_raw_mode().context("failed enabling raw mode for login prompt")?; let _guard = RawModeGuard; loop { let ev = event::read().context("failed to read login confirmation key")?; if let Event::Key(key) = ev { match key.code { KeyCode::Enter => return Ok(true), KeyCode::Esc => return Ok(false), _ => {} } } } } ================================================ FILE: crates/atuin-ai/src/commands.rs ================================================ use std::{ fs, path::{Path, PathBuf}, }; use atuin_common::shell::Shell; use clap::{Args, Subcommand}; use eyre::Result; use tracing_appender::rolling::{RollingFileAppender, Rotation}; use tracing_subscriber::{EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt}; #[cfg(debug_assertions)] pub mod debug_render; pub mod init; pub mod inline; #[derive(Args, Debug)] pub struct AiArgs { /// Enable verbose logging #[arg(short, long, global = true)] verbose: bool, /// Custom API endpoint; defaults to reading from the `ai.endpoint` setting. #[arg(long, global = true)] api_endpoint: Option, /// Custom API token; defaults to reading from the `ai.api_token` setting. #[arg(long, global = true)] api_token: Option, } #[derive(Subcommand, Debug)] pub enum Commands { /// Initialize shell integration Init { /// Shell to generate integration for; defaults to "auto" #[arg(value_name = "SHELL", default_value = "auto")] shell: String, }, /// Inline completion mode with small TUI overlay Inline { #[command(flatten)] args: AiArgs, /// Current command line to complete #[arg(value_name = "COMMAND")] command: Option, /// Keep TUI output visible after exit (default: erase) #[arg(long)] keep: bool, /// Use the hook mode #[arg(long, hide = true)] hook: bool, /// Log state changes to file for debugging (dev tool) #[arg(long, value_name = "FILE", hide = true)] debug_state: Option, }, /// Debug render: output a single frame from JSON state (dev tool) #[cfg(debug_assertions)] DebugRender { /// Input file (reads from stdin if not provided) #[arg(short, long)] input: Option, /// Output format: ansi (default), plain, json #[arg(short, long, default_value = "ansi")] format: String, }, } pub async fn run( command: Commands, settings: &atuin_client::settings::Settings, ) -> eyre::Result<()> { match command { Commands::Init { shell } => init::run(shell).await, Commands::Inline { command, keep, debug_state, hook, args, .. } => { if settings.logs.ai_enabled() { init_logging(settings, args.verbose)?; } inline::run( command, args.api_endpoint, args.api_token, keep, debug_state, settings, hook, ) .await } #[cfg(debug_assertions)] Commands::DebugRender { input, format } => { let output_format = match format.as_str() { "plain" => debug_render::OutputFormat::Plain, "json" => debug_render::OutputFormat::Json, _ => debug_render::OutputFormat::Ansi, }; debug_render::run(input, output_format).await } } } pub fn detect_shell() -> Option { Some(Shell::current().to_string()) } /// Initializes logging for the AI commands. fn init_logging(settings: &atuin_client::settings::Settings, verbose: bool) -> Result<()> { // ATUIN_LOG env var overrides config file level settings let env_log_set = std::env::var("ATUIN_LOG").is_ok(); // Base filter from env var (or empty if not set) let base_filter = EnvFilter::from_env("ATUIN_LOG").add_directive("sqlx_sqlite::regexp=off".parse()?); // Use config level unless ATUIN_LOG is set let filter = if env_log_set { base_filter } else { EnvFilter::default() .add_directive(settings.logs.ai_level().as_directive().parse()?) .add_directive("sqlx_sqlite::regexp=off".parse()?) }; let log_dir = PathBuf::from(&settings.logs.dir); let ai_log_filename = settings.logs.ai.file.clone(); // Clean up old log files cleanup_old_logs(&log_dir, &ai_log_filename, settings.logs.ai_retention()); let console_layer = if verbose { Some( fmt::layer() .with_writer(std::io::stderr) .with_ansi(true) .with_target(false) .with_filter(filter.clone()), ) } else { None }; let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, &ai_log_filename); let base = tracing_subscriber::registry().with( fmt::layer() .with_writer(file_appender) .with_ansi(false) .with_filter(filter), ); if let Some(console_layer) = console_layer { base.with(console_layer).init(); } else { base.init(); }; Ok(()) } fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) { let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(retention_days * 24 * 60 * 60); let Ok(entries) = fs::read_dir(log_dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; // Match files like "search.log.2024-02-23" or "daemon.log.2024-02-23" if !name.starts_with(prefix) || name == prefix { continue; } if let Ok(metadata) = entry.metadata() && let Ok(modified) = metadata.modified() && modified < cutoff { let _ = fs::remove_file(&path); } } } ================================================ FILE: crates/atuin-ai/src/lib.rs ================================================ pub mod commands; pub mod tui; ================================================ FILE: crates/atuin-ai/src/tui/app.rs ================================================ use super::state::{AppMode, AppState, ExitAction}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tui_textarea::{Input, Key}; /// Thin wrapper around AppState for compatibility /// All state lives in AppState, this just provides the handle_key interface pub struct App { pub state: AppState, } impl App { pub fn new() -> Self { Self { state: AppState::new(), } } /// Handle a key event. Returns true if render is needed. pub fn handle_key(&mut self, key: KeyEvent) -> bool { match self.state.mode { AppMode::Input => self.handle_input_key(key), AppMode::Generating => self.handle_generating_key(key), AppMode::Streaming => self.handle_streaming_key(key), AppMode::Review => self.handle_review_key(key), AppMode::Error => self.handle_error_key(key), } } fn handle_input_key(&mut self, key: KeyEvent) -> bool { // Handle special keys ourselves match key.code { KeyCode::Esc => { self.state.exit(ExitAction::Cancel); return true; } KeyCode::Enter => { if self.state.input_is_empty() { self.state.exit(ExitAction::Cancel); } else { self.state.start_generating(); } return true; } _ => {} } // Delegate all other keys to textarea // Manually convert crossterm KeyEvent to tui-textarea Input // (needed due to crossterm version mismatch) let tui_key = match key.code { KeyCode::Char(c) => Key::Char(c), KeyCode::Backspace => Key::Backspace, KeyCode::Delete => Key::Delete, KeyCode::Left => Key::Left, KeyCode::Right => Key::Right, KeyCode::Up => Key::Up, KeyCode::Down => Key::Down, KeyCode::Home => Key::Home, KeyCode::End => Key::End, KeyCode::PageUp => Key::PageUp, KeyCode::PageDown => Key::PageDown, KeyCode::Tab => Key::Tab, _ => Key::Null, }; if tui_key != Key::Null { let input = Input { key: tui_key, ctrl: key.modifiers.contains(KeyModifiers::CONTROL), alt: key.modifiers.contains(KeyModifiers::ALT), shift: key.modifiers.contains(KeyModifiers::SHIFT), }; self.state.textarea.input(input); } true } fn handle_generating_key(&mut self, key: KeyEvent) -> bool { match key.code { KeyCode::Esc => { self.state.cancel_generation(); true } _ => false, // Discard other keys during generation } } fn handle_streaming_key(&mut self, key: KeyEvent) -> bool { match key.code { KeyCode::Esc => { self.state.cancel_streaming(); true } _ => false, // Ignore other keys during streaming } } fn handle_review_key(&mut self, key: KeyEvent) -> bool { match key.code { KeyCode::Esc => { self.state.confirmation_pending = false; // Clear confirmation state self.state.exit(ExitAction::Cancel); true } KeyCode::Enter => { let cmd = self.state.current_command().map(|c| c.to_string()); if let Some(cmd) = cmd { if self.state.is_current_command_dangerous() && !self.state.confirmation_pending { // First Enter on dangerous command: enter confirmation mode self.state.confirmation_pending = true; } else { // Second Enter (confirmation), or non-dangerous command: execute self.state.confirmation_pending = false; self.state.exit(ExitAction::Execute(cmd)); } } true } KeyCode::Tab => { let cmd = self.state.current_command().map(|c| c.to_string()); if let Some(cmd) = cmd { self.state.confirmation_pending = false; // Clear on Tab too self.state.exit(ExitAction::Insert(cmd)); } true } KeyCode::Char('f') => { // Changed from 'e' to 'f' for follow-up mode self.state.confirmation_pending = false; // Clear on follow-up self.state.start_edit_mode(); true } _ => false, } } fn handle_error_key(&mut self, key: KeyEvent) -> bool { match key.code { KeyCode::Esc => { self.state.exit(ExitAction::Cancel); true } KeyCode::Enter | KeyCode::Char('r') => { self.state.retry(); true } _ => false, } } } impl Default for App { fn default() -> Self { Self::new() } } ================================================ FILE: crates/atuin-ai/src/tui/component.rs ================================================ //! Component-oriented rendering primitives for the TUI. //! //! Defines the `Component` trait and container types (`VStack`, `SymbolRow`, etc.) //! that enable declarative, composable UI layout. use atuin_client::theme::{Meaning, Theme}; use ratatui::{ Frame, backend::FromCrossterm, layout::Rect, style::Style, text::Span, widgets::Paragraph, }; use tui_textarea::TextArea; /// Context passed through the component tree during rendering. pub struct RenderContext<'a> { pub theme: &'a Theme, pub anchor_col: u16, pub textarea: Option<&'a TextArea<'static>>, /// Maximum viewport height (for scroll calculations) pub max_height: u16, /// When true, the viewport is a fixed rect already positioned for the card. /// The card fills the entire viewport instead of positioning via anchor_col. pub popup_mode: bool, /// When true, blocks are rendered in reverse order so that the input field /// appears at the bottom of the card (close to the prompt when the popup /// is above the cursor). pub render_above: bool, } /// A renderable component with intrinsic sizing. pub trait Component { /// Calculate the intrinsic height at the given width. fn height(&self, width: u16) -> u16; /// Render into the given area. fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext); } /// Vertical stack of components. /// /// Children are laid out top-to-bottom with optional spacing between them. /// When `scroll_offset > 0`, content is scrolled so that only the visible /// portion is rendered. pub struct VStack { pub children: Vec>, pub spacing: u16, pub scroll_offset: u16, } impl VStack { pub fn new(children: Vec>) -> Self { Self { children, spacing: 0, scroll_offset: 0, } } } impl Component for VStack { fn height(&self, width: u16) -> u16 { if self.children.is_empty() { return 0; } let content: u16 = self.children.iter().map(|c| c.height(width)).sum(); let gaps = (self.children.len() as u16 - 1) * self.spacing; content + gaps } fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { if self.children.is_empty() { return; } let heights: Vec = self.children.iter().map(|c| c.height(area.width)).collect(); let viewport_start = self.scroll_offset; let viewport_end = self.scroll_offset + area.height; let mut cum: u16 = 0; for (i, (child, &h)) in self.children.iter().zip(heights.iter()).enumerate() { let child_start = cum; let child_end = cum + h; // Render if any part of the child is within the viewport if child_end > viewport_start && child_start < viewport_end { let visible_start = child_start.max(viewport_start); let visible_end = child_end.min(viewport_end); let child_area = Rect { x: area.x, y: area.y + visible_start - viewport_start, width: area.width, height: visible_end - visible_start, }; child.render(frame, child_area, ctx); } cum = child_end; if i < self.children.len() - 1 { cum += self.spacing; } } } } /// Fixed-height empty space. pub struct Spacer(pub u16); impl Component for Spacer { fn height(&self, _width: u16) -> u16 { self.0 } fn render(&self, _frame: &mut Frame, _area: Rect, _ctx: &RenderContext) {} } /// A row with a symbol in column 0 and content in columns 2+. /// /// This is the horizontal layout primitive used by all content types that /// display a prefix symbol (>, $, !, ?, etc.) followed by text. pub struct SymbolRow { pub symbol: String, pub symbol_meaning: Meaning, pub inner: Box, } impl Component for SymbolRow { fn height(&self, width: u16) -> u16 { self.inner.height(width.saturating_sub(2)) } fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { // Render symbol at column 0, first row only let style = Style::from_crossterm(ctx.theme.as_style(self.symbol_meaning)); let symbol_area = Rect { x: area.x, y: area.y, width: 1, height: 1, }; frame.render_widget( Paragraph::new(self.symbol.as_str()).style(style), symbol_area, ); // Render inner content at column 2+ let content_area = Rect { x: area.x.saturating_add(2), y: area.y, width: area.width.saturating_sub(2), height: area.height, }; self.inner.render(frame, content_area, ctx); } } /// Horizontal separator spanning the full card width (├───┤). /// /// Extends beyond its content area to overlap the card's left and right borders. pub struct Separator { pub card_width: u16, } impl Component for Separator { fn height(&self, _width: u16) -> u16 { 1 } fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); let inner_width = self.card_width.saturating_sub(2) as usize; let separator = format!( "\u{251c}{}\u{2524}", // ├ ... ┤ "\u{2500}".repeat(inner_width) // ─ ); // Extend left to overlap the card border (content area is inset by border + padding) let sep_area = Rect { x: area.x.saturating_sub(2), y: area.y, width: self.card_width, height: 1, }; frame.render_widget(Paragraph::new(Span::styled(separator, style)), sep_area); } } ================================================ FILE: crates/atuin-ai/src/tui/components.rs ================================================ //! Leaf components for each content type and factory functions for building //! the component tree from the view model. use atuin_client::theme::{Meaning, Theme}; use ratatui::{ Frame, backend::FromCrossterm, layout::Rect, style::{Modifier, Style}, text::{Line, Span}, widgets::{Paragraph, Wrap}, }; use super::component::{Component, RenderContext, Separator, Spacer, SymbolRow, VStack}; use super::spinner::active_frame; use super::view_model::{Block, Content, WarningKind}; // --------------------------------------------------------------------------- // Text measurement utilities // --------------------------------------------------------------------------- /// Count lines when text is wrapped at given width. /// Uses ratatui's Paragraph::line_count for accurate wrapping calculation. pub(crate) fn line_count_wrapped(text: &str, width: usize) -> u16 { if width == 0 { return 1; } let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); paragraph.line_count(width as u16).max(1) as u16 } /// Count lines using word-wrap algorithm (matches TextArea's WrapMode::Word). /// Words won't be broken mid-word, so this may produce more lines than character wrapping. /// Returns (line_count, last_line_width) so caller can determine if cursor needs extra space. pub(crate) fn word_wrap_line_count_with_last_width(text: &str, width: usize) -> (u16, usize) { if width == 0 || text.is_empty() { return (1, 0); } let mut line_count = 0u16; let mut current_line_width = 0usize; for line in text.lines() { if line.is_empty() { line_count += 1; current_line_width = 0; continue; } let mut line_started = false; for word in line.split_whitespace() { let word_width = unicode_width::UnicodeWidthStr::width(word); if !line_started { if word_width > width { line_count += word_width.div_ceil(width) as u16; current_line_width = word_width % width; if current_line_width == 0 { current_line_width = 0; line_started = false; } else { line_started = true; } } else { current_line_width = word_width; line_started = true; } } else { let needed = current_line_width + 1 + word_width; if needed > width { line_count += 1; if word_width > width { line_count += word_width.div_ceil(width) as u16; current_line_width = word_width % width; if current_line_width == 0 { line_started = false; } } else { current_line_width = word_width; } } else { current_line_width = needed; } } } if line_started { line_count += 1; } } if line_count == 0 { line_count = 1; current_line_width = 0; } (line_count, current_line_width) } // --------------------------------------------------------------------------- // Inline markdown formatting // --------------------------------------------------------------------------- /// Parse inline markdown formatting (**bold** and `code`) into styled spans. /// Preserves all other text — list prefixes, indentation, and line structure /// are left exactly as-is. fn style_inline_markdown(text: &str, theme: &Theme) -> Vec> { let base_style = Style::from_crossterm(theme.as_style(Meaning::Base)); let code_style = Style::from_crossterm(theme.as_style(Meaning::Guidance)); let bold_style = base_style.add_modifier(Modifier::BOLD); text.lines() .map(|line| { Line::from(parse_inline_formatting( line, base_style, bold_style, code_style, )) }) .collect() } /// Parse a single line for `code` and **bold** markers, returning styled spans. fn parse_inline_formatting( line: &str, base: Style, bold: Style, code: Style, ) -> Vec> { let mut spans = Vec::new(); let mut current = String::new(); let mut chars = line.chars().peekable(); while let Some(ch) = chars.next() { if ch == '`' { // Flush accumulated plain text if !current.is_empty() { spans.push(Span::styled(std::mem::take(&mut current), base)); } // Collect until closing backtick let mut code_text = String::new(); let mut closed = false; for next in chars.by_ref() { if next == '`' { closed = true; break; } code_text.push(next); } if closed { spans.push(Span::styled(code_text, code)); } else { // Unclosed backtick — render as-is current.push('`'); current.push_str(&code_text); } } else if ch == '*' && chars.peek() == Some(&'*') { chars.next(); // consume second * // Flush accumulated plain text if !current.is_empty() { spans.push(Span::styled(std::mem::take(&mut current), base)); } // Collect until closing ** let mut bold_text = String::new(); let mut closed = false; while let Some(next) = chars.next() { if next == '*' && chars.peek() == Some(&'*') { chars.next(); closed = true; break; } bold_text.push(next); } if closed { spans.push(Span::styled(bold_text, bold)); } else { // Unclosed ** — render as-is current.push_str("**"); current.push_str(&bold_text); } } else { current.push(ch); } } if !current.is_empty() { spans.push(Span::styled(current, base)); } spans } // --------------------------------------------------------------------------- // Leaf components // --------------------------------------------------------------------------- /// User input display (active textarea or static text). pub struct InputContent { pub text: String, pub active: bool, } impl Component for InputContent { fn height(&self, width: u16) -> u16 { let w = width as usize; if self.active { let (lines, last_width) = word_wrap_line_count_with_last_width(&self.text, w); if last_width >= w { lines.saturating_add(1) } else { lines } } else { line_count_wrapped(&self.text, w) } } fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { if self.active { if let Some(textarea) = ctx.textarea { frame.render_widget(textarea, area); } } else { let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); frame.render_widget( Paragraph::new(self.text.as_str()) .style(style) .wrap(Wrap { trim: false }), area, ); } } } /// Command suggestion ($ prefix). pub struct CommandContent { pub text: String, pub faded: bool, } impl Component for CommandContent { fn height(&self, width: u16) -> u16 { line_count_wrapped(&self.text, width as usize) } fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { let mut style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); if self.faded { style = style.add_modifier(Modifier::DIM); } frame.render_widget( Paragraph::new(self.text.as_str()) .style(style) .wrap(Wrap { trim: false }), area, ); } } /// Markdown text content (indented, no symbol). pub struct TextContent { pub markdown: String, } impl Component for TextContent { fn height(&self, width: u16) -> u16 { // Height uses raw text — slightly overestimates since markdown syntax // characters (**, `) are stripped in rendering, but this is harmless // (allocates equal or more space than needed, never less). line_count_wrapped(&self.markdown, width as usize) } fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { let lines = style_inline_markdown(&self.markdown, ctx.theme); let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } } /// Error message (! prefix). pub struct ErrorContent { pub message: String, } impl Component for ErrorContent { fn height(&self, width: u16) -> u16 { line_count_wrapped(&self.message, width as usize) } fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); frame.render_widget( Paragraph::new(self.message.as_str()) .style(style) .wrap(Wrap { trim: false }), area, ); } } /// Warning for dangerous or low-confidence commands. pub struct WarningContent { pub kind: WarningKind, pub text: String, pub pending_confirm: bool, } impl Component for WarningContent { fn height(&self, width: u16) -> u16 { let display_text = if self.pending_confirm { "Press Enter again to run this dangerous command" } else { self.text.as_str() }; line_count_wrapped(display_text, width as usize) } fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); let display_text = if self.pending_confirm { "Press Enter again to run this dangerous command" } else { self.text.as_str() }; frame.render_widget( Paragraph::new(display_text) .style(style) .wrap(Wrap { trim: false }), area, ); } } /// Animated spinner with status text. pub struct SpinnerContent { pub status_text: String, } impl Component for SpinnerContent { fn height(&self, _width: u16) -> u16 { 1 } fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); frame.render_widget(Paragraph::new(self.status_text.as_str()).style(style), area); } } /// Tool call progress (in-flight spinner or completed checkmark). pub struct ToolStatusContent { pub completed_count: usize, pub current_label: Option, pub frame: usize, } impl Component for ToolStatusContent { fn height(&self, _width: u16) -> u16 { 1 } fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); let text = if let Some(ref label) = self.current_label { if self.completed_count > 0 { format!( "{} (used {} tool{})", label, self.completed_count, if self.completed_count == 1 { "" } else { "s" } ) } else { label.clone() } } else { format!( "Used {} tool{}", self.completed_count, if self.completed_count == 1 { "" } else { "s" } ) }; frame.render_widget(Paragraph::new(text).style(style), area); } } // --------------------------------------------------------------------------- // Factory functions // --------------------------------------------------------------------------- /// Convert a view model `Content` item into a `SymbolRow`-wrapped component. fn content_to_component(content: &Content) -> Box { match content { Content::Input { text, active, .. } => Box::new(SymbolRow { symbol: ">".to_string(), symbol_meaning: Meaning::Guidance, inner: Box::new(InputContent { text: text.clone(), active: *active, }), }), Content::Command { text, faded } => Box::new(SymbolRow { symbol: "$".to_string(), symbol_meaning: Meaning::Important, inner: Box::new(CommandContent { text: text.clone(), faded: *faded, }), }), Content::Text { markdown } => Box::new(SymbolRow { symbol: " ".to_string(), symbol_meaning: Meaning::Base, inner: Box::new(TextContent { markdown: markdown.clone(), }), }), Content::Error { message } => Box::new(SymbolRow { symbol: "!".to_string(), symbol_meaning: Meaning::AlertError, inner: Box::new(ErrorContent { message: message.clone(), }), }), Content::Warning { kind, text, pending_confirm, } => { let (symbol, meaning) = match kind { WarningKind::Danger => ("!", Meaning::AlertError), WarningKind::LowConfidence => ("?", Meaning::AlertWarn), }; Box::new(SymbolRow { symbol: symbol.to_string(), symbol_meaning: meaning, inner: Box::new(WarningContent { kind: *kind, text: text.clone(), pending_confirm: *pending_confirm, }), }) } Content::Spinner { frame, status_text } => Box::new(SymbolRow { symbol: active_frame(*frame).to_string(), symbol_meaning: Meaning::Annotation, inner: Box::new(SpinnerContent { status_text: status_text.clone(), }), }), Content::ToolStatus { completed_count, current_label, frame, } => { let symbol = if current_label.is_some() { active_frame(*frame).to_string() } else { "\u{2713}".to_string() // ✓ }; Box::new(SymbolRow { symbol, symbol_meaning: Meaning::Annotation, inner: Box::new(ToolStatusContent { completed_count: *completed_count, current_label: current_label.clone(), frame: *frame, }), }) } } } /// Convert a view model `Block` into a `VStack` of content components. fn build_block_component(block: &Block) -> Box { let mut children: Vec> = Vec::new(); for (idx, content) in block.content.iter().enumerate() { if idx > 0 { children.push(Box::new(Spacer(1))); // blank line between items } children.push(content_to_component(content)); } // Trailing blank line (padding after content) children.push(Box::new(Spacer(1))); Box::new(VStack::new(children)) } /// Build the full component tree from an ordered list of view model blocks. /// /// The tree is a `VStack` with blocks separated by `Separator` + `Spacer` pairs. /// The caller sets `scroll_offset` on the returned `VStack` before rendering. pub fn build_component_tree(items: &[&Block], card_width: u16) -> VStack { let mut children: Vec> = Vec::new(); for (idx, block) in items.iter().enumerate() { if idx > 0 { children.push(Box::new(Separator { card_width })); children.push(Box::new(Spacer(1))); // leading blank after separator } children.push(build_block_component(block)); } VStack::new(children) } ================================================ FILE: crates/atuin-ai/src/tui/event.rs ================================================ use crate::tui::App; use crossterm::event::{Event, EventStream, KeyEvent, KeyEventKind}; use eyre::{Result, eyre}; use futures::StreamExt; use std::time::Duration; use tokio::time; /// Base tick interval for the event loop (fast for responsive streaming) const BASE_TICK_INTERVAL: Duration = Duration::from_millis(50); /// Application events that drive the TUI state machine. /// /// # Event Types /// - `Key`: Keyboard input (filtered to KeyEventKind::Press only) /// - `Tick`: Periodic event for updates (50ms base interval) /// - `Resize`: Terminal window resize /// - `StreamChunk/StreamDone/StreamError`: Placeholders for Phase 3 streaming /// /// # Design Decisions /// - Fast 50ms base tick for responsive streaming; spinner timing handled in AppState /// - Stream events are placeholders - will be wired to channels in Phase 3 /// - Resize handling enables responsive layout adjustments #[derive(Debug, Clone)] pub enum AppEvent { /// Keyboard input event (filtered to Press events only) Key(KeyEvent), /// Periodic tick for updates (50ms base interval; spinner timing in AppState) Tick, /// Terminal resize event (width, height) Resize(u16, u16), /// Stream chunk received (Phase 3 placeholder) StreamChunk(String), /// Stream completed successfully (Phase 3 placeholder) StreamDone, /// Stream error occurred (Phase 3 placeholder) StreamError(String), } /// Async event loop that drives the TUI with prioritized event handling. /// /// # Priority Model (Biased Select) /// 1. **Stream data** - Highest priority (future Phase 3 streaming) /// 2. **Keyboard input** - Medium priority (user responsiveness) /// 3. **Tick events** - Lowest priority (spinner animation) /// /// This ensures stream data is processed immediately when available, /// keyboard input is responsive, and spinner updates don't block higher priority events. /// /// # Graceful Shutdown /// - SIGINT (Ctrl+C) sets shutdown flag and breaks the loop /// - EventStream close (stdin EOF) triggers shutdown /// - Shutdown flag can be checked/set externally for controlled termination /// /// # Example /// ```no_run /// use atuin_ai::tui::EventLoop; /// /// # async fn example() -> eyre::Result<()> { /// let mut event_loop = EventLoop::new(); /// loop { /// let event = event_loop.run().await?; /// // Handle event... /// # break; /// } /// # Ok(()) /// # } /// ``` pub struct EventLoop { /// Tick interval timer (created lazily on first run) tick_timer: Option, /// Flag indicating a render was requested (future use in Phase 2) #[allow(dead_code)] render_requested: bool, /// Shutdown flag - when true, event loop will terminate shutdown: bool, } impl EventLoop { /// Create a new EventLoop with default settings. /// /// # Defaults /// - Tick interval: 50ms base rate (spinner timing handled separately in AppState) /// - Render requested: false /// - Shutdown: false pub fn new() -> Self { Self { tick_timer: None, render_requested: false, shutdown: false, } } /// Run the event loop, returning the next application event. /// /// # Priority Model /// Uses `tokio::select!` with `biased;` mode to enforce priority: /// 1. Stream data (placeholder for Phase 3) /// 2. Keyboard input with rapid keypress batching /// 3. Tick for spinner animation /// /// # Keyboard Handling /// - Filters to KeyEventKind::Press on all platforms for safety /// - Batching of rapid keypresses will be implemented in Phase 2 /// - Currently returns individual key events /// /// # Graceful Shutdown /// - SIGINT (Ctrl+C) triggers shutdown and returns last event /// - EventStream close (stdin EOF) triggers shutdown /// - Shutdown flag can be checked after this returns /// /// # Errors /// - Returns error if terminal event stream encounters an error /// - EventStream close is handled gracefully as shutdown signal /// /// # Example /// ```no_run /// # use atuin_ai::tui::EventLoop; /// # async fn example() -> eyre::Result<()> { /// let mut event_loop = EventLoop::new(); /// while !event_loop.is_shutdown() { /// match event_loop.run().await? { /// // Handle events... /// # _ => break, /// } /// } /// # Ok(()) /// # } /// ``` pub async fn run(&mut self) -> Result { // Create async event stream for keyboard/terminal events let mut reader = EventStream::new(); // Get or create the tick timer (reused across calls to maintain timing) // Uses fast base tick for responsive streaming; spinner timing handled in AppState let tick_timer = self.tick_timer.get_or_insert_with(|| { let mut interval = time::interval(BASE_TICK_INTERVAL); // Skip the first immediate tick interval.reset(); interval }); loop { if self.shutdown { break; } // Biased select: prioritize stream > keyboard > tick let event = tokio::select! { biased; // Priority 1: Stream data (placeholder for Phase 3) // In Phase 3, this will be: // Some(chunk) = stream_rx.recv() => { ... } // Priority 2: Keyboard input maybe_event = reader.next() => { match maybe_event { Some(Ok(Event::Key(key))) => { // Filter to Press events only for cross-platform safety if key.kind == KeyEventKind::Press { // Note: Rapid keypress batching will be implemented in Phase 2 // when we integrate with the state machine. // For now, just return individual key events. Some(AppEvent::Key(key)) } else { None } } Some(Ok(Event::Resize(w, h))) => { Some(AppEvent::Resize(w, h)) } Some(Err(e)) => { return Err(eyre!("terminal event error: {}", e)); } None => { // EventStream closed (stdin EOF) - trigger shutdown self.shutdown = true; None } _ => { // Ignore other event types (mouse, focus, etc.) None } } } // Priority 3: Tick for spinner animation _ = tick_timer.tick() => { Some(AppEvent::Tick) } // SIGINT handling (Ctrl+C) - cross-platform _ = tokio::signal::ctrl_c() => { self.shutdown = true; // Return one more event to allow graceful shutdown handling Some(AppEvent::Tick) } }; if let Some(app_event) = event { return Ok(app_event); } } // Loop exited due to shutdown - return final tick to allow cleanup Ok(AppEvent::Tick) } /// Check if the event loop has been signaled to shut down. /// /// This can be used to cleanly exit the main TUI loop after receiving /// a shutdown signal (Ctrl+C, stdin close, etc.) pub fn is_shutdown(&self) -> bool { self.shutdown } /// Signal the event loop to shut down. /// /// The shutdown will take effect on the next iteration of `run()`. pub fn shutdown(&mut self) { self.shutdown = true; } /// Poll for next event and apply to app state. /// /// This is a convenience method that combines `run()` with `App` state updates. /// Returns true if app should continue, false if should exit. /// /// # Example /// ```no_run /// # use atuin_ai::tui::{EventLoop, App}; /// # async fn example() -> eyre::Result<()> { /// let mut event_loop = EventLoop::new(); /// let mut app = App::new(); /// /// while event_loop.poll_and_apply(&mut app).await? { /// // Render app state... /// } /// # Ok(()) /// # } /// ``` pub async fn poll_and_apply(&mut self, app: &mut App) -> Result { let event = self.run().await?; match event { AppEvent::Key(key) => { app.handle_key(key); } AppEvent::Tick => { app.state.tick(); } AppEvent::Resize(_, _) => { // Render will be triggered anyway } AppEvent::StreamChunk(_) | AppEvent::StreamDone | AppEvent::StreamError(_) => { // Placeholder for Phase 3 } } Ok(!app.state.should_exit) } } impl Default for EventLoop { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_event_loop_creation() { let event_loop = EventLoop::new(); assert!(!event_loop.shutdown); } #[test] fn test_shutdown_flag() { let mut event_loop = EventLoop::new(); assert!(!event_loop.is_shutdown()); event_loop.shutdown(); assert!(event_loop.is_shutdown()); } // Note: Cannot easily test run() in unit tests since it requires a TTY. // Integration tests should verify: // 1. Tick events are generated at 150ms intervals // 2. Keyboard events are properly filtered to Press only // 3. Rapid keypresses are batched // 4. SIGINT triggers graceful shutdown // 5. Resize events are propagated correctly } ================================================ FILE: crates/atuin-ai/src/tui/mod.rs ================================================ pub mod app; pub mod component; pub mod components; pub mod event; #[cfg(unix)] pub mod popup; pub mod render; pub mod spinner; pub mod state; pub mod terminal; pub mod view_model; pub use app::App; pub use event::{AppEvent, EventLoop}; pub use render::{RenderContext, calculate_needed_height, markdown_to_spans}; pub use state::{AppMode, AppState, ConversationEvent, ExitAction}; pub use terminal::{TerminalGuard, install_panic_hook}; pub use view_model::{Block, Blocks, Content}; ================================================ FILE: crates/atuin-ai/src/tui/popup.rs ================================================ use ratatui::layout::Rect; /// Maximum popup height (lines). Keeps context visible around the popup. const MAX_POPUP_HEIGHT: u16 = 24; /// Minimum usable popup height. const MIN_POPUP_HEIGHT: u16 = 5; /// Initial popup height — just enough for input + a small response. const INITIAL_POPUP_HEIGHT: u16 = 5; /// Margin around the card in popup mode. pub(crate) const POPUP_MARGIN: u16 = 0; /// Screen state captured from atuin-hex's screen server. pub struct SavedScreen { #[allow(dead_code)] pub rows: u16, #[allow(dead_code)] pub cols: u16, pub cursor_row: u16, pub cursor_col: u16, /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout. pub rows_data: Vec>, } /// Popup mode state: saved screen + computed placement. pub struct PopupState { pub saved_screen: SavedScreen, /// Maximum rect computed from placement (the ceiling for growth). pub max_rect: Rect, /// Current rect — starts small, grows as content arrives. pub current_rect: Rect, pub scroll_offset: u16, /// True when the popup renders above the cursor (input at bottom of card). pub render_above: bool, } impl PopupState { /// Resize the popup to fit `needed` lines of content. /// /// Grows or shrinks the popup as needed (clamped to max_rect / INITIAL_POPUP_HEIGHT). /// When growing, clears the new rect area. When shrinking, restores freed rows /// from the saved screen data. /// /// Returns `Some(new_rect)` if the size changed (caller must resize terminal), /// or `None` if no change is needed. pub fn fit_to(&mut self, needed: u16) -> Option { let new_height = needed.clamp(INITIAL_POPUP_HEIGHT, self.max_rect.height); if new_height == self.current_rect.height { return None; } let old_rect = self.current_rect; let growing = new_height > old_rect.height; if self.render_above { let new_y = self.max_rect.y + self.max_rect.height - new_height; self.current_rect = Rect::new(old_rect.x, new_y, old_rect.width, new_height); } else { self.current_rect = Rect::new(old_rect.x, old_rect.y, old_rect.width, new_height); } if growing { // Clear the entire new rect so the new Terminal doesn't leave // ghost content from the old card. self.clear_rows( self.current_rect.y, self.current_rect.y + self.current_rect.height, ); } else { // Shrinking: restore freed rows from saved screen data, then // clear the new (smaller) rect for the re-rendered card. self.restore_rows(&old_rect); self.clear_rows( self.current_rect.y, self.current_rect.y + self.current_rect.height, ); } Some(self.current_rect) } /// Clear a range of terminal rows within the popup width. fn clear_rows(&self, from_row: u16, to_row: u16) { use crossterm::cursor::MoveTo; use crossterm::execute; use crossterm::style::{Attribute, SetAttribute}; use std::io::{Write, stdout}; let mut out = stdout(); for row in from_row..to_row { let _ = execute!( out, MoveTo(self.current_rect.x, row), SetAttribute(Attribute::Reset) ); let _ = write!( out, "{:width$}", "", width = self.current_rect.width as usize ); } let _ = out.flush(); } /// Restore rows that were freed by shrinking — the rows in old_rect /// that are no longer covered by current_rect. fn restore_rows(&self, old_rect: &Rect) { use crossterm::cursor::MoveTo; use crossterm::execute; use crossterm::style::{Attribute, SetAttribute}; use std::io::{Write, stdout}; let mut out = stdout(); // Determine which rows are freed let (freed_start, freed_end) = if self.render_above { // Shrinking from above: freed rows are at the old top (old_rect.y, self.current_rect.y) } else { // Shrinking from below: freed rows are at the old bottom ( self.current_rect.y + self.current_rect.height, old_rect.y + old_rect.height, ) }; for row in freed_start..freed_end { let source_row = (row + self.scroll_offset) as usize; // Clear the popup region let _ = execute!(out, MoveTo(old_rect.x, row), SetAttribute(Attribute::Reset),); let _ = write!(out, "{:width$}", "", width = old_rect.width as usize); // Write back saved row data from column 0 let _ = execute!(out, MoveTo(0, row)); if let Some(row_bytes) = self.saved_screen.rows_data.get(source_row) { let _ = out.write_all(row_bytes); } } let _ = out.flush(); } } /// Try to set up popup overlay mode. /// /// Checks for `ATUIN_HEX_SOCKET`, fetches screen state, computes placement, /// and scrolls the terminal if needed. Returns `None` if popup mode is not /// available (no socket, fetch failed, etc.), in which case the caller should /// fall back to inline mode. pub fn try_setup_popup() -> Option { use std::io::Write; let socket_path = std::env::var("ATUIN_HEX_SOCKET").ok()?; let saved = fetch_screen_state(&socket_path)?; let (term_cols, term_rows) = crossterm::terminal::size().unwrap_or((saved.cols, saved.rows)); // Full-width popup with margin for visual separation let popup_width = term_cols; let (rect, scroll, render_above) = compute_popup_placement( saved.cursor_row, saved.cursor_col, term_rows, term_cols, popup_width, ); // Scroll terminal up if needed to make room for the popup if scroll > 0 { let mut stdout = std::io::stdout(); let _ = crossterm::execute!(stdout, crossterm::cursor::MoveTo(0, term_rows - 1)); for _ in 0..scroll { let _ = writeln!(stdout); } let _ = stdout.flush(); } // Start with a small rect that grows as content arrives let initial_height = INITIAL_POPUP_HEIGHT.min(rect.height); let current_rect = if render_above { // Anchor at the bottom of max_rect (near cursor), grow upward Rect::new( rect.x, rect.y + rect.height - initial_height, rect.width, initial_height, ) } else { // Anchor at the top of max_rect (near cursor), grow downward Rect::new(rect.x, rect.y, rect.width, initial_height) }; Some(PopupState { saved_screen: saved, max_rect: rect, current_rect, scroll_offset: scroll, render_above, }) } /// Restore the screen area that was covered by the popup. /// /// Clears the popup region, then writes pre-formatted per-row ANSI bytes from /// column 0 to correctly restore wide characters, colors, and all attributes. pub fn restore(state: &PopupState) { use crossterm::cursor::MoveTo; use crossterm::execute; use crossterm::style::{Attribute, SetAttribute}; use std::io::{Write, stdout}; let saved = &state.saved_screen; let popup_rect = state.current_rect; let scroll_offset = state.scroll_offset; let mut stdout = stdout(); for dy in 0..popup_rect.height { let target_row = popup_rect.y + dy; let source_row = (target_row + scroll_offset) as usize; // Clear only the popup region with spaces let _ = execute!( stdout, MoveTo(popup_rect.x, target_row), SetAttribute(Attribute::Reset), ); let _ = write!(stdout, "{:width$}", "", width = popup_rect.width as usize); // Write back full row ANSI data from column 0 let _ = execute!(stdout, MoveTo(0, target_row)); if let Some(row_bytes) = saved.rows_data.get(source_row) { let _ = stdout.write_all(row_bytes); } } // Restore cursor position (adjusted for any scrolling) let _ = execute!( stdout, MoveTo( saved.cursor_col, saved.cursor_row.saturating_sub(scroll_offset) ) ); let _ = stdout.flush(); } /// Connect to atuin-hex's Unix socket and fetch the current screen state. /// /// The wire format is: /// ```text /// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE] /// [row_0_len: u32 BE][row_0_bytes...] /// [row_1_len: u32 BE][row_1_bytes...] /// ... /// ``` fn fetch_screen_state(socket_path: &str) -> Option { use std::io::Read; use std::os::unix::net::UnixStream; use std::time::Duration; let mut stream = UnixStream::connect(socket_path).ok()?; stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; let mut data = Vec::new(); stream.read_to_end(&mut data).ok()?; if data.len() < 8 { return None; } let rows = u16::from_be_bytes([data[0], data[1]]); let cols = u16::from_be_bytes([data[2], data[3]]); let cursor_row = u16::from_be_bytes([data[4], data[5]]); let cursor_col = u16::from_be_bytes([data[6], data[7]]); let mut rows_data = Vec::with_capacity(rows as usize); let mut offset = 8; while offset + 4 <= data.len() { let row_len = u32::from_be_bytes([ data[offset], data[offset + 1], data[offset + 2], data[offset + 3], ]) as usize; offset += 4; if offset + row_len > data.len() { break; } rows_data.push(data[offset..offset + row_len].to_vec()); offset += row_len; } Some(SavedScreen { rows, cols, cursor_row, cursor_col, rows_data, }) } /// Compute popup placement for the AI card. /// /// Positions the popup near the cursor: below if there's room, above otherwise. /// Uses a capped height (MAX_POPUP_HEIGHT) so the popup doesn't fill the screen. /// /// Returns `(popup_rect, scroll_offset, render_above)`: /// - `render_above`: true when popup is above cursor (input should be at bottom) /// - `scroll_offset`: lines the caller should scroll the terminal up fn compute_popup_placement( cursor_row: u16, cursor_col: u16, term_rows: u16, term_cols: u16, card_width: u16, ) -> (Rect, u16, bool) { // Horizontal: anchor card near cursor, clamp to screen let popup_w = card_width.min(term_cols); let preferred_x = cursor_col.saturating_sub(2); let max_x = term_cols.saturating_sub(popup_w); let popup_x = preferred_x.min(max_x); // Vertical: use a reasonable height, not the full terminal let max_h = MAX_POPUP_HEIGHT .min(term_rows.saturating_sub(2)) .max(MIN_POPUP_HEIGHT); let space_above = cursor_row; let space_below = term_rows.saturating_sub(cursor_row); if max_h <= space_below { // Fits below cursor — input at top (close to prompt) let popup_y = cursor_row; (Rect::new(popup_x, popup_y, popup_w, max_h), 0, false) } else if max_h <= space_above { // Fits above cursor — input at bottom (close to prompt) let popup_y = cursor_row.saturating_sub(max_h); (Rect::new(popup_x, popup_y, popup_w, max_h), 0, true) } else { // Neither side fits fully — use whichever side has more space, // scrolling the terminal if needed to reach MIN_POPUP_HEIGHT. let render_above = space_above > space_below; let available = if render_above { space_above } else { space_below }; let h = available.max(MIN_POPUP_HEIGHT).min(max_h); let scroll = h.saturating_sub(available); let popup_y = if render_above { cursor_row.saturating_sub(h + scroll) } else { cursor_row.saturating_sub(scroll) }; ( Rect::new(popup_x, popup_y, popup_w, h), scroll, render_above, ) } } ================================================ FILE: crates/atuin-ai/src/tui/render.rs ================================================ use atuin_client::theme::{Meaning, Theme}; use pulldown_cmark::{Event, Parser, Tag, TagEnd}; use ratatui::{ Frame, backend::FromCrossterm, layout::{Alignment, Rect}, style::{Modifier, Style}, text::{Line, Span}, widgets::{Block as RatatuiBlock, Borders, Padding}, }; use super::component::Component; pub use super::component::RenderContext; use super::components::build_component_tree; use super::spinner::active_frame; use super::state::AppState; use super::view_model::Blocks; /// Fixed card width for the TUI pub(crate) const CARD_WIDTH: u16 = 64; /// Calculate the height needed to render the current state. /// Used to dynamically resize the viewport before rendering. /// `card_width` is the outer card width (including borders); pass 0 to use CARD_WIDTH default. pub fn calculate_needed_height(state: &AppState, card_width: u16) -> u16 { let view = Blocks::from_state(state); let w = if card_width > 0 { card_width } else { CARD_WIDTH }; let content_width = w.saturating_sub(4).max(1); let items: Vec<_> = view.items.iter().collect(); let tree = build_component_tree(&items, w); // Add borders (2) + top padding (1), minimum 5 tree.height(content_width).saturating_add(3).max(5) } /// Main render function: derives view model from state, then renders it pub fn render(frame: &mut Frame, state: &AppState, ctx: &RenderContext) { // PURE DERIVATION: view model is always rebuilt from state let view = Blocks::from_state(state); // Render the derived view model render_view(frame, &view, ctx); } fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { let full_area = frame.area(); // In popup mode, the viewport is already positioned and sized for the card. // Clear it to prevent background bleed-through, then inset by margin for the card. let (area, card_x, desired_width) = if ctx.popup_mode { #[cfg(unix)] use super::popup::POPUP_MARGIN; #[cfg(not(unix))] const POPUP_MARGIN: u16 = 0; frame.render_widget(ratatui::widgets::Clear, full_area); let inset = full_area.inner(ratatui::layout::Margin { horizontal: POPUP_MARGIN, vertical: POPUP_MARGIN, }); (inset, inset.x, inset.width) } else { let dw = CARD_WIDTH.min(full_area.width.saturating_sub(2)).max(32); let max_x = full_area.x + full_area.width.saturating_sub(dw); let preferred_x = full_area.x + ctx.anchor_col.saturating_sub(2); (full_area, preferred_x.min(max_x), dw) }; // Build ordered items list — the active content (input/LLM response) // should always be closest to the cursor/prompt: // - Popup below cursor (render_above=false): reverse so active is at top // - Popup above cursor (render_above=true): normal order, active is at bottom // - Inline mode: normal order (no reversal) let items: Vec<&super::view_model::Block> = if ctx.popup_mode && !ctx.render_above { view.items.iter().rev().collect() } else { view.items.iter().collect() }; // Build component tree from view model let mut tree = build_component_tree(&items, desired_width); let content_width = desired_width.saturating_sub(4).max(1); let desired_height = tree.height(content_width).saturating_add(3).max(5); // Cap card height at viewport height to prevent overflow let actual_height = desired_height.min(area.height); // Calculate scroll offset to keep the active content visible when overflowing. // When render_above=false (popup below cursor), items are reversed so the active // content (input/spinner) is at the top — scroll_offset stays 0 to show the top. // Otherwise, scroll to show the bottom where the active content lives. tree.scroll_offset = if ctx.popup_mode && !ctx.render_above { 0 } else { desired_height.saturating_sub(actual_height) }; let card = Rect { x: card_x, y: area.y, width: desired_width, height: actual_height, }; // Get title from first block in ORIGINAL order (always the input block) let title = view .items .first() .and_then(|b| b.title.as_deref()) .unwrap_or("Describe the command you'd like to generate:"); // Create bordered frame // Padding: left=1, right=1, top=1, bottom=0 (blocks have trailing blanks) let mut outer_block = RatatuiBlock::default() .borders(Borders::ALL) .title(title) .title_top(Line::from("atuin").alignment(Alignment::Right)) .title_bottom(Line::from(view.footer).alignment(Alignment::Right)) .padding(Padding::new(1, 1, 1, 0)); // Status bar: transient status on the bottom border, left-aligned if let Some(ref sb) = view.status_bar { let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); let spinner = active_frame(sb.frame); let status_text = format!(" {} {} ", spinner, sb.text); outer_block = outer_block .title_bottom(Line::from(Span::styled(status_text, style)).alignment(Alignment::Left)); } let inner_area = outer_block.inner(card); frame.render_widget(outer_block, card); // Render the component tree tree.render(frame, inner_area, ctx); } /// Convert markdown to styled spans pub fn markdown_to_spans<'a>(text: &'a str, theme: &'a Theme) -> Vec> { let parser = Parser::new(text); let mut lines: Vec>> = vec![Vec::new()]; let mut current_line = 0; let base_style = Style::from_crossterm(theme.as_style(Meaning::Base)); let code_style = Style::from_crossterm(theme.as_style(Meaning::Important)); let mut style_stack: Vec