Skip to content

feat(hook): let programs that run commands keep the terminal - #53

Merged
NaNIsaac merged 4 commits into
mainfrom
feat/hook-runners
Oct 28, 2026
Merged

NaNIsaac merged 4 commits into
mainfrom
feat/hook-runners

Conversation

@NaNIsaac

@NaNIsaac NaNIsaac commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

A program that runs command lines for you, such as hf from hanfix (it re-runs the previous command typed with the Korean layout on), was captured as a whole: the hook wrapped the hf line, so the command hf ran got the capture pipe instead of the terminal. An interactive one then fails, for example codex resume --last with "stdout is not a terminal". This adds a small contract so such programs keep the terminal and what they run is still captured by the hook's own rules.

Contract

  • CODIGEST_RUNNERS: a runner appends its command names (PowerShell $global:CODIGEST_RUNNERS += 'hf'; bash/zsh CODIGEST_RUNNERS="${CODIGEST_RUNNERS-} hf"). The hook does not capture a line that starts with one of them. It is only read when Enter is pressed, so it does not matter whether the runner or codigest loads first.
  • __cdg_run <line>: defined by both hooks. It applies the same checks as for a typed line (skip list, CODIGEST_CAP_SKIP, CODIGEST_NOCAP, runners) and runs the line captured or plainly; plainly as well while a capture is already running. A runner looks it up when it runs a line, and uses Invoke-Expression / eval when it is not there. PowerShell callers dot-source it (. __cdg_run $line) so the line runs in their scope, as Invoke-Expression does.

Both are in the README (English and Korean) under "Programs that run other commands", with the thefuck line: fuck can be listed in CODIGEST_RUNNERS, so what it runs keeps the terminal and is not captured. hanfix will use the contract in its next release; with an older hanfix, or an older codigest, the behaviour is the same as now.

Other changes

  • PowerShell: the hook no longer replaces a PSConsoleHostReadLine that someone else defined. One defined before it (atuin) is called for the line; one defined after it (VS Code's shell integration, which saves the current function and wraps it) is detected on its first call, and the hook puts itself back on top around it. That other function then sees the line as typed: VS Code's command detection (OSC 633;E) used to get . __cdg { <line> }. A program loaded later that never calls the function it replaced (atuin reads the line itself) used to stop the capture for good; a PowerShell.OnIdle subscription (-SupportEvent, so not in Get-Job) puts the hook back on top around it while the shell waits for input. The first line after such a program loads is not captured. Loading the hook again (. $PROFILE) does not chain it to itself (marker comment codigest-readline).
  • PowerShell: the engine history kept the wrapped line in an interactive session, because __cdg's local $__cdgLine was the global one once dot-sourced at the prompt, and its cleanup removed it. Fixed in its own commit, fixes PowerShell hook: engine history keeps the wrapped line in an interactive session #51.
  • bash: VS Code's shell integration loaded after the hook stops the capture (bash hook: nothing is captured with VS Code's shell integration loaded after it #52, a README limitation). Not fixed here: the Enter handler (bind -x) triggers the DEBUG trap, and VS Code evaluates the trap it chained only for that first event. The test for it is marked xfail(strict=True).
  • CI installs zsh, so the zsh cases run on Linux.

Tests (tests/test_hook_terminal.py)

Each test types lines into an interactive shell on a pseudo-terminal (stdlib pty on Linux and macOS; ConPTY via pywinpty on Windows, skipped when pywinpty is missing). A probe program records whether its stdout is a terminal, and a fake cdg records what cdg term ingest gets.

  • pwsh and bash/zsh, hook loaded before and after the runner: a captured line gets a pipe; the runner's own line keeps the terminal; through __cdg_run, a line on the skip list keeps the terminal and is not captured, another one gets a pipe and is captured under that line with its output.
  • pwsh: PSReadLine's history and the engine history keep the lines as typed, once each, with -PredictionSource History on and no other read-line present.
  • pwsh: the hook loaded twice captures each line once and chains nothing.
  • pwsh: a PSConsoleHostReadLine defined after the hook that reads the line itself (as atuin's does) is called by the hook from the next prompt on; lines are captured again and the histories stay as typed. Fails without the OnIdle subscription.
  • The pty on Linux and macOS answers the cursor position query PSReadLine sends (ConPTY answers it itself), so the pwsh cases run there too.

Other terminal programs (PowerShell, Windows, not in the suite)

Run with each program's release build in a scratch folder, all of its state pinned there, in both load orders (program then hook, hook then program). In all 8 cases a captured line gets a pipe, the runner and a skipped line keep the terminal, PSReadLine and engine history keep the lines as typed, and the program still does its job:

Program Version Program then hook Hook then program
atuin 18.24.2 ok, atuin records the lines as typed ok (before the OnIdle change: nothing captured)
starship 1.27.0 ok ok
oh-my-posh 31.9.1 ok ok
zoxide 0.10.0 ok, zoxide query finds the folder ok

bash-preexec 0.6.0 with the bash hook: ok in both orders (Git Bash).

  • pwsh with VS Code's integration script loaded after the hook: VS Code reports the typed line and the line is still captured. With the hook from main this test fails (VS Code gets . __cdg { wrapped }).
  • bash: after a __cdg_run capture started inside a function, jobs is empty and the next lines run (the process-substitution question from the __cdg_open comment).

The PowerShell tests set the PSReadLine history path to the test folder before the first prompt, so they neither read nor write the user's history.

local-ci: f96ead6 | pytest 296 passed, 1 skipped (rest of the suite, at fd48fc7; later commits touch only the hook and its terminal tests); terminal tests on Windows with ConPTY: pwsh 5 passed, Git Bash 5.3 2 passed + 1 xfailed (#52), zsh skipped (not installed); new atuin-style test fails with the OnIdle block disabled; matrix above; ruff 0.17.1 check and format; actionlint ci.yml; bash -n hook.sh; conventions.py check: 0 findings | zsh and pwsh on Linux and the Rust tests only in CI; real ssh, macOS terminals, atuin/starship/oh-my-posh/zoxide with bash or zsh not run

__cdg is dot-sourced into the global scope at the prompt, so its local $__cdgLine was the global one and its cleanup removed it before __cdgRestoreHistory could read it. Fixes #51.
…T_RUNNERS, __cdg_run)

A runner such as hanfix's hf lists its names in CODIGEST_RUNNERS, so its own line is not captured, and hands each command line it runs to __cdg_run, which applies the hook's rules to that line. The PowerShell hook also calls a PSConsoleHostReadLine defined before or after it (VS Code's shell integration, atuin) instead of replacing it. Refs #52.
@NaNIsaac
NaNIsaac merged commit 9c4cf56 into main Oct 28, 2026
15 checks passed
@NaNIsaac
NaNIsaac deleted the feat/hook-runners branch October 28, 2026 14:21
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.

PowerShell hook: engine history keeps the wrapped line in an interactive session

1 participant