diff --git a/.github/workflows/unix-regression.yml b/.github/workflows/unix-regression.yml
new file mode 100644
index 0000000..3016e3c
--- /dev/null
+++ b/.github/workflows/unix-regression.yml
@@ -0,0 +1,47 @@
+name: Unix regression smoke
+
+on:
+ pull_request:
+ paths:
+ - ".github/workflows/unix-regression.yml"
+ - "lib/ourocode/terminal/**"
+ - "test/ourocode/terminal/**"
+ - "rust/ourocode_ipc/**"
+ - "mix.exs"
+ - "mix.lock"
+ - "scripts/package.sh"
+ workflow_dispatch:
+
+jobs:
+ tui-helper:
+ name: ${{ matrix.os }} tty helper
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: erlef/setup-beam@v1
+ with:
+ otp-version: "27"
+ elixir-version: "1.18"
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - name: Fetch Mix dependencies
+ run: mix deps.get
+
+ - name: Build Unix tty helper
+ run: cargo build --release --manifest-path rust/ourocode_ipc/Cargo.toml --bin ourocode_tty
+
+ - name: Test Rust IPC crate
+ run: cargo test --manifest-path rust/ourocode_ipc/Cargo.toml
+
+ - name: Test terminal helper discovery
+ run: mix test test/ourocode/terminal/tty_driver_test.exs
+
+ - name: Package Unix release asset
+ run: ./scripts/package.sh
diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml
new file mode 100644
index 0000000..b65eda8
--- /dev/null
+++ b/.github/workflows/windows-package.yml
@@ -0,0 +1,114 @@
+name: Windows package smoke
+
+on:
+ pull_request:
+ paths:
+ - ".github/workflows/windows-package.yml"
+ - "install.ps1"
+ - "uninstall.ps1"
+ - "scripts/package-windows.ps1"
+ - "lib/ourocode/terminal/tty_driver.ex"
+ - "rust/ourocode_ipc/**"
+ - "mix.exs"
+ - "mix.lock"
+ workflow_dispatch:
+
+jobs:
+ package-install-smoke:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: erlef/setup-beam@v1
+ with:
+ otp-version: "27"
+ elixir-version: "1.18"
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: x86_64-pc-windows-msvc
+
+ - name: Fetch Mix dependencies
+ shell: pwsh
+ run: mix deps.get
+
+ - name: Build Windows release zip
+ shell: pwsh
+ run: |
+ .\scripts\package-windows.ps1 -Version "0.0.0-ci" -OutputDir ".\dist"
+ if (-not (Test-Path ".\dist\ourocode-v0.0.0-ci-windows-x64.zip")) {
+ throw "Windows release zip was not created"
+ }
+ if (-not (Test-Path ".\dist\ourocode-v0.0.0-ci-windows-x64.zip.sha256")) {
+ throw "Windows release checksum was not created"
+ }
+ $expanded = Join-Path $env:RUNNER_TEMP "Ourocode Expanded"
+ Expand-Archive -LiteralPath ".\dist\ourocode-v0.0.0-ci-windows-x64.zip" -DestinationPath $expanded -Force
+ if (-not (Get-ChildItem -LiteralPath $expanded -Recurse -Filter "uninstall.ps1" | Select-Object -First 1)) {
+ throw "Windows release zip did not include uninstall.ps1"
+ }
+
+ - name: Install from zip with checksum
+ shell: pwsh
+ run: |
+ $zip = Resolve-Path ".\dist\ourocode-v0.0.0-ci-windows-x64.zip"
+ $sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash
+ $root = Join-Path $env:RUNNER_TEMP "Ourocode Test"
+ $originalUserPath = [Environment]::GetEnvironmentVariable("Path", "User")
+ try {
+ powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 -LocalZip $zip -Sha256 $sha -InstallRoot $root
+ $expectedBin = Join-Path $root "bin"
+ $updatedUserPath = [Environment]::GetEnvironmentVariable("Path", "User")
+ if ($updatedUserPath -notlike "*$expectedBin*") {
+ throw "installer did not add the stable shim directory to the user PATH"
+ }
+ # Keep the current process PATH (setup-beam put escript.exe there) so the
+ # ourocode.cmd launcher can resolve escript; a real user has Erlang on a
+ # persistent PATH, but on CI it only lives in this job's process PATH.
+ $env:Path = "$updatedUserPath;$([Environment]::GetEnvironmentVariable("Path", "Machine"));$env:Path"
+ powershell -NoProfile -Command "ourocode --version"
+ cmd.exe /c "ourocode --version"
+ }
+ finally {
+ [Environment]::SetEnvironmentVariable("Path", $originalUserPath, "User")
+ }
+
+ - name: Reject checksum mismatch
+ shell: pwsh
+ run: |
+ $zip = Resolve-Path ".\dist\ourocode-v0.0.0-ci-windows-x64.zip"
+ $root = Join-Path $env:RUNNER_TEMP "Ourocode Bad Hash"
+ powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 -LocalZip $zip -Sha256 0000 -InstallRoot $root -NoPathUpdate
+ $code = $LASTEXITCODE
+ if ($code -eq 0) {
+ throw "checksum mismatch unexpectedly succeeded"
+ }
+ $global:LASTEXITCODE = 0
+
+ - name: Uninstall and reinstall loop
+ shell: pwsh
+ run: |
+ $zip = Resolve-Path ".\dist\ourocode-v0.0.0-ci-windows-x64.zip"
+ $sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash
+ $root = Join-Path $env:RUNNER_TEMP "Ourocode Reinstall"
+ $originalUserPath = [Environment]::GetEnvironmentVariable("Path", "User")
+ try {
+ powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 -LocalZip $zip -Sha256 $sha -InstallRoot $root
+ powershell -NoProfile -ExecutionPolicy Bypass -File .\uninstall.ps1 -InstallRoot $root -AllVersions
+ if (Test-Path -LiteralPath $root) {
+ throw "uninstall left install root behind"
+ }
+ $updatedUserPath = [Environment]::GetEnvironmentVariable("Path", "User")
+ if ($updatedUserPath -like "*$root*") {
+ throw "uninstall left the install root on user PATH"
+ }
+ powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 -LocalZip $zip -Sha256 $sha -InstallRoot $root
+ $cmd = Join-Path $root "bin\ourocode.cmd"
+ if (-not (Test-Path -LiteralPath $cmd)) {
+ throw "reinstall did not recreate the launcher"
+ }
+ }
+ finally {
+ [Environment]::SetEnvironmentVariable("Path", $originalUserPath, "User")
+ Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
+ }
diff --git a/.gitignore b/.gitignore
index 2682155..a25a8e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,7 @@
# Stray seed artifacts
/seed_*.yaml
+
+.om*/
+*.dump
+docs/rubrics/
diff --git a/README.md b/README.md
index f8aa64d..41dffb6 100644
--- a/README.md
+++ b/README.md
@@ -22,30 +22,53 @@ The current release is optimized for local macOS development and guided workflow
## Quick Start
-Install the latest prerelease build:
+Install the latest prerelease build on macOS or Linux:
```bash
curl -fsSL https://raw.githubusercontent.com/Q00/ourocode/release/bootstrap/install.sh | bash
```
+Install a Windows release from PowerShell:
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 -Version Ourocode
Start with a PM interview, clarify the task, approve execution, and keep proof in the terminal.
install
-+ Windows uses PowerShell 5.1+ or 7+, Erlang/OTP, and `escript.exe`. + Downloaded releases verify the `.sha256` sidecar before install, write to `%LOCALAPPDATA%\Ourocode`, + update the user PATH for new shells, and include `uninstall.ps1` for repeatable reinstall cycles. + The Windows path does not require Bash, WSL, or a permanent execution-policy change. + Release builders also need Git, Elixir/Mix, Rust stable with the MSVC target, and MSVC Build Tools. +
curl -fsSL https://raw.githubusercontent.com/Q00/ourocode/release/bootstrap/install.sh | bash
+ # Windows final-user checks
+$PSVersionTable.PSVersion
+Get-Command escript.exe
+erl -eval "erlang:display(erlang:system_info(otp_release)), halt()." -noshell
+
+# Windows install
+powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 -Version <version>
+ourocode --version
+powershell -NoProfile -ExecutionPolicy Bypass -File .\uninstall.ps1 -AllVersions
+
+# Windows release builder
+.\scripts\package-windows.ps1 -Version <version>
+
+# macOS / Linux
+curl -fsSL https://raw.githubusercontent.com/Q00/ourocode/release/bootstrap/install.sh | bash
ourocode
ourocode --prompt "/agents" --format json
ourocode --verify --format json --project-dir .
diff --git a/install.ps1 b/install.ps1
new file mode 100644
index 0000000..3eaf27a
--- /dev/null
+++ b/install.ps1
@@ -0,0 +1,412 @@
+[CmdletBinding()]
+param(
+ [string]$Version,
+ [string]$LocalZip,
+ [string]$Sha256,
+ [string]$InstallRoot,
+ [switch]$NoPathUpdate,
+ [string]$Repo = "Ouro-labs/ourocode",
+ [string]$ReleaseUrl,
+ [string]$Sha256Url,
+ [switch]$SkipPrerequisiteCheckForTest
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+function Stop-Install {
+ param([string]$Message)
+ [Console]::Error.WriteLine($Message)
+ exit 1
+}
+
+function Resolve-ExistingFile {
+ param(
+ [string]$Path,
+ [string]$Description
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Path)) {
+ Stop-Install "$Description path was empty."
+ }
+
+ $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue
+ if (-not $resolved) {
+ Stop-Install "$Description not found: $Path"
+ }
+
+ return $resolved.ProviderPath
+}
+
+function Get-DefaultInstallRoot {
+ if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
+ Stop-Install "LOCALAPPDATA is not set. Pass -InstallRoot to choose an install directory."
+ }
+
+ return (Join-Path $env:LOCALAPPDATA "Ourocode")
+}
+
+function Get-VersionFromZipName {
+ param([string]$ZipPath)
+
+ $name = [System.IO.Path]::GetFileName($ZipPath)
+ $match = [regex]::Match($name, '^ourocode-v(.+)-windows-x64\.zip$', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
+ if ($match.Success) {
+ return $match.Groups[1].Value
+ }
+
+ return $null
+}
+
+function Assert-EscriptAvailable {
+ param([switch]$SkipForTest)
+
+ if ($SkipForTest) {
+ Write-Host "==> skipping escript.exe prerequisite check for installer test"
+ return
+ }
+
+ $escript = Get-Command escript.exe -ErrorAction SilentlyContinue
+ if (-not $escript) {
+ Stop-Install @"
+Erlang/OTP runtime was not found: escript.exe is not available on PATH.
+
+Ourocode is installed as an Erlang escript and needs Erlang/OTP to run.
+Install Erlang/OTP from https://www.erlang.org/downloads, open a new PowerShell
+session, confirm `Get-Command escript.exe` succeeds, then rerun this installer.
+"@
+ }
+}
+
+function Get-ReleaseAssetUrl {
+ param(
+ [string]$Version,
+ [string]$Repo,
+ [string]$ReleaseUrl
+ )
+
+ $assetName = "ourocode-v$Version-windows-x64.zip"
+ if ([string]::IsNullOrWhiteSpace($ReleaseUrl)) {
+ return "https://github.com/$Repo/releases/download/v$Version/$assetName"
+ }
+
+ return $ReleaseUrl
+}
+
+function Save-UrlToFile {
+ param(
+ [string]$Url,
+ [string]$Destination
+ )
+
+ $client = New-Object System.Net.WebClient
+ try {
+ $client.DownloadFile($Url, $Destination)
+ }
+ finally {
+ $client.Dispose()
+ }
+}
+
+function Read-UrlText {
+ param([string]$Url)
+
+ $client = New-Object System.Net.WebClient
+ try {
+ return $client.DownloadString($Url)
+ }
+ finally {
+ $client.Dispose()
+ }
+}
+
+function Save-ReleaseZip {
+ param(
+ [string]$Version,
+ [string]$Repo,
+ [string]$ReleaseUrl,
+ [string]$DestinationDirectory
+ )
+
+ $assetName = "ourocode-v$Version-windows-x64.zip"
+ $ReleaseUrl = Get-ReleaseAssetUrl -Version $Version -Repo $Repo -ReleaseUrl $ReleaseUrl
+ $destination = Join-Path $DestinationDirectory $assetName
+ Write-Host "==> downloading $ReleaseUrl"
+ Save-UrlToFile -Url $ReleaseUrl -Destination $destination
+ return $destination
+}
+
+function Get-ExpectedReleaseSha256 {
+ param(
+ [string]$Version,
+ [string]$Repo,
+ [string]$ReleaseUrl,
+ [string]$Sha256Url
+ )
+
+ if (-not [string]::IsNullOrWhiteSpace($Sha256Url)) {
+ $checksumUrl = $Sha256Url
+ }
+ else {
+ $checksumUrl = "$(Get-ReleaseAssetUrl -Version $Version -Repo $Repo -ReleaseUrl $ReleaseUrl).sha256"
+ }
+
+ Write-Host "==> downloading checksum $checksumUrl"
+ $content = Read-UrlText -Url $checksumUrl
+ $match = [regex]::Match($content, '(?i)\b[0-9a-f]{64}\b')
+ if (-not $match.Success) {
+ Stop-Install "Checksum response did not contain a SHA256 value: $checksumUrl"
+ }
+
+ return $match.Value
+}
+
+function Get-LocalZipSha256 {
+ param([string]$ZipPath)
+
+ $checksumPath = "$ZipPath.sha256"
+ if (-not (Test-Path -LiteralPath $checksumPath -PathType Leaf)) {
+ Stop-Install "Local zip installs require -Sha256 or a sidecar checksum file at $checksumPath."
+ }
+
+ $content = Get-Content -LiteralPath $checksumPath -Raw
+ $match = [regex]::Match($content, '(?i)\b[0-9a-f]{64}\b')
+ if (-not $match.Success) {
+ Stop-Install "Checksum file did not contain a SHA256 value: $checksumPath"
+ }
+
+ return $match.Value
+}
+
+function Assert-ZipHash {
+ param(
+ [string]$ZipPath,
+ [string]$ExpectedSha256
+ )
+
+ if ([string]::IsNullOrWhiteSpace($ExpectedSha256)) {
+ return
+ }
+
+ $actual = (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
+ $expected = $ExpectedSha256.Trim().ToLowerInvariant()
+ if ($actual -ne $expected) {
+ Stop-Install "SHA256 mismatch for $ZipPath. Expected $expected but got $actual."
+ }
+}
+
+function Expand-ReleaseZip {
+ param(
+ [string]$ZipPath,
+ [string]$DestinationDirectory
+ )
+
+ New-Item -ItemType Directory -Force -Path $DestinationDirectory | Out-Null
+ Expand-Archive -LiteralPath $ZipPath -DestinationPath $DestinationDirectory -Force
+
+ $entrypoint = Get-ChildItem -LiteralPath $DestinationDirectory -Recurse -Force -File |
+ Where-Object { $_.Name -ceq "ourocode" } |
+ Sort-Object { $_.FullName.Length } |
+ Select-Object -First 1
+
+ if (-not $entrypoint) {
+ Stop-Install "Release zip did not contain an 'ourocode' escript."
+ }
+
+ return $entrypoint.Directory.FullName
+}
+
+function Copy-ReleaseRoot {
+ param(
+ [string]$SourceRoot,
+ [string]$DestinationRoot
+ )
+
+ New-Item -ItemType Directory -Force -Path $DestinationRoot | Out-Null
+ Get-ChildItem -LiteralPath $SourceRoot -Force | ForEach-Object {
+ Copy-Item -LiteralPath $_.FullName -Destination $DestinationRoot -Recurse -Force
+ }
+
+ $installedOurocode = Join-Path $DestinationRoot "ourocode"
+ if (-not (Test-Path -LiteralPath $installedOurocode -PathType Leaf)) {
+ Stop-Install "Install staging failed: missing $installedOurocode"
+ }
+}
+
+function Move-StagedInstall {
+ param(
+ [string]$StageRoot,
+ [string]$InstallDirectory
+ )
+
+ $parent = Split-Path -Parent $InstallDirectory
+ New-Item -ItemType Directory -Force -Path $parent | Out-Null
+
+ $backup = $null
+ if (Test-Path -LiteralPath $InstallDirectory) {
+ $backup = "$InstallDirectory.previous-$([System.Guid]::NewGuid().ToString('N'))"
+ Move-Item -LiteralPath $InstallDirectory -Destination $backup
+ }
+
+ try {
+ Move-Item -LiteralPath $StageRoot -Destination $InstallDirectory
+ if ($backup -and (Test-Path -LiteralPath $backup)) {
+ Remove-Item -LiteralPath $backup -Recurse -Force
+ }
+ }
+ catch {
+ if ($backup -and (Test-Path -LiteralPath $backup) -and -not (Test-Path -LiteralPath $InstallDirectory)) {
+ Move-Item -LiteralPath $backup -Destination $InstallDirectory
+ }
+ throw
+ }
+}
+
+function Write-CmdLauncher {
+ param(
+ [string]$LauncherPath,
+ [string]$InstallDirectory
+ )
+
+ $installedOurocode = Join-Path $InstallDirectory "ourocode"
+ $installedTty = Join-Path (Join-Path $InstallDirectory "bin") "ourocode_tty.exe"
+ $content = @"
+@echo off
+setlocal
+if exist "$installedTty" set "OUROCODE_TTY=$installedTty"
+escript.exe "$installedOurocode" %*
+exit /b %ERRORLEVEL%
+"@
+
+ $launcherDirectory = Split-Path -Parent $LauncherPath
+ New-Item -ItemType Directory -Force -Path $launcherDirectory | Out-Null
+ Set-Content -LiteralPath $LauncherPath -Value $content -Encoding ASCII
+}
+
+function ConvertTo-PathKey {
+ param([string]$Path)
+
+ if ([string]::IsNullOrWhiteSpace($Path)) {
+ return ""
+ }
+
+ $trimmed = $Path.Trim().Trim('"')
+ try {
+ $full = [System.IO.Path]::GetFullPath($trimmed)
+ }
+ catch {
+ $full = $trimmed
+ }
+
+ return $full.TrimEnd('\').ToLowerInvariant()
+}
+
+function Add-UserPathOnce {
+ param([string]$Directory)
+
+ $current = [Environment]::GetEnvironmentVariable("Path", "User")
+ $entries = @()
+ if (-not [string]::IsNullOrWhiteSpace($current)) {
+ $entries = $current -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
+ }
+
+ $targetKey = ConvertTo-PathKey $Directory
+ $deduped = New-Object System.Collections.Generic.List[string]
+ $seen = New-Object 'System.Collections.Generic.HashSet[string]'
+
+ foreach ($entry in $entries) {
+ $key = ConvertTo-PathKey $entry
+ if ([string]::IsNullOrWhiteSpace($key)) {
+ continue
+ }
+ if ($key -eq $targetKey) {
+ continue
+ }
+ if ($seen.Add($key)) {
+ $deduped.Add($entry.Trim())
+ }
+ }
+
+ $deduped.Add($Directory)
+ $newPath = [string]::Join(';', $deduped)
+ [Environment]::SetEnvironmentVariable("Path", $newPath, "User")
+
+ if (($env:Path -split ';' | ForEach-Object { ConvertTo-PathKey $_ }) -notcontains $targetKey) {
+ $env:Path = "$Directory;$env:Path"
+ }
+}
+
+Assert-EscriptAvailable -SkipForTest:$SkipPrerequisiteCheckForTest
+
+$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "ourocode-install-$([System.Guid]::NewGuid().ToString('N'))"
+New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null
+
+try {
+ $zipPath = $null
+ if (-not [string]::IsNullOrWhiteSpace($LocalZip)) {
+ $zipPath = Resolve-ExistingFile -Path $LocalZip -Description "Local zip"
+ if ([string]::IsNullOrWhiteSpace($Sha256)) {
+ $Sha256 = Get-LocalZipSha256 -ZipPath $zipPath
+ }
+ }
+
+ if ([string]::IsNullOrWhiteSpace($Version)) {
+ if ($zipPath) {
+ $Version = Get-VersionFromZipName -ZipPath $zipPath
+ }
+ if ([string]::IsNullOrWhiteSpace($Version) -and -not [string]::IsNullOrWhiteSpace($env:OUROCODE_VERSION)) {
+ $Version = $env:OUROCODE_VERSION
+ }
+ if ([string]::IsNullOrWhiteSpace($Version)) {
+ $Version = "0.1.13"
+ }
+ }
+
+ if (-not $zipPath) {
+ $zipPath = Save-ReleaseZip -Version $Version -Repo $Repo -ReleaseUrl $ReleaseUrl -DestinationDirectory $tempRoot
+ if ([string]::IsNullOrWhiteSpace($Sha256)) {
+ $Sha256 = Get-ExpectedReleaseSha256 -Version $Version -Repo $Repo -ReleaseUrl $ReleaseUrl -Sha256Url $Sha256Url
+ }
+ }
+
+ $root = $InstallRoot
+ if ([string]::IsNullOrWhiteSpace($root)) {
+ $root = Get-DefaultInstallRoot
+ }
+ $root = [System.IO.Path]::GetFullPath($root)
+
+ $installDirectory = Join-Path $root $Version
+ $launcherDirectory = Join-Path $root "bin"
+ $launcherPath = Join-Path $launcherDirectory "ourocode.cmd"
+ $expanded = Join-Path $tempRoot "expanded"
+ $stage = Join-Path $tempRoot "stage"
+
+ Write-Host "==> ourocode install"
+ Write-Host "==> version: $Version"
+ Write-Host "==> zip: $zipPath"
+ Assert-ZipHash -ZipPath $zipPath -ExpectedSha256 $Sha256
+
+ $releaseRoot = Expand-ReleaseZip -ZipPath $zipPath -DestinationDirectory $expanded
+ Copy-ReleaseRoot -SourceRoot $releaseRoot -DestinationRoot $stage
+ Move-StagedInstall -StageRoot $stage -InstallDirectory $installDirectory
+ Write-CmdLauncher -LauncherPath $launcherPath -InstallDirectory $installDirectory
+
+ if ($NoPathUpdate) {
+ Write-Host "==> PATH update skipped (-NoPathUpdate)"
+ }
+ else {
+ Add-UserPathOnce -Directory $launcherDirectory
+ Write-Host "==> added to user PATH: $launcherDirectory"
+ Write-Host " Open a new PowerShell or Command Prompt session if 'ourocode' is not found."
+ }
+
+ Write-Host ""
+ Write-Host "==> ready"
+ Write-Host " installed: $installDirectory"
+ Write-Host " command: $launcherPath"
+}
+finally {
+ if (Test-Path -LiteralPath $tempRoot) {
+ Remove-Item -LiteralPath $tempRoot -Recurse -Force
+ }
+}
diff --git a/lib/ourocode/model/cli.ex b/lib/ourocode/model/cli.ex
index 58b7bf1..80a6547 100644
--- a/lib/ourocode/model/cli.ex
+++ b/lib/ourocode/model/cli.ex
@@ -37,6 +37,21 @@ defmodule Ourocode.Model.Cli do
end
end
+ @doc false
+ @spec runner_command(
+ String.t(),
+ [String.t()],
+ {:unix | :win32, atom()},
+ (String.t() -> String.t() | nil)
+ ) :: {String.t(), [String.t()]}
+ def runner_command(path, args, os_type \\ :os.type(), which \\ &System.find_executable/1)
+
+ def runner_command(path, args, {:win32, _name}, _which), do: {path, args}
+
+ def runner_command(path, args, _os_type, which) when is_function(which, 1) do
+ {which.("sh") || "/bin/sh", ["-c", ~s(exec "$0" "$@"
delay = Keyword.get(opts, :retry_base_delay_ms, @retry_base_delay_ms)
- run_with_retry(id, path, args(id, prompt, nil), on_chunk, delay, 1)
+ run_with_retry(id, path, args(id, prompt, nil), on_chunk, delay, 1, run)
end
end
- defp run_with_retry(id, path, args, on_chunk, delay, attempt) do
+ defp run_with_retry(id, path, args, on_chunk, delay, attempt, run) do
emitted = :counters.new(1, [])
counted_chunk = fn chunk ->
@@ -67,11 +83,11 @@ defmodule Ourocode.Model.Cli do
on_chunk.(chunk)
end
- case run(id, path, args, counted_chunk) do
+ case run.(id, path, args, counted_chunk) do
{:error, {:exit, _status}} = error ->
if :counters.get(emitted, 1) == 0 and attempt < @max_attempts do
Process.sleep(delay * Integer.pow(2, attempt - 1))
- run_with_retry(id, path, args, on_chunk, delay, attempt + 1)
+ run_with_retry(id, path, args, on_chunk, delay, attempt + 1, run)
else
error
end
@@ -82,18 +98,15 @@ defmodule Ourocode.Model.Cli do
end
defp run(id, path, args, on_chunk) do
- # Spawn through `sh -c 'exec "$0" "$@" \"#{log_path}\" 2>&1", exe] ++ args,
+ shell: command,
+ args: command_args,
log_path: log_path
}
end
@spec stop(map() | nil) :: :ok
def stop(%{mode: :spawned} = handle) do
- case Map.get(handle, :os_pid) do
- pid when is_integer(pid) and pid > 0 ->
- System.cmd("kill", ["-TERM", Integer.to_string(pid)], stderr_to_stdout: true)
-
- _none ->
- :ok
- end
+ handle
+ |> Map.get(:os_pid)
+ |> terminate_os_process()
erl_port = Map.get(handle, :port)
if is_port(erl_port) and Port.info(erl_port) != nil, do: Port.close(erl_port)
@@ -68,4 +64,51 @@ defmodule Ourocode.Runtime.McpDaemon.Process do
end
def stop(_handle), do: :ok
+
+ defp launch_plan(nil, exe, args, log_path) do
+ if windows?() do
+ {exe, args}
+ else
+ shell = default_shell()
+ {shell, shell_args(shell, exe, args, log_path)}
+ end
+ end
+
+ defp launch_plan(shell, exe, args, log_path) do
+ if windows?() and windows_shell?(shell) do
+ {exe, args}
+ else
+ {shell, shell_args(shell, exe, args, log_path)}
+ end
+ end
+
+ defp default_shell do
+ System.find_executable("sh") || "/bin/sh"
+ end
+
+ defp shell_args(_shell, exe, args, log_path) do
+ ["-c", "exec \"$0\" \"$@\" >\"#{log_path}\" 2>&1", exe] ++ args
+ end
+
+ defp terminate_os_process(pid) when is_integer(pid) and pid > 0 do
+ if windows?() do
+ command = System.find_executable("taskkill") || "taskkill"
+ System.cmd(command, ["/PID", Integer.to_string(pid), "/T", "/F"], stderr_to_stdout: true)
+ else
+ System.cmd("kill", ["-TERM", Integer.to_string(pid)], stderr_to_stdout: true)
+ end
+
+ :ok
+ end
+
+ defp terminate_os_process(_pid), do: :ok
+
+ defp windows_shell?(shell) do
+ shell
+ |> Path.basename()
+ |> String.downcase()
+ |> then(&(&1 in ["cmd", "cmd.exe"]))
+ end
+
+ defp windows?, do: match?({:win32, _name}, :os.type())
end
diff --git a/lib/ourocode/terminal/tty_driver.ex b/lib/ourocode/terminal/tty_driver.ex
index ccf3ae4..9851c0d 100644
--- a/lib/ourocode/terminal/tty_driver.ex
+++ b/lib/ourocode/terminal/tty_driver.ex
@@ -4,14 +4,23 @@ defmodule Ourocode.Terminal.TtyDriver do
"""
@poll_ms 500
+ @helper_osc_prefix "\e]777;ourocode-"
+ @helper_osc_resize_prefix "\e]777;ourocode-resize="
+ @helper_osc_redraw "\e]777;ourocode-control=redraw\a"
+ @bracketed_paste_start "\e[200~"
+ @bracketed_paste_end "\e[201~"
@doc "Absolute path of the built tty helper, or nil if it is not present."
@spec helper_path() :: String.t() | nil
def helper_path do
+ cwd = File.cwd!()
+
[
System.get_env("OUROCODE_TTY"),
- Path.join(File.cwd!(), "rust/ourocode_ipc/target/release/ourocode_tty"),
- Path.join(File.cwd!(), "bin/ourocode_tty")
+ Path.join(cwd, "bin/ourocode_tty.exe"),
+ Path.join(cwd, "bin/ourocode_tty"),
+ Path.join(cwd, "rust/ourocode_ipc/target/release/ourocode_tty.exe"),
+ Path.join(cwd, "rust/ourocode_ipc/target/release/ourocode_tty")
]
|> helper_path()
end
@@ -30,12 +39,10 @@ defmodule Ourocode.Terminal.TtyDriver do
path ->
port =
- Port.open({:spawn_executable, String.to_charlist(path)}, [
- :binary,
- :exit_status,
- :nouse_stdio,
- :hide
- ])
+ Port.open(
+ {:spawn_executable, String.to_charlist(path)},
+ port_options(:os.type())
+ )
case read_header(port, "") do
{:ok, cols, rows, rest} ->
@@ -68,11 +75,22 @@ defmodule Ourocode.Terminal.TtyDriver do
end
@spec next_chunk(port() | nil, non_neg_integer()) ::
- {:ok, binary()} | {:file_cache_ready, [String.t()]} | :tick | :eof
+ {:ok, binary()}
+ | {:ok, binary(), binary()}
+ | {:resize, {pos_integer(), pos_integer()}}
+ | {:resize, {pos_integer(), pos_integer()}, binary()}
+ | {:control, :redraw}
+ | {:control, :redraw, binary()}
+ | {:ignore, binary()}
+ | {:file_cache_ready, [String.t()]}
+ | :tick
+ | :eof
def next_chunk(port, poll_ms \\ @poll_ms) do
receive do
{^port, {:data, data}} when is_binary(data) ->
- {:ok, data}
+ data
+ |> decode_chunk()
+ |> next_chunk_reply()
{^port, {:exit_status, _status}} ->
:eof
@@ -99,6 +117,11 @@ defmodule Ourocode.Terminal.TtyDriver do
System.get_env("OUROCODE_FORCE_TTY") == "1" or match?({:ok, _}, :io.columns())
end
+ @doc false
+ @spec port_options(:os.type()) :: [:binary | :exit_status | :nouse_stdio | :hide]
+ def port_options({:win32, _}), do: [:binary, :exit_status]
+ def port_options(_os_type), do: [:binary, :exit_status, :nouse_stdio, :hide]
+
@doc false
def enter_sequence, do: "\e[?1049h\e[?1006h\e[?1003h\e[?25l\e[2J\e[H"
@@ -136,6 +159,109 @@ defmodule Ourocode.Terminal.TtyDriver do
end
end
+ @doc false
+ @spec decode_chunk(binary()) ::
+ {:ok, binary(), binary()}
+ | {:resize, {pos_integer(), pos_integer()}, binary()}
+ | {:control, :redraw, binary()}
+ | {:ignore, binary()}
+ def decode_chunk(data) when is_binary(data) do
+ case helper_frame_bounds(data) do
+ nil ->
+ {:ok, data, ""}
+
+ {0, frame_size} ->
+ frame = binary_part(data, 0, frame_size)
+ rest = binary_part(data, frame_size, byte_size(data) - frame_size)
+ decode_helper_frame(frame, rest)
+
+ {start, _frame_size} ->
+ raw = binary_part(data, 0, start)
+ rest = binary_part(data, start, byte_size(data) - start)
+ {:ok, raw, rest}
+ end
+ end
+
+ defp next_chunk_reply({:ok, data, ""}), do: {:ok, data}
+ defp next_chunk_reply({:resize, size, ""}), do: {:resize, size}
+ defp next_chunk_reply({:control, :redraw, ""}), do: {:control, :redraw}
+ defp next_chunk_reply({:ignore, ""}), do: :tick
+ defp next_chunk_reply(other), do: other
+
+ defp decode_helper_frame(@helper_osc_redraw, rest), do: {:control, :redraw, rest}
+
+ defp decode_helper_frame(@helper_osc_resize_prefix <> rest = frame, remaining) do
+ with true <- String.ends_with?(rest, "\a"),
+ value <- binary_part(rest, 0, byte_size(rest) - 1),
+ [cols_text, rows_text] <- String.split(value, "x", parts: 2),
+ {cols, ""} when cols > 0 <- Integer.parse(cols_text),
+ {rows, ""} when rows > 0 <- Integer.parse(rows_text) do
+ {:resize, {cols, rows}, remaining}
+ else
+ _invalid ->
+ if helper_control_frame?(frame), do: {:ignore, remaining}, else: {:ok, frame, remaining}
+ end
+ end
+
+ defp decode_helper_frame(frame, rest) do
+ if helper_control_frame?(frame), do: {:ignore, rest}, else: {:ok, frame, rest}
+ end
+
+ defp helper_control_frame?(data) do
+ String.starts_with?(data, @helper_osc_prefix) and String.ends_with?(data, "\a")
+ end
+
+ defp helper_frame_bounds(data), do: helper_frame_bounds(data, 0)
+
+ defp helper_frame_bounds(data, offset) when offset >= byte_size(data), do: nil
+
+ defp helper_frame_bounds(data, offset) do
+ case next_helper_or_paste(data, offset) do
+ nil ->
+ nil
+
+ {:helper, index} ->
+ case match_from(data, "\a", index) do
+ {bel_index, 1} -> {index, bel_index - index + 1}
+ :nomatch -> nil
+ end
+
+ {:paste, index} ->
+ paste_content_index = index + byte_size(@bracketed_paste_start)
+
+ case match_from(data, @bracketed_paste_end, paste_content_index) do
+ {paste_end_index, paste_end_size} ->
+ helper_frame_bounds(data, paste_end_index + paste_end_size)
+
+ :nomatch ->
+ nil
+ end
+ end
+ end
+
+ defp next_helper_or_paste(data, offset) do
+ match =
+ [
+ helper: match_from(data, @helper_osc_prefix, offset),
+ paste: match_from(data, @bracketed_paste_start, offset)
+ ]
+ |> Enum.reject(fn {_kind, match} -> match == :nomatch end)
+ |> Enum.min_by(fn {_kind, {index, _size}} -> index end, fn -> nil end)
+
+ case match do
+ nil -> nil
+ {kind, {index, _size}} -> {kind, index}
+ end
+ end
+
+ defp match_from(data, pattern, offset) do
+ if offset >= byte_size(data) do
+ :nomatch
+ else
+ :binary.match(data, pattern, scope: {offset, byte_size(data) - offset})
+ end
+ end
+
defp safe_close(port) do
if is_port(port) and Port.info(port) != nil, do: Port.close(port)
:ok
diff --git a/lib/ourocode/terminal/tui_answer_submission.ex b/lib/ourocode/terminal/tui_answer_submission.ex
index 8a4ac52..fa2c68c 100644
--- a/lib/ourocode/terminal/tui_answer_submission.ex
+++ b/lib/ourocode/terminal/tui_answer_submission.ex
@@ -129,5 +129,5 @@ defmodule Ourocode.Terminal.TuiAnswerSubmission do
|> Kernel.in(["cancel", "decline", "/cancel"])
end
- defp log(output, text), do: IO.puts(output, text)
+ defp log(output, text), do: IO.puts(output, String.replace_invalid(text, ""))
end
diff --git a/lib/ourocode/terminal/tui_driver_session.ex b/lib/ourocode/terminal/tui_driver_session.ex
index d7b4979..882f460 100644
--- a/lib/ourocode/terminal/tui_driver_session.ex
+++ b/lib/ourocode/terminal/tui_driver_session.ex
@@ -44,11 +44,39 @@ defmodule Ourocode.Terminal.TuiDriverSession do
end
@spec next_chunk(pid(), non_neg_integer()) ::
- {:ok, binary()} | :tick | :eof
+ {:ok, binary()}
+ | {:resize, {pos_integer(), pos_integer()}}
+ | {:control, :redraw}
+ | :tick
+ | :eof
def next_chunk(state, poll_ms \\ @poll_ms) when is_pid(state) do
case TuiState.take_inbuf(state) do
"" ->
case TtyDriver.next_chunk(TuiState.port(state), poll_ms) do
+ {:ok, raw, rest} ->
+ TuiState.put_inbuf(state, rest)
+ {:ok, raw}
+
+ {:resize, size, rest} ->
+ TuiState.put_inbuf(state, rest)
+ TuiState.put_size(state, size)
+ {:resize, size}
+
+ {:resize, size} ->
+ TuiState.put_size(state, size)
+ {:resize, size}
+
+ {:control, :redraw, rest} ->
+ TuiState.put_inbuf(state, rest)
+ {:control, :redraw}
+
+ {:control, :redraw} ->
+ {:control, :redraw}
+
+ {:ignore, rest} ->
+ TuiState.put_inbuf(state, rest)
+ :tick
+
{:file_cache_ready, files} ->
TuiState.put_file_cache(state, files)
:tick
@@ -58,10 +86,31 @@ defmodule Ourocode.Terminal.TuiDriverSession do
end
buffered ->
- {:ok, buffered}
+ apply_decoded_chunk(state, TtyDriver.decode_chunk(buffered))
end
end
+ defp apply_decoded_chunk(state, {:ok, raw, rest}) do
+ TuiState.put_inbuf(state, rest)
+ {:ok, raw}
+ end
+
+ defp apply_decoded_chunk(state, {:resize, size, rest}) do
+ TuiState.put_inbuf(state, rest)
+ TuiState.put_size(state, size)
+ {:resize, size}
+ end
+
+ defp apply_decoded_chunk(state, {:control, :redraw, rest}) do
+ TuiState.put_inbuf(state, rest)
+ {:control, :redraw}
+ end
+
+ defp apply_decoded_chunk(state, {:ignore, rest}) do
+ TuiState.put_inbuf(state, rest)
+ :tick
+ end
+
@spec refresh_size(pid()) :: {pos_integer(), pos_integer()}
def refresh_size(state) when is_pid(state) do
size = TtyDriver.size(TuiState.size(state))
diff --git a/lib/ourocode/terminal/tui_input_loop.ex b/lib/ourocode/terminal/tui_input_loop.ex
index 1a847eb..7a2b98c 100644
--- a/lib/ourocode/terminal/tui_input_loop.ex
+++ b/lib/ourocode/terminal/tui_input_loop.ex
@@ -64,7 +64,7 @@ defmodule Ourocode.Terminal.TuiInputLoop do
TuiInteraction.capturing?(result, state) and
match?(%{key: k} when k in [:enter, :escape], event) and
- not slash_submit?(event, state) ->
+ not command_submit?(event, state) ->
TuiInteraction.handle_event(event, result, output, state)
draw.()
cont.()
@@ -121,6 +121,15 @@ defmodule Ourocode.Terminal.TuiInputLoop do
:continue -> read_key_loop(result, output, state, callbacks)
end
+ {:resize, {columns, rows}} ->
+ redraw(callbacks, result, output, state, TuiState.buffer(state), columns, rows)
+ read_key_loop(result, output, state, callbacks)
+
+ {:control, :redraw} ->
+ {columns, rows} = TuiState.size(state)
+ redraw(callbacks, result, output, state, TuiState.buffer(state), columns, rows)
+ read_key_loop(result, output, state, callbacks)
+
{:ok, chunk} ->
{columns, rows} = TuiDriverSession.refresh_size(state)
{events, leftover} = KeyReader.decode(TuiState.take_leftover(state) <> chunk)
@@ -149,14 +158,18 @@ defmodule Ourocode.Terminal.TuiInputLoop do
})
end
- defp slash_submit?(%{key: :enter}, state) do
+ defp command_submit?(%{key: :enter}, state) do
state
|> TuiState.buffer()
|> String.trim_leading()
- |> String.starts_with?("/")
+ |> command_like?()
end
- defp slash_submit?(_event, _state), do: false
+ defp command_submit?(_event, _state), do: false
+
+ defp command_like?("/" <> _rest), do: true
+ defp command_like?("ooo" <> rest), do: rest == "" or String.match?(rest, ~r/^\s/)
+ defp command_like?(_line), do: false
defp pending_cancel_prefix?(result, state) do
buffer = TuiState.buffer(state)
diff --git a/lib/ourocode/terminal/tui_interaction.ex b/lib/ourocode/terminal/tui_interaction.ex
index 61be16e..884d330 100644
--- a/lib/ourocode/terminal/tui_interaction.ex
+++ b/lib/ourocode/terminal/tui_interaction.ex
@@ -431,7 +431,7 @@ defmodule Ourocode.Terminal.TuiInteraction do
defp accepted_notification(""), do: "accepted - answer captured"
defp accepted_notification(label), do: "accepted - " <> label
- defp log(output, text), do: IO.puts(output, text)
+ defp log(output, text), do: IO.puts(output, String.replace_invalid(text, ""))
defp clear_captured_activity(output) do
StringIO.flush(output)
diff --git a/lib/ourocode/terminal/tui_login.ex b/lib/ourocode/terminal/tui_login.ex
index 066c757..45a726a 100644
--- a/lib/ourocode/terminal/tui_login.ex
+++ b/lib/ourocode/terminal/tui_login.ex
@@ -257,11 +257,9 @@ defmodule Ourocode.Terminal.TuiLogin do
if TuiEnvironment.test_run?() do
{:error, :test_run}
else
- opener = System.find_executable("open") || System.find_executable("xdg-open")
-
- case opener do
+ case open_url_command(url, :os.type(), &System.find_executable/1) do
nil -> {:error, :not_found}
- command -> system_ok(command, [url])
+ {command, args} -> system_ok(command, args)
end
end
rescue
@@ -272,15 +270,51 @@ defmodule Ourocode.Terminal.TuiLogin do
if TuiEnvironment.test_run?() do
{:error, :test_run}
else
- case clipboard_command() do
- nil -> {:error, :not_found}
- command -> copy_with_stdin(command, text)
+ case windows_clipboard_command(text, :os.type(), &System.find_executable/1) do
+ {command, args, env} ->
+ system_ok(command, args, env: env)
+
+ nil ->
+ copy_to_unix_clipboard(text)
end
end
rescue
exception -> {:error, exception}
end
+ defp copy_to_unix_clipboard(text) do
+ case clipboard_command() do
+ nil -> {:error, :not_found}
+ command -> copy_with_stdin(command, text)
+ end
+ end
+
+ @doc false
+ @spec open_url_command(String.t(), tuple(), (String.t() -> String.t() | nil)) ::
+ {String.t(), [String.t()]} | nil
+ def open_url_command(url, {:win32, _}, _find_executable) do
+ {"rundll32.exe", ["url.dll,FileProtocolHandler", url]}
+ end
+
+ def open_url_command(url, _os_type, find_executable) do
+ case find_executable.("open") || find_executable.("xdg-open") do
+ nil -> nil
+ command -> {command, [url]}
+ end
+ end
+
+ @doc false
+ @spec windows_clipboard_command(String.t(), tuple(), (String.t() -> String.t() | nil)) ::
+ {String.t(), [String.t()], [{String.t(), String.t()}]} | nil
+ def windows_clipboard_command(text, {:win32, _}, find_executable) do
+ command = find_executable.("powershell.exe") || "powershell.exe"
+ args = ["-NoProfile", "-Command", "Set-Clipboard -Value $env:OUROCODE_CLIPBOARD_TEXT"]
+
+ {command, args, [{"OUROCODE_CLIPBOARD_TEXT", text}]}
+ end
+
+ def windows_clipboard_command(_text, _os_type, _find_executable), do: nil
+
defp clipboard_command do
System.find_executable("pbcopy") ||
if(File.exists?("/usr/bin/pbcopy"), do: "/usr/bin/pbcopy")
diff --git a/rust/ourocode_ipc/Cargo.lock b/rust/ourocode_ipc/Cargo.lock
index 9cae354..09e60eb 100644
--- a/rust/ourocode_ipc/Cargo.lock
+++ b/rust/ourocode_ipc/Cargo.lock
@@ -26,6 +26,7 @@ version = "0.1.0"
dependencies = [
"libc",
"serde_json",
+ "windows-sys",
]
[[package]]
@@ -105,6 +106,79 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
[[package]]
name = "zmij"
version = "1.0.21"
diff --git a/rust/ourocode_ipc/Cargo.toml b/rust/ourocode_ipc/Cargo.toml
index ecf76e8..bf4f4d3 100644
--- a/rust/ourocode_ipc/Cargo.toml
+++ b/rust/ourocode_ipc/Cargo.toml
@@ -13,6 +13,17 @@ path = "src/lib.rs"
name = "ourocode_tty"
path = "src/bin/ourocode_tty.rs"
+[[bin]]
+name = "ourocode"
+path = "src/bin/ourocode.rs"
+
[dependencies]
serde_json = "1"
libc = "0.2"
+windows-sys = { version = "0.59", features = [
+ "Win32_Foundation",
+ "Win32_Security",
+ "Win32_Storage_FileSystem",
+ "Win32_System_Console",
+ "Win32_System_IO",
+] }
diff --git a/rust/ourocode_ipc/src/bin/ourocode.rs b/rust/ourocode_ipc/src/bin/ourocode.rs
new file mode 100644
index 0000000..ada38f6
--- /dev/null
+++ b/rust/ourocode_ipc/src/bin/ourocode.rs
@@ -0,0 +1,240 @@
+use std::env;
+use std::ffi::OsString;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::{Command, ExitCode};
+
+fn main() -> ExitCode {
+ match run() {
+ Ok(code) => ExitCode::from(code),
+ Err(message) => {
+ eprintln!("ourocode.exe: {message}");
+ ExitCode::from(1)
+ }
+ }
+}
+
+fn run() -> Result {
+ let exe =
+ env::current_exe().map_err(|error| format!("could not locate executable: {error}"))?;
+ let exe_dir = exe
+ .parent()
+ .ok_or_else(|| format!("could not resolve executable directory: {}", exe.display()))?;
+ let escript = resolve_escript(exe_dir)
+ .ok_or_else(|| format!("could not find ourocode escript near {}", exe_dir.display()))?;
+ let tty = resolve_tty(exe_dir);
+ let escript_runner = resolve_escript_runner().ok_or_else(|| {
+ "could not find escript.exe; set ESCRIPT or install Erlang/Elixir".to_owned()
+ })?;
+
+ let mut command = Command::new(escript_runner);
+ command.arg(escript).args(env::args_os().skip(1));
+
+ if env::var_os("OUROCODE_TTY").is_none() {
+ if let Some(path) = tty {
+ command.env("OUROCODE_TTY", path);
+ }
+ }
+
+ let status = command
+ .status()
+ .map_err(|error| format!("failed to start escript.exe: {error}"))?;
+ Ok(exit_code(status.code()))
+}
+
+fn exit_code(code: Option) -> u8 {
+ match code {
+ Some(value) if (0..=255).contains(&value) => value as u8,
+ Some(_) => 1,
+ None => 1,
+ }
+}
+
+fn resolve_escript(exe_dir: &Path) -> Option {
+ candidate_escripts(exe_dir)
+ .into_iter()
+ .find(|candidate| candidate.is_file())
+}
+
+fn candidate_escripts(exe_dir: &Path) -> [PathBuf; 2] {
+ [
+ exe_dir.join("ourocode"),
+ exe_dir.parent().map_or_else(
+ || exe_dir.join("ourocode"),
+ |parent| parent.join("ourocode"),
+ ),
+ ]
+}
+
+fn resolve_escript_runner() -> Option {
+ if let Some(path) = env::var_os("ESCRIPT") {
+ return Some(path);
+ }
+
+ candidate_escript_runners()
+ .into_iter()
+ .find(|candidate| candidate.is_file())
+ .map(PathBuf::into_os_string)
+ .or_else(|| Some(OsString::from("escript.exe")))
+}
+
+fn candidate_escript_runners() -> Vec {
+ let mut candidates = path_escript_candidates();
+
+ if let Some(user_profile) = env::var_os("USERPROFILE") {
+ let otp_root = PathBuf::from(user_profile)
+ .join(".elixir-install")
+ .join("installs")
+ .join("otp");
+ candidates.extend(versioned_escript_candidates(&otp_root));
+ }
+
+ candidates.extend(program_files_escript_candidates("ProgramFiles"));
+ candidates.extend(program_files_escript_candidates("ProgramFiles(x86)"));
+ candidates
+}
+
+fn path_escript_candidates() -> Vec {
+ env::var_os("PATH")
+ .map(|path| {
+ env::split_paths(&path)
+ .map(|entry| entry.join("escript.exe"))
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+fn versioned_escript_candidates(root: &Path) -> Vec {
+ let mut versions = match fs::read_dir(root) {
+ Ok(entries) => entries
+ .filter_map(|entry| entry.ok().map(|entry| entry.path()))
+ .collect::>(),
+ Err(_error) => Vec::new(),
+ };
+
+ versions.sort();
+ versions.reverse();
+
+ versions
+ .into_iter()
+ .flat_map(|version| version_escript_candidates(&version))
+ .collect()
+}
+
+fn version_escript_candidates(version: &Path) -> Vec {
+ let mut candidates = vec![version.join("bin").join("escript.exe")];
+
+ let mut erts_dirs = match fs::read_dir(version) {
+ Ok(entries) => entries
+ .filter_map(|entry| entry.ok().map(|entry| entry.path()))
+ .filter(|path| {
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.starts_with("erts-"))
+ })
+ .collect::>(),
+ Err(_error) => Vec::new(),
+ };
+
+ erts_dirs.sort();
+ erts_dirs.reverse();
+ candidates.extend(
+ erts_dirs
+ .into_iter()
+ .map(|path| path.join("bin").join("escript.exe")),
+ );
+ candidates
+}
+
+fn program_files_escript_candidates(var_name: &str) -> Vec {
+ env::var_os(var_name)
+ .map(|program_files| {
+ vec![
+ PathBuf::from(&program_files)
+ .join("Erlang OTP")
+ .join("bin")
+ .join("escript.exe"),
+ PathBuf::from(program_files)
+ .join("Erlang OTP")
+ .join("erts-17.0.2")
+ .join("bin")
+ .join("escript.exe"),
+ ]
+ })
+ .unwrap_or_default()
+}
+
+fn resolve_tty(exe_dir: &Path) -> Option {
+ candidate_ttys(exe_dir)
+ .into_iter()
+ .find(|candidate| candidate.is_file())
+}
+
+fn candidate_ttys(exe_dir: &Path) -> [PathBuf; 2] {
+ [
+ exe_dir.join("ourocode_tty.exe"),
+ exe_dir.join("bin").join("ourocode_tty.exe"),
+ ]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{candidate_escripts, candidate_ttys, exit_code, versioned_escript_candidates};
+ use std::fs;
+ use std::path::{Path, PathBuf};
+
+ #[test]
+ fn resolves_escript_candidates_for_source_and_installed_layouts() {
+ let candidates = candidate_escripts(Path::new("C:/tools/ourocode/bin"));
+
+ assert_eq!(candidates[0], Path::new("C:/tools/ourocode/bin/ourocode"));
+ assert_eq!(candidates[1], Path::new("C:/tools/ourocode/ourocode"));
+ }
+
+ #[test]
+ fn resolves_tty_candidates_for_source_and_installed_layouts() {
+ let candidates = candidate_ttys(Path::new("C:/tools/ourocode"));
+
+ assert_eq!(
+ candidates[0],
+ Path::new("C:/tools/ourocode/ourocode_tty.exe")
+ );
+ assert_eq!(
+ candidates[1],
+ Path::new("C:/tools/ourocode/bin/ourocode_tty.exe")
+ );
+ }
+
+ #[test]
+ fn normalizes_process_exit_codes() {
+ assert_eq!(exit_code(Some(0)), 0);
+ assert_eq!(exit_code(Some(255)), 255);
+ assert_eq!(exit_code(Some(256)), 1);
+ assert_eq!(exit_code(None), 1);
+ }
+
+ #[test]
+ fn searches_newest_elixir_installed_otp_first() {
+ let root = test_root("ourocode-launcher-otp");
+ let old = root.join("28.0.1");
+ let new = root.join("29.0.2");
+ fs::create_dir_all(old.join("bin")).expect("create old bin");
+ fs::create_dir_all(new.join("bin")).expect("create new bin");
+ fs::create_dir_all(new.join("erts-17.0.2").join("bin")).expect("create erts bin");
+
+ let candidates = versioned_escript_candidates(&root);
+
+ assert_eq!(candidates[0], new.join("bin").join("escript.exe"));
+ assert_eq!(
+ candidates[1],
+ new.join("erts-17.0.2").join("bin").join("escript.exe")
+ );
+ assert_eq!(candidates[2], old.join("bin").join("escript.exe"));
+
+ fs::remove_dir_all(root).expect("remove test root");
+ }
+
+ fn test_root(name: &str) -> PathBuf {
+ std::env::temp_dir().join(format!("{name}-{}", std::process::id()))
+ }
+}
diff --git a/rust/ourocode_ipc/src/bin/ourocode_tty.rs b/rust/ourocode_ipc/src/bin/ourocode_tty.rs
index f287720..210466e 100644
--- a/rust/ourocode_ipc/src/bin/ourocode_tty.rs
+++ b/rust/ourocode_ipc/src/bin/ourocode_tty.rs
@@ -6,128 +6,948 @@
//! a Port-spawned child has no controlling terminal so `/dev/tty` is not an
//! option either.
//!
-//! So we use the terminal fds the BEAM already holds. Erlang's
-//! `:nouse_stdio` port option leaves fds 0/1/2 inherited from the BEAM (the
-//! real terminal) and moves the Erlang<->port protocol to fds 3 and 4.
-//! termios works on any tty fd whether or not it is the ctty, so we set raw
-//! mode directly on fd 0.
+//! On Unix, we use the terminal fds the BEAM already holds. Erlang's
+//! `:nouse_stdio` port option leaves fds 0/1/2 inherited from the BEAM
+//! (the real terminal) and moves the Erlang<->port protocol to fds 3 and 4.
+//! termios works on any tty fd whether or not it is the ctty, so the Unix
+//! helper sets raw mode directly on fd 0.
//!
//! * fd 0 terminal input (keystrokes; raw termios set here)
//! * fd 1 terminal output (frames written verbatim)
//! * fd 3 protocol in (frames from Elixir)
//! * fd 4 protocol out (size header then key byte stream to Elixir)
//!
+//! On Windows, Port stdio remains the protocol pipe on fd 0/1. Console input
+//! and output are acquired separately through CONIN$/CONOUT$ or real console
+//! std handles.
+//!
//! Protocol: first thing written to fd 4 is " \n"; everything
//! after is the raw key stream. Restores the original termios on exit.
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::{mem, process, ptr, thread};
+#[cfg(unix)]
+mod unix {
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use std::{mem, process, ptr, thread};
+
+ const TTY_IN: libc::c_int = 0;
+ const TTY_OUT: libc::c_int = 1;
+ const PROTO_IN: libc::c_int = 3;
+ const PROTO_OUT: libc::c_int = 4;
+
+ static mut SAVED: Option = None;
+ static RESTORED: AtomicBool = AtomicBool::new(false);
+
+ unsafe fn restore() {
+ if RESTORED.swap(true, Ordering::SeqCst) {
+ return;
+ }
+ if let Some(saved) = SAVED {
+ libc::tcsetattr(TTY_IN, libc::TCSANOW, &saved);
+ }
+ let seq = b"\x1b[?25h\x1b[?1049l";
+ libc::write(TTY_OUT, seq.as_ptr() as *const libc::c_void, seq.len());
+ }
-const TTY_IN: libc::c_int = 0;
-const TTY_OUT: libc::c_int = 1;
-const PROTO_IN: libc::c_int = 3;
-const PROTO_OUT: libc::c_int = 4;
+ extern "C" fn on_signal(_sig: libc::c_int) {
+ unsafe { restore() };
+ process::exit(0);
+ }
-static mut SAVED: Option = None;
-static RESTORED: AtomicBool = AtomicBool::new(false);
+ fn install_signal(sig: libc::c_int) {
+ unsafe {
+ let mut sa: libc::sigaction = mem::zeroed();
+ sa.sa_sigaction = on_signal as *const () as usize;
+ libc::sigemptyset(&mut sa.sa_mask);
+ libc::sigaction(sig, &sa, ptr::null_mut());
+ }
+ }
-unsafe fn restore() {
- if RESTORED.swap(true, Ordering::SeqCst) {
- return;
+ fn write_all(fd: libc::c_int, buf: &[u8]) -> bool {
+ let mut off = 0usize;
+ while off < buf.len() {
+ let w = unsafe {
+ libc::write(
+ fd,
+ buf.as_ptr().add(off) as *const libc::c_void,
+ buf.len() - off,
+ )
+ };
+ if w <= 0 {
+ return false;
+ }
+ off += w as usize;
+ }
+ true
}
- if let Some(saved) = SAVED {
- libc::tcsetattr(TTY_IN, libc::TCSANOW, &saved);
+
+ pub fn run() {
+ // fd 0 must be a terminal. If not (piped / no tty), bail so Elixir falls
+ // back to the plain renderer.
+ if unsafe { libc::isatty(TTY_IN) } != 1 {
+ process::exit(1);
+ }
+
+ unsafe {
+ let mut term: libc::termios = mem::zeroed();
+ if libc::tcgetattr(TTY_IN, &mut term) != 0 {
+ process::exit(1);
+ }
+ SAVED = Some(term);
+
+ let mut raw = term;
+ libc::cfmakeraw(&mut raw);
+ libc::tcsetattr(TTY_IN, libc::TCSANOW, &raw);
+ }
+
+ for sig in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP, libc::SIGPIPE] {
+ install_signal(sig);
+ }
+
+ let (cols, rows) = unsafe {
+ let mut ws: libc::winsize = mem::zeroed();
+ if libc::ioctl(TTY_OUT, libc::TIOCGWINSZ, &mut ws) == 0
+ && ws.ws_col > 0
+ && ws.ws_row > 0
+ {
+ (ws.ws_col, ws.ws_row)
+ } else {
+ (120u16, 40u16)
+ }
+ };
+ write_all(PROTO_OUT, format!("{} {}\n", cols, rows).as_bytes());
+
+ // terminal input -> Elixir
+ thread::spawn(move || {
+ let mut buf = [0u8; 4096];
+ loop {
+ let n =
+ unsafe { libc::read(TTY_IN, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
+ if n <= 0 || !write_all(PROTO_OUT, &buf[..n as usize]) {
+ process::exit(0);
+ }
+ }
+ });
+
+ // Elixir frames -> terminal. EOF means Elixir is done.
+ let mut buf = [0u8; 16384];
+ loop {
+ let n =
+ unsafe { libc::read(PROTO_IN, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
+ if n <= 0 || !write_all(TTY_OUT, &buf[..n as usize]) {
+ break;
+ }
+ }
+
+ unsafe { restore() };
+ process::exit(0);
}
- let seq = b"\x1b[?25h\x1b[?1049l";
- libc::write(TTY_OUT, seq.as_ptr() as *const libc::c_void, seq.len());
}
-extern "C" fn on_signal(_sig: libc::c_int) {
- unsafe { restore() };
- process::exit(0);
+#[cfg(unix)]
+fn main() {
+ unix::run();
}
-fn install_signal(sig: libc::c_int) {
- unsafe {
- let mut sa: libc::sigaction = mem::zeroed();
- sa.sa_sigaction = on_signal as *const () as usize;
- libc::sigemptyset(&mut sa.sa_mask);
- libc::sigaction(sig, &sa, ptr::null_mut());
- }
+#[cfg(windows)]
+fn main() {
+ windows::run();
}
-fn write_all(fd: libc::c_int, buf: &[u8]) -> bool {
- let mut off = 0usize;
- while off < buf.len() {
- let w = unsafe {
- libc::write(
- fd,
- buf.as_ptr().add(off) as *const libc::c_void,
- buf.len() - off,
+#[cfg(not(any(unix, windows)))]
+fn main() {
+ eprintln!("ourocode_tty: unsupported platform; falling back");
+ std::process::exit(1);
+}
+
+#[cfg(windows)]
+mod windows {
+ use std::ffi::c_void;
+ use std::mem::MaybeUninit;
+ use std::process;
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use std::thread;
+
+ use windows_sys::Win32::Foundation::{
+ CloseHandle, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE,
+ };
+ use windows_sys::Win32::Storage::FileSystem::{
+ CreateFileW, WriteFile, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
+ };
+ use windows_sys::Win32::System::Console::{
+ AttachConsole, GetConsoleMode, GetConsoleScreenBufferInfo, GetStdHandle, ReadConsoleInputW,
+ SetConsoleCtrlHandler, SetConsoleMode, ATTACH_PARENT_PROCESS, CONSOLE_SCREEN_BUFFER_INFO,
+ ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_MOUSE_INPUT, ENABLE_PROCESSED_INPUT,
+ ENABLE_VIRTUAL_TERMINAL_INPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, ENABLE_WINDOW_INPUT,
+ FROM_LEFT_1ST_BUTTON_PRESSED, INPUT_RECORD, KEY_EVENT, MOUSE_EVENT, STD_INPUT_HANDLE,
+ STD_OUTPUT_HANDLE, WINDOW_BUFFER_SIZE_EVENT,
+ };
+
+ const PROTO_IN: libc::c_int = 0;
+ const PROTO_OUT: libc::c_int = 1;
+ const CONSOLE_READ_WRITE: u32 = GENERIC_READ | GENERIC_WRITE;
+
+ static RESTORED: AtomicBool = AtomicBool::new(false);
+ static mut INPUT_HANDLE: HANDLE = std::ptr::null_mut();
+ static mut OUTPUT_HANDLE: HANDLE = std::ptr::null_mut();
+ static mut INPUT_MODE: u32 = 0;
+ static mut OUTPUT_MODE: u32 = 0;
+ static mut CLOSE_INPUT_HANDLE: bool = false;
+ static mut CLOSE_OUTPUT_HANDLE: bool = false;
+
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
+ enum ConsoleDevice {
+ Input,
+ Output,
+ }
+
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
+ enum ConsoleHandleCandidate {
+ NamedConsole { name: &'static str, access: u32 },
+ StdHandle(u32),
+ }
+
+ struct AcquiredConsoleHandle {
+ handle: HANDLE,
+ close_on_restore: bool,
+ }
+
+ enum WindowsConsoleEvent {
+ Key(WindowsKeyEvent),
+ Resize(WindowsResizeEvent),
+ Mouse(WindowsMouseEvent),
+ }
+
+ struct WindowsKeyEvent {
+ key_down: bool,
+ unicode_char: Option,
+ }
+
+ struct WindowsResizeEvent {
+ columns: i16,
+ rows: i16,
+ }
+
+ struct WindowsMouseEvent {
+ column: i16,
+ row: i16,
+ button: MouseButton,
+ state: MouseButtonState,
+ }
+
+ enum MouseButton {
+ Left,
+ }
+
+ enum MouseButtonState {
+ Pressed,
+ }
+
+ struct ConsoleGuard;
+
+ impl Drop for ConsoleGuard {
+ fn drop(&mut self) {
+ restore();
+ }
+ }
+
+ fn translate_windows_console_event(event: WindowsConsoleEvent) -> Option> {
+ match event {
+ WindowsConsoleEvent::Key(event) => translate_key_event(event),
+ WindowsConsoleEvent::Resize(event) => translate_resize_event(event),
+ WindowsConsoleEvent::Mouse(event) => translate_mouse_event(event),
+ }
+ }
+
+ fn input_record_to_windows_console_event(record: &INPUT_RECORD) -> Option {
+ match u32::from(record.EventType) {
+ KEY_EVENT => {
+ // SAFETY: Category 8 - FFI boundary union access. Win32 sets
+ // EventType to KEY_EVENT only when Event.KeyEvent is the active
+ // INPUT_RECORD union field; tests construct the same invariant.
+ let event = unsafe { record.Event.KeyEvent };
+ // SAFETY: Category 8 - FFI boundary union access. The `W`
+ // console APIs document UnicodeChar as the active key char
+ // representation for KEY_EVENT_RECORD.
+ let unicode = unsafe { event.uChar.UnicodeChar };
+ let unicode_char = if unicode == 0 {
+ None
+ } else {
+ char::from_u32(u32::from(unicode))
+ };
+
+ Some(WindowsConsoleEvent::Key(WindowsKeyEvent {
+ key_down: event.bKeyDown != 0,
+ unicode_char,
+ }))
+ }
+ WINDOW_BUFFER_SIZE_EVENT => {
+ // SAFETY: Category 8 - FFI boundary union access. Win32 sets
+ // EventType to WINDOW_BUFFER_SIZE_EVENT only when
+ // Event.WindowBufferSizeEvent is the active union field.
+ let event = unsafe { record.Event.WindowBufferSizeEvent };
+
+ Some(WindowsConsoleEvent::Resize(WindowsResizeEvent {
+ columns: event.dwSize.X,
+ rows: event.dwSize.Y,
+ }))
+ }
+ MOUSE_EVENT => {
+ // SAFETY: Category 8 - FFI boundary union access. Win32 sets
+ // EventType to MOUSE_EVENT only when Event.MouseEvent is the
+ // active INPUT_RECORD union field.
+ let event = unsafe { record.Event.MouseEvent };
+ if event.dwButtonState & FROM_LEFT_1ST_BUTTON_PRESSED == 0 {
+ return None;
+ }
+
+ Some(WindowsConsoleEvent::Mouse(WindowsMouseEvent {
+ column: event.dwMousePosition.X,
+ row: event.dwMousePosition.Y,
+ button: MouseButton::Left,
+ state: MouseButtonState::Pressed,
+ }))
+ }
+ _ => None,
+ }
+ }
+
+ fn translate_key_event(event: WindowsKeyEvent) -> Option> {
+ if !event.key_down {
+ return None;
+ }
+
+ event.unicode_char.map(|ch| {
+ let mut encoded = [0u8; 4];
+ ch.encode_utf8(&mut encoded).as_bytes().to_vec()
+ })
+ }
+
+ fn translate_resize_event(event: WindowsResizeEvent) -> Option> {
+ if event.columns <= 0 || event.rows <= 0 {
+ return None;
+ }
+
+ Some(
+ format!(
+ "\x1b]777;ourocode-resize={}x{}\x07",
+ event.columns, event.rows
)
+ .into_bytes(),
+ )
+ }
+
+ fn translate_mouse_event(event: WindowsMouseEvent) -> Option> {
+ if event.column < 0 || event.row < 0 {
+ return None;
+ }
+
+ let button_code = match event.button {
+ MouseButton::Left => 0,
};
- if w <= 0 {
- return false;
+ let suffix = match event.state {
+ MouseButtonState::Pressed => 'M',
+ };
+ let column = i32::from(event.column) + 1;
+ let row = i32::from(event.row) + 1;
+
+ Some(format!("\x1b[<{button_code};{column};{row}{suffix}").into_bytes())
+ }
+
+ fn restore() {
+ if RESTORED.swap(true, Ordering::SeqCst) {
+ return;
+ }
+
+ // SAFETY: Category 8 - FFI boundary. The handles and modes are copied
+ // from successful GetStdHandle/GetConsoleMode calls during setup and
+ // never mutated after the control handler is installed.
+ unsafe {
+ if !INPUT_HANDLE.is_null() && INPUT_HANDLE != INVALID_HANDLE_VALUE {
+ SetConsoleMode(INPUT_HANDLE, INPUT_MODE);
+ }
+ if !OUTPUT_HANDLE.is_null() && OUTPUT_HANDLE != INVALID_HANDLE_VALUE {
+ SetConsoleMode(OUTPUT_HANDLE, OUTPUT_MODE);
+ }
+
+ let _ = write_console(b"\x1b[?25h\x1b[?1049l");
+
+ if CLOSE_INPUT_HANDLE && !INPUT_HANDLE.is_null() && INPUT_HANDLE != INVALID_HANDLE_VALUE
+ {
+ CloseHandle(INPUT_HANDLE);
+ CLOSE_INPUT_HANDLE = false;
+ }
+ if CLOSE_OUTPUT_HANDLE
+ && !OUTPUT_HANDLE.is_null()
+ && OUTPUT_HANDLE != INVALID_HANDLE_VALUE
+ {
+ CloseHandle(OUTPUT_HANDLE);
+ CLOSE_OUTPUT_HANDLE = false;
+ }
}
- off += w as usize;
}
- true
-}
-fn main() {
- // fd 0 must be a terminal. If not (piped / no tty), bail so Elixir falls
- // back to the plain renderer.
- if unsafe { libc::isatty(TTY_IN) } != 1 {
- process::exit(1);
+ unsafe extern "system" fn on_console_ctrl(_ctrl_type: u32) -> i32 {
+ restore();
+ 0
}
- unsafe {
- let mut term: libc::termios = mem::zeroed();
- if libc::tcgetattr(TTY_IN, &mut term) != 0 {
- process::exit(1);
+ fn exit_after_restore(code: i32) -> ! {
+ restore();
+ process::exit(code);
+ }
+
+ fn write_proto(buf: &[u8]) -> bool {
+ let mut off = 0usize;
+ while off < buf.len() {
+ let remaining = buf.len() - off;
+ let chunk = remaining.min(i32::MAX as usize);
+ let written = unsafe {
+ libc::write(
+ PROTO_OUT,
+ buf.as_ptr().add(off) as *const c_void,
+ chunk as libc::c_uint,
+ )
+ };
+ if written <= 0 {
+ return false;
+ }
+ off += written as usize;
}
- SAVED = Some(term);
+ true
+ }
- let mut raw = term;
- libc::cfmakeraw(&mut raw);
- libc::tcsetattr(TTY_IN, libc::TCSANOW, &raw);
+ fn read_proto(buf: &mut [u8]) -> isize {
+ (unsafe {
+ libc::read(
+ PROTO_IN,
+ buf.as_mut_ptr() as *mut c_void,
+ buf.len() as libc::c_uint,
+ )
+ }) as isize
}
- for sig in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP, libc::SIGPIPE] {
- install_signal(sig);
+ fn write_console(buf: &[u8]) -> bool {
+ let handle = unsafe { OUTPUT_HANDLE };
+ if handle.is_null() || handle == INVALID_HANDLE_VALUE {
+ return false;
+ }
+
+ let mut off = 0usize;
+ while off < buf.len() {
+ let chunk = (buf.len() - off).min(u32::MAX as usize);
+ let mut written = 0u32;
+ let ok = unsafe {
+ WriteFile(
+ handle,
+ buf.as_ptr().add(off),
+ chunk as u32,
+ &mut written,
+ std::ptr::null_mut(),
+ )
+ };
+ if ok == 0 || written == 0 {
+ return false;
+ }
+ off += written as usize;
+ }
+ true
}
- let (cols, rows) = unsafe {
- let mut ws: libc::winsize = mem::zeroed();
- if libc::ioctl(TTY_OUT, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 && ws.ws_row > 0 {
- (ws.ws_col, ws.ws_row)
+ fn read_console_event() -> Result