Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/codigest/shell/hook.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
#
# The line you type is run as `. __cdg { <line> }`. The wrapping happens in
# PSConsoleHostReadLine, after PSReadLine has returned the line, so the screen and
# the history keep the line as typed. Dot-sourcing runs it in the caller's scope,
# the PSReadLine history keep the line as typed; the engine history entry is put back
# before the next prompt. Dot-sourcing runs it in the caller's scope,
# so `cd`, variable assignments and function definitions behave exactly as typed.
# Output is teed into a buffer and handed to `cdg term ingest` in a background
# process (redaction happens there, before anything touches disk).
Expand Down Expand Up @@ -130,13 +131,31 @@ $global:__cdgSkip = '^\s*(vim?|nvim|nano|emacs|less|more|ssh|htop|btop|top|fzf|l
'|^\s*(gh\s+auth\s+login|az\s+login|gcloud\s+(auth\s+login|init)|aws\s+configure|ssh-keygen|ssh-copy-id|sudo|runas)\b' +
'|^\s*(mysql|psql|sqlite3|redis-cli|mongosh|duckdb|julia|irb|ghci|ollama\s+run)(\.exe)?\b'

# The engine history (`history`, `r`) records the line handed to the engine, the wrapped
# one. Before the next prompt the last entry is put back as typed, with its status and
# times; its new entry takes the next id, so ids skip one after each captured line.
function global:__cdgRestoreHistory {
$line = $global:__cdgLine
$global:__cdgLine = $null
$last = Get-History -Count 1
if (-not $line -or -not $last -or -not $last.CommandLine.StartsWith('. __cdg { ')) { return }
Clear-History -Id $last.Id
[pscustomobject]@{
CommandLine = $line
ExecutionStatus = $last.ExecutionStatus
StartExecutionTime = $last.StartExecutionTime
EndExecutionTime = $last.EndExecutionTime
} | Add-History
}

# Replaces PSReadLine's own PSConsoleHostReadLine, which the host calls for every prompt.
# PSReadLine draws and stores the line as typed; only the string returned to the engine
# is wrapped, so nothing changes on screen.
function global:PSConsoleHostReadLine {
# First statement: any script run resets $?, and PSReadLine shows it in the prompt.
$__cdgOk = $?
Microsoft.PowerShell.Core\Set-StrictMode -Off
__cdgRestoreHistory
$line = try { [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext, $__cdgOk) }
catch [Management.Automation.MethodException] { # PSReadLine < 2.2 has no status argument
[Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext) }
Expand Down
39 changes: 39 additions & 0 deletions tests/test_hook_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import re
import shutil
import subprocess
from pathlib import Path

import pytest

HOOK = Path(__file__).parents[1] / "src" / "codigest" / "shell" / "hook.ps1"

# Runs the hook's history restore on a wrapped entry, then on a plain one.
SCRIPT = r"""
$t = Get-Date
[pscustomobject]@{ CommandLine = '. __cdg { git status }'; ExecutionStatus = 'Completed'; StartExecutionTime = $t; EndExecutionTime = $t.AddSeconds(2) } | Add-History
$global:__cdgLine = 'git status'
__cdgRestoreHistory
$h = (Get-History)[-1]
"$($h.CommandLine)|$($h.ExecutionStatus)|$(($h.EndExecutionTime - $h.StartExecutionTime).TotalSeconds)|$($null -eq $global:__cdgLine)"
[pscustomobject]@{ CommandLine = 'ls'; ExecutionStatus = 'Failed'; StartExecutionTime = $t; EndExecutionTime = $t } | Add-History
__cdgRestoreHistory
(Get-History | Select-Object -Last 2).CommandLine -join ','
"""


@pytest.mark.parametrize("shell", ["pwsh", "powershell"])
def test_engine_history_gets_the_line_as_typed(shell):
if not shutil.which(shell):
pytest.skip(f"{shell} not installed")
fn = re.search(
r"(?s)function global:__cdgRestoreHistory \{.*?\n\}", HOOK.read_text(encoding="utf-8")
)
assert fn, "restore function not found in hook.ps1"
out = subprocess.run(
[shell, "-NoProfile", "-NonInteractive", "-Command", fn.group(0) + "\n" + SCRIPT],
capture_output=True,
text=True,
timeout=60,
check=True,
).stdout.splitlines()
assert out == ["git status|Completed|2|True", "git status,ls"]
Loading