Skip to content

feat(interactive): iterate on zsh-style hooks - #1363

Draft
reubeno wants to merge 1 commit into
mainfrom
preexec
Draft

reubeno wants to merge 1 commit into
mainfrom
preexec

Conversation

@reubeno

@reubeno reubeno commented Sep 10, 2026

Copy link
Copy Markdown
Owner

The existing run_cmd_exec_funcs support called whatever precmd_functions and preexec_functions happened to contain, with no setup and no status handling. Make it a real implementation of that contract, in a brush-interactive::zsh_hooks module of its own.

init_zsh_style_hooks seeds the two registries and claims the bash_preexec_imported/__bp_imported inclusion guards before profile and rc files load, so a copy of bash-preexec sourced later finds itself "already loaded" and leaves the hooks to us. The guards aren't exported, so a child bash still gets the real implementation. Calling it, and calling it before config files load, is a documented requirement of the feature.

Dispatch honors the rest: precmd runs ahead of PROMPT_COMMAND; every hook sees the last command's $?, PIPESTATUS, and $_, restored afterwards so neither PROMPT_COMMAND nor the next command sees a hook's leavings; a failing hook is reported and the rest still run; a hook that calls exit exits the shell. preexec fires only for a line that runs a command, matching the DEBUG trap it stands in for, and receives the line as typed.

Terminal integration now emits its OSC 633 command markers as a pair from the one path that runs the command. A line a preexec hook exited out of never runs and gets neither marker; a bind -x command does run and now gets both, where it previously emitted a lone D. The integration utility takes on the writing itself and is no longer optional: a shell without integration holds one that reports no capabilities, so every event reported to it does nothing.

The atuin e2e adapter turns the feature on, since atuin init bash inlines a copy of bash-preexec. Contract documented in docs/reference/zsh-hooks.md.

BREAKING CHANGE: InteractiveOptions::run_cmd_exec_funcs is renamed to zsh_style_hooks, and embedders must now call init_zsh_style_hooks before loading profile and rc files. Only shell functions are dispatched, matching zsh. Because a sourced bash-preexec now stands down rather than installing itself, the BP_PIPESTATUS copy it used to provide is gone; hooks read the real PIPESTATUS, which brush restores for them.

Assisted-By: Claude Opus 5

The existing `run_cmd_exec_funcs` support called whatever `precmd_functions`
and `preexec_functions` happened to contain, with no setup and no status
handling. Make it a real implementation of that contract, in a
`brush-interactive::zsh_hooks` module of its own.

`init_zsh_style_hooks` seeds the two registries and claims the
`bash_preexec_imported`/`__bp_imported` inclusion guards before profile and rc
files load, so a copy of bash-preexec sourced later finds itself "already
loaded" and leaves the hooks to us. The guards aren't exported, so a child bash
still gets the real implementation. Calling it, and calling it before config
files load, is a documented requirement of the feature.

Dispatch honors the rest: `precmd` runs ahead of `PROMPT_COMMAND`; every hook
sees the last command's `$?`, `PIPESTATUS`, and `$_`, restored afterwards so
neither `PROMPT_COMMAND` nor the next command sees a hook's leavings; a failing
hook is reported and the rest still run; a hook that calls `exit` exits the
shell. `preexec` fires only for a line that runs a command, matching the `DEBUG`
trap it stands in for, and receives the line as typed.

Terminal integration now emits its OSC 633 command markers as a pair from the
one path that runs the command. A line a `preexec` hook exited out of never runs
and gets neither marker; a `bind -x` command does run and now gets both, where
it previously emitted a lone `D`. The integration utility takes on the writing
itself and is no longer optional: a shell without integration holds one that
reports no capabilities, so every event reported to it does nothing.

The atuin e2e adapter turns the feature on, since `atuin init bash` inlines a
copy of bash-preexec. Contract documented in docs/reference/zsh-hooks.md.

BREAKING CHANGE: `InteractiveOptions::run_cmd_exec_funcs` is renamed to
`zsh_style_hooks`, and embedders must now call `init_zsh_style_hooks` before
loading profile and rc files. Only shell functions are dispatched, matching zsh.
Because a sourced bash-preexec now stands down rather than installing itself,
the `BP_PIPESTATUS` copy it used to provide is gone; hooks read the real
`PIPESTATUS`, which brush restores for them.

Assisted-By: Claude Opus 5

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Direct hook invocation misses errexit handling for hooks that explicitly return a nonzero status.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Implements native zsh-style hooks and integrates them with interactive execution, terminal markers, documentation, and atuin testing.

Changes:

  • Initializes and dispatches precmd/preexec hooks with preserved command state.
  • Pairs terminal command markers across typed and bound commands.
  • Documents and comprehensively tests the hook contract and breaking API rename.
File summaries
File Description
e2e/README.md Documents config-based feature activation.
e2e/atuin/entrypoint.sh Enables native hooks for atuin tests.
docs/reference/zsh-hooks.md Defines the hook contract.
docs/reference/README.md Links the new reference.
docs/reference/experimental.md Expands experimental-feature guidance.
brush-shell/tests/reedline_interactive_tests.rs Tests bound-command hooks and markers.
brush-shell/tests/interactive_tests.rs Tests initialization and marker behavior.
brush-shell/tests/cases/brush/zsh_hooks.yaml Adds compatibility coverage.
brush-shell/src/entry.rs Initializes hooks before startup files.
brush-interactive/src/zsh_hooks.rs Implements hook initialization and dispatch.
brush-interactive/src/term_integration.rs Encapsulates terminal event output.
brush-interactive/src/options.rs Maps the renamed option.
brush-interactive/src/lib.rs Exports hook initialization.
brush-interactive/src/interactive_shell.rs Integrates hooks and paired markers.
Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +212 to +224
match shell
.invoke_function(&hook_name, arg, shell.default_exec_params())
.await
{
// Returned without restoring: the shell is exiting, and `$?` belongs to the hook
// that exited it.
Ok(result) if result.is_exit() => return Ok(ControlFlow::Break(result)),
Ok(_) => {}
Err(e) => {
let mut stderr = shell.stderr();
let _ = shell.display_error(&mut stderr, &e);
}
}
@github-actions

Copy link
Copy Markdown

Test Results

    5 files     49 suites   23m 47s ⏱️
2 988 tests 2 978 ✅ 0 💤 10 ❌
9 330 runs  9 319 ✅ 0 💤 11 ❌

For more details on these failures, see this check.

Results for commit 59306f2.

@github-actions

Copy link
Copy Markdown

Public API changes for crate: brush-interactive

Removed items

-pub brush_interactive::InteractiveOptions::run_cmd_exec_funcs: bool

Added items

+pub brush_interactive::InteractiveOptions::zsh_style_hooks: bool
+pub fn brush_interactive::init_zsh_style_hooks<SE: brush_core::extensions::ShellExtensions>(&mut brush_core::shell::Shell<SE>, &brush_interactive::InteractiveOptions) -> core::result::Result<(), brush_interactive::ShellError>

Performance Benchmark Report

Benchmark name Baseline (μs) Test/PR (μs) Delta (μs) Delta %
clone_shell_object 17.61 μs 17.67 μs 0.06 μs ⚪ Unchanged
eval_arithmetic 0.15 μs 0.15 μs -0.00 μs ⚪ Unchanged
expand_one_string 1.77 μs 1.77 μs 0.00 μs ⚪ Unchanged
for_loop 31.74 μs 31.57 μs -0.17 μs ⚪ Unchanged
full_peg_complex 59.45 μs 59.57 μs 0.12 μs ⚪ Unchanged
full_peg_for_loop 6.33 μs 6.33 μs -0.00 μs ⚪ Unchanged
full_peg_nested_expansions 16.42 μs 16.30 μs -0.12 μs 🟢 -0.70%
full_peg_pipeline 4.33 μs 4.39 μs 0.06 μs ⚪ Unchanged
full_peg_simple 1.88 μs 1.86 μs -0.02 μs 🟢 -1.17%
function_call 3.62 μs 3.39 μs -0.22 μs ⚪ Unchanged
instantiate_shell 55.22 μs 54.62 μs -0.60 μs ⚪ Unchanged
instantiate_shell_with_init_scripts 27030.02 μs 25802.53 μs -1227.49 μs 🟢 -4.54%
parse_peg_bash_completion 2130.11 μs 2126.46 μs -3.66 μs ⚪ Unchanged
parse_peg_complex 19.78 μs 19.93 μs 0.15 μs ⚪ Unchanged
parse_peg_for_loop 2.11 μs 2.06 μs -0.04 μs 🟢 -2.04%
parse_peg_pipeline 2.21 μs 2.20 μs -0.01 μs ⚪ Unchanged
parse_peg_simple 1.16 μs 1.17 μs 0.01 μs ⚪ Unchanged
run_echo_builtin_command 16.89 μs 16.59 μs -0.30 μs ⚪ Unchanged
tokenize_sample_script 3.44 μs 3.76 μs 0.32 μs 🟠 +9.21%

Code Coverage Report: Only Changed Files listed

Package Base Coverage New Coverage Difference
brush-core/src/error.rs 🟢 90.91% 🟢 94.81% 🟢 3.9%
brush-interactive/src/interactive_shell.rs 🟠 68.51% 🟢 88.24% 🟢 19.73%
brush-interactive/src/term_integration.rs 🔴 46.02% 🟢 81.82% 🟢 35.8%
brush-interactive/src/zsh_hooks.rs 🔴 0% 🟢 100% 🟢 100%
brush-shell/src/entry.rs 🟢 92.74% 🟢 92.59% 🔴 -0.15%
brush-test-harness/src/runner.rs 🟢 84.18% 🟢 85.6% 🟢 1.42%
Overall Coverage 🟢 77.31% 🟢 77.82% 🟢 0.51%

Minimum allowed coverage is 70%, this run produced 77.82%
Maximum allowed coverage difference is -5%, this run produced 0.51%

Test Summary: bash-completion test suite

Outcome Count Percentage
✅ Pass 1588 75.30
❗️ Error 17 0.81
❌ Fail 150 7.11
⏩ Skip 339 16.07
❎ Expected Fail 13 0.62
✔️ Unexpected Pass 2 0.09
📊 Total 2109 100.00

@reubeno
reubeno added this pull request to stack #1366 September 10, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants